feat: 实现受控真实下单安全边界 (#99)
This commit is contained in:
@@ -56,8 +56,8 @@ class ClaimCapabilities:
|
|||||||
raise ValueError("supported_types 不能为空")
|
raise ValueError("supported_types 不能为空")
|
||||||
if any(not isinstance(value, TaskType) for value in self.supported_types):
|
if any(not isinstance(value, TaskType) for value in self.supported_types):
|
||||||
raise ValueError("supported_types 必须使用 TaskType")
|
raise ValueError("supported_types 必须使用 TaskType")
|
||||||
if self.purchase_mode != "dry_run":
|
if self.purchase_mode not in {"dry_run", "live"}:
|
||||||
raise ValueError("当前版本只允许 purchase_mode=dry_run")
|
raise ValueError("purchase_mode 只允许 dry_run 或 live")
|
||||||
if not self.schema_versions or any(
|
if not self.schema_versions or any(
|
||||||
version <= 0 for version in self.schema_versions
|
version <= 0 for version in self.schema_versions
|
||||||
):
|
):
|
||||||
@@ -75,6 +75,7 @@ class AdminTask:
|
|||||||
payload: Mapping[str, Any] = field(default_factory=dict)
|
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||||
created_at: str = ""
|
created_at: str = ""
|
||||||
updated_at: str = ""
|
updated_at: str = ""
|
||||||
|
execution_mode: str = "dry_run"
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not self.task_id.strip():
|
if not self.task_id.strip():
|
||||||
@@ -83,6 +84,13 @@ class AdminTask:
|
|||||||
raise ValueError("task_type 必须使用 TaskType")
|
raise ValueError("task_type 必须使用 TaskType")
|
||||||
if self.version <= 0:
|
if self.version <= 0:
|
||||||
raise ValueError("version 必须大于 0")
|
raise ValueError("version 必须大于 0")
|
||||||
|
if self.execution_mode not in {"dry_run", "live"}:
|
||||||
|
raise ValueError("execution_mode 只允许 dry_run 或 live")
|
||||||
|
if (
|
||||||
|
self.task_type is not TaskType.PURCHASE
|
||||||
|
and self.execution_mode != "dry_run"
|
||||||
|
):
|
||||||
|
raise ValueError("只有采购任务允许 execution_mode=live")
|
||||||
if not isinstance(self.payload, Mapping):
|
if not isinstance(self.payload, Mapping):
|
||||||
raise ValueError("payload 必须是对象")
|
raise ValueError("payload 必须是对象")
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -4,7 +4,7 @@
|
|||||||
``MIGRATIONS`` 末尾增加版本,不能修改已经发布的迁移。
|
``MIGRATIONS`` 末尾增加版本,不能修改已经发布的迁移。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SCHEMA_VERSION = 2
|
SCHEMA_VERSION = 3
|
||||||
|
|
||||||
|
|
||||||
MIGRATION_1 = (
|
MIGRATION_1 = (
|
||||||
@@ -134,7 +134,16 @@ MIGRATION_2 = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATION_3 = (
|
||||||
|
"""
|
||||||
|
ALTER TABLE pdd_tasks ADD COLUMN execution_mode TEXT NOT NULL DEFAULT 'dry_run'
|
||||||
|
CHECK (execution_mode IN ('dry_run', 'live'))
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS = {
|
MIGRATIONS = {
|
||||||
1: MIGRATION_1,
|
1: MIGRATION_1,
|
||||||
2: MIGRATION_2,
|
2: MIGRATION_2,
|
||||||
|
3: MIGRATION_3,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ class HttpAdminGateway(AdminGateway):
|
|||||||
raw_type = task.get("type")
|
raw_type = task.get("type")
|
||||||
version = task.get("version")
|
version = task.get("version")
|
||||||
priority = task.get("priority")
|
priority = task.get("priority")
|
||||||
|
execution_mode = task.get("execution_mode", "dry_run")
|
||||||
task_payload = task.get("payload")
|
task_payload = task.get("payload")
|
||||||
created_at = task.get("created_at")
|
created_at = task.get("created_at")
|
||||||
updated_at = task.get("updated_at")
|
updated_at = task.get("updated_at")
|
||||||
@@ -373,6 +374,7 @@ class HttpAdminGateway(AdminGateway):
|
|||||||
and version > 0
|
and version > 0
|
||||||
and isinstance(priority, int)
|
and isinstance(priority, int)
|
||||||
and not isinstance(priority, bool)
|
and not isinstance(priority, bool)
|
||||||
|
and isinstance(execution_mode, str)
|
||||||
and isinstance(task_payload, Mapping)
|
and isinstance(task_payload, Mapping)
|
||||||
and isinstance(created_at, str)
|
and isinstance(created_at, str)
|
||||||
and isinstance(updated_at, str)
|
and isinstance(updated_at, str)
|
||||||
@@ -395,6 +397,16 @@ class HttpAdminGateway(AdminGateway):
|
|||||||
request_id,
|
request_id,
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
if execution_mode not in {"dry_run", "live"} or (
|
||||||
|
task_type is not TaskType.PURCHASE and execution_mode != "dry_run"
|
||||||
|
):
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_INVALID_RESPONSE",
|
||||||
|
"Admin 返回了不支持的任务执行模式",
|
||||||
|
False,
|
||||||
|
request_id,
|
||||||
|
)
|
||||||
|
|
||||||
cls._validate_claim_payload(task_type, task_payload, request_id)
|
cls._validate_claim_payload(task_type, task_payload, request_id)
|
||||||
|
|
||||||
return AdminTask(
|
return AdminTask(
|
||||||
@@ -402,6 +414,7 @@ class HttpAdminGateway(AdminGateway):
|
|||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
version=version,
|
version=version,
|
||||||
priority=priority,
|
priority=priority,
|
||||||
|
execution_mode=execution_mode,
|
||||||
payload=dict(task_payload),
|
payload=dict(task_payload),
|
||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
updated_at=updated_at,
|
updated_at=updated_at,
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""真实下单的本地授权状态。
|
||||||
|
|
||||||
|
授权只来自设置页的明确确认,并绑定当前 Client ID 和 Android 设备。
|
||||||
|
缺少、损坏或不匹配的设置一律按关闭处理。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .settings_repository import SettingsRepository
|
||||||
|
|
||||||
|
|
||||||
|
LIVE_ENABLED_KEY = "purchase.live_enabled"
|
||||||
|
LIVE_CLIENT_ID_KEY = "purchase.live_client_id"
|
||||||
|
LIVE_DEVICE_SERIAL_KEY = "purchase.live_device_serial"
|
||||||
|
LIVE_CONFIRMED_AT_KEY = "purchase.live_confirmed_at"
|
||||||
|
LIVE_CONFIRMATION_TEXT = "创建未付款订单"
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LivePurchaseAuthorization:
|
||||||
|
"""设置页展示和运行时核对使用的授权快照。"""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
client_id: str = ""
|
||||||
|
device_serial: str = ""
|
||||||
|
confirmed_at: str = ""
|
||||||
|
|
||||||
|
def matches(self, client_id: str, device_serial: str) -> bool:
|
||||||
|
"""只有完整授权和两项绑定完全一致时才返回 True。"""
|
||||||
|
|
||||||
|
return (
|
||||||
|
self.enabled
|
||||||
|
and bool(self.confirmed_at)
|
||||||
|
and self.client_id == str(client_id or "").strip()
|
||||||
|
and self.device_serial == str(device_serial or "").strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LivePurchaseAuthorizationService:
|
||||||
|
"""保存、关闭并校验本机真实下单授权。"""
|
||||||
|
|
||||||
|
def __init__(self, repository: SettingsRepository):
|
||||||
|
self._repository = repository
|
||||||
|
|
||||||
|
def load(self) -> LivePurchaseAuthorization:
|
||||||
|
"""读取授权;任何字段异常都安全降级为关闭。"""
|
||||||
|
|
||||||
|
enabled = self._repository.get(LIVE_ENABLED_KEY, False)
|
||||||
|
client_id = self._repository.get(LIVE_CLIENT_ID_KEY, "")
|
||||||
|
serial = self._repository.get(LIVE_DEVICE_SERIAL_KEY, "")
|
||||||
|
confirmed_at = self._repository.get(LIVE_CONFIRMED_AT_KEY, "")
|
||||||
|
if enabled is not True:
|
||||||
|
return LivePurchaseAuthorization()
|
||||||
|
if not all(
|
||||||
|
isinstance(value, str)
|
||||||
|
for value in (client_id, serial, confirmed_at)
|
||||||
|
):
|
||||||
|
return LivePurchaseAuthorization()
|
||||||
|
normalized = LivePurchaseAuthorization(
|
||||||
|
True,
|
||||||
|
client_id.strip(),
|
||||||
|
serial.strip(),
|
||||||
|
confirmed_at.strip(),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not normalized.client_id
|
||||||
|
or not normalized.device_serial
|
||||||
|
or not normalized.confirmed_at
|
||||||
|
):
|
||||||
|
return LivePurchaseAuthorization()
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def enable(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
device_serial: str,
|
||||||
|
confirmation_text: str,
|
||||||
|
) -> LivePurchaseAuthorization:
|
||||||
|
"""精确核对确认文字后,原子保存绑定和确认时间。"""
|
||||||
|
|
||||||
|
checked_client = str(client_id or "").strip()
|
||||||
|
checked_serial = str(device_serial or "").strip()
|
||||||
|
if not checked_client:
|
||||||
|
raise ValueError("请先保存当前 Client 设备号")
|
||||||
|
if not checked_serial:
|
||||||
|
raise ValueError("请先选择并保存 Android 设备")
|
||||||
|
if confirmation_text.strip() != LIVE_CONFIRMATION_TEXT:
|
||||||
|
raise ValueError(f"请输入“{LIVE_CONFIRMATION_TEXT}”确认")
|
||||||
|
confirmed_at = _utc_now_iso()
|
||||||
|
self._repository.set_many(
|
||||||
|
{
|
||||||
|
LIVE_ENABLED_KEY: True,
|
||||||
|
LIVE_CLIENT_ID_KEY: checked_client,
|
||||||
|
LIVE_DEVICE_SERIAL_KEY: checked_serial,
|
||||||
|
LIVE_CONFIRMED_AT_KEY: confirmed_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return LivePurchaseAuthorization(
|
||||||
|
True, checked_client, checked_serial, confirmed_at
|
||||||
|
)
|
||||||
|
|
||||||
|
def disable(self) -> LivePurchaseAuthorization:
|
||||||
|
"""立即关闭能力;历史绑定不用于重新启用。"""
|
||||||
|
|
||||||
|
self._repository.set(LIVE_ENABLED_KEY, False)
|
||||||
|
return LivePurchaseAuthorization()
|
||||||
|
|
||||||
|
def purchase_mode_for(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
device_serial: str,
|
||||||
|
*,
|
||||||
|
live_adapter_ready: bool,
|
||||||
|
) -> str:
|
||||||
|
"""集中计算对 Admin 声明的能力,默认永远是 dry_run。"""
|
||||||
|
|
||||||
|
if live_adapter_ready and self.load().matches(client_id, device_serial):
|
||||||
|
return "live"
|
||||||
|
return "dry_run"
|
||||||
@@ -143,6 +143,11 @@ class MockAdminGateway(AdminGateway):
|
|||||||
continue
|
continue
|
||||||
if item.task.task_type not in capabilities.supported_types:
|
if item.task.task_type not in capabilities.supported_types:
|
||||||
continue
|
continue
|
||||||
|
if (
|
||||||
|
item.task.execution_mode == "live"
|
||||||
|
and capabilities.purchase_mode != "live"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
item.claimed = True
|
item.claimed = True
|
||||||
self._claimed_task_ids.add(item.task.task_id)
|
self._claimed_task_ids.add(item.task.task_id)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class PurchasePageState:
|
|||||||
quantity: int = 0
|
quantity: int = 0
|
||||||
price_cent: int = 0
|
price_cent: int = 0
|
||||||
candidate_count: int = 1
|
candidate_count: int = 1
|
||||||
|
in_stock: bool = True
|
||||||
|
submit_candidate_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class PddPurchaseError(RuntimeError):
|
class PddPurchaseError(RuntimeError):
|
||||||
@@ -73,3 +75,11 @@ class PddPurchaseAdapter(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""释放设备会话;不得在此方法中产生页面点击。"""
|
"""释放设备会话;不得在此方法中产生页面点击。"""
|
||||||
|
|
||||||
|
|
||||||
|
class PddLivePurchaseAdapter(PddPurchaseAdapter):
|
||||||
|
"""受控真实采购接口;只增加一次性提交,不提供付款或取消。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def submit_order_once(self) -> None:
|
||||||
|
"""用最新页面状态确认唯一按钮并单击一次;调用后禁止重试。"""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""uiautomator2 采购演练 Adapter。
|
"""uiautomator2 采购 Adapter。
|
||||||
|
|
||||||
本模块只到 PDD 最终提交订单按钮前。代码中没有点击提交订单
|
演练 factory 返回没有提交方法的窄接口;live factory 单独返回只允许一次提交的
|
||||||
或付款的方法。
|
接口。两条路径都不提供付款、取消订单或绕过安全校验的方法。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -19,6 +19,7 @@ from .pdd_device_service import (
|
|||||||
PddDeviceService,
|
PddDeviceService,
|
||||||
)
|
)
|
||||||
from .pdd_purchase_adapter import (
|
from .pdd_purchase_adapter import (
|
||||||
|
PddLivePurchaseAdapter,
|
||||||
PddPurchaseAdapter,
|
PddPurchaseAdapter,
|
||||||
PddPurchaseError,
|
PddPurchaseError,
|
||||||
PurchasePageState,
|
PurchasePageState,
|
||||||
@@ -36,6 +37,7 @@ _CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击
|
|||||||
_RISK_MARKERS = ("操作频繁", "异常请求", "风险提示", "账号异常")
|
_RISK_MARKERS = ("操作频繁", "异常请求", "风险提示", "账号异常")
|
||||||
_PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支付失败")
|
_PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支付失败")
|
||||||
_FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买")
|
_FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买")
|
||||||
|
_OUT_OF_STOCK_MARKERS = ("已售罄", "暂时缺货", "库存不足", "该商品已售罄")
|
||||||
_SUPPORTED_OPTION_KEYS = frozenset({"color", "size"})
|
_SUPPORTED_OPTION_KEYS = frozenset({"color", "size"})
|
||||||
|
|
||||||
|
|
||||||
@@ -200,6 +202,33 @@ def _price_cent(root: ET.Element) -> int:
|
|||||||
return max(parsed_nodes)[2]
|
return max(parsed_nodes)[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _final_submit_targets(root: ET.Element) -> list[Bounds]:
|
||||||
|
"""返回底部可见、启用且文字明确的唯一提交按钮坐标。"""
|
||||||
|
|
||||||
|
screen_bottom = 0
|
||||||
|
for node in root.iter("node"):
|
||||||
|
bounds = _parse_bounds(node.get("bounds", ""))
|
||||||
|
if bounds is not None:
|
||||||
|
screen_bottom = max(screen_bottom, bounds[3])
|
||||||
|
if screen_bottom <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
targets = set()
|
||||||
|
for node in root.iter("node"):
|
||||||
|
label = _label(node)
|
||||||
|
bounds = _parse_bounds(node.get("bounds", ""))
|
||||||
|
if not label or bounds is None:
|
||||||
|
continue
|
||||||
|
if not any(marker in label for marker in _FINAL_SUBMIT_MARKERS):
|
||||||
|
continue
|
||||||
|
if node.get("visible-to-user") != "true" or node.get("enabled") != "true":
|
||||||
|
continue
|
||||||
|
if (bounds[1] + bounds[3]) // 2 < screen_bottom * 0.6:
|
||||||
|
continue
|
||||||
|
targets.add(bounds)
|
||||||
|
return sorted(targets)
|
||||||
|
|
||||||
|
|
||||||
class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
||||||
"""PDD 真机采购演练会话,不提供真实下单能力。"""
|
"""PDD 真机采购演练会话,不提供真实下单能力。"""
|
||||||
|
|
||||||
@@ -229,6 +258,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
self._device = None
|
self._device = None
|
||||||
self._goods_id = ""
|
self._goods_id = ""
|
||||||
self._requested_options: dict[str, str] = {}
|
self._requested_options: dict[str, str] = {}
|
||||||
|
self._submit_attempted = False
|
||||||
|
|
||||||
def open_goods(self, goods_url: str) -> None:
|
def open_goods(self, goods_url: str) -> None:
|
||||||
self._goods_id = _goods_id_from_url(goods_url)
|
self._goods_id = _goods_id_from_url(goods_url)
|
||||||
@@ -268,6 +298,8 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
xml_data = self._dump_hierarchy()
|
xml_data = self._dump_hierarchy()
|
||||||
root = _parse_xml(xml_data)
|
root = _parse_xml(xml_data)
|
||||||
kind = _page_kind(root, str(current.get("package") or ""))
|
kind = _page_kind(root, str(current.get("package") or ""))
|
||||||
|
labels = _labels(root)
|
||||||
|
submit_targets = _final_submit_targets(root)
|
||||||
selected = {
|
selected = {
|
||||||
key: value
|
key: value
|
||||||
for key, value in self._requested_options.items()
|
for key, value in self._requested_options.items()
|
||||||
@@ -280,6 +312,11 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
quantity=_quantity(root),
|
quantity=_quantity(root),
|
||||||
price_cent=_price_cent(root),
|
price_cent=_price_cent(root),
|
||||||
candidate_count=1 if kind != "unknown" else 0,
|
candidate_count=1 if kind != "unknown" else 0,
|
||||||
|
in_stock=not any(
|
||||||
|
marker in " ".join(labels)
|
||||||
|
for marker in _OUT_OF_STOCK_MARKERS
|
||||||
|
),
|
||||||
|
submit_candidate_count=len(submit_targets),
|
||||||
)
|
)
|
||||||
except PddPurchaseError:
|
except PddPurchaseError:
|
||||||
raise
|
raise
|
||||||
@@ -512,3 +549,57 @@ def create_u2_purchase_adapter(
|
|||||||
device_service=_PURCHASE_DEVICE_SERVICE,
|
device_service=_PURCHASE_DEVICE_SERVICE,
|
||||||
cancelled=cancelled,
|
cancelled=cancelled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
|
||||||
|
"""只允许一次最终提交点击的真机 Adapter,不包含付款路径。"""
|
||||||
|
|
||||||
|
def submit_order_once(self) -> None:
|
||||||
|
if self._submit_attempted:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_SUBMIT_ALREADY_ATTEMPTED",
|
||||||
|
"本次采购已经尝试提交,禁止再次点击",
|
||||||
|
step="purchase_submit_once",
|
||||||
|
)
|
||||||
|
self._check_cancelled("purchase_submit_once")
|
||||||
|
device = self._require_device()
|
||||||
|
try:
|
||||||
|
current = device.app_current()
|
||||||
|
root = _parse_xml(self._dump_hierarchy())
|
||||||
|
kind = _page_kind(root, str(current.get("package") or ""))
|
||||||
|
if kind in {"captcha", "login_required", "risk_control", "payment"}:
|
||||||
|
self._raise_special_page(kind)
|
||||||
|
if kind != "order_confirmation":
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_CONFIRMATION_LOST",
|
||||||
|
"最终提交前页面已经变化,禁止提交订单",
|
||||||
|
step="purchase_submit_once",
|
||||||
|
)
|
||||||
|
targets = _final_submit_targets(root)
|
||||||
|
if len(targets) != 1:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||||
|
"最终提交按钮不是唯一可靠目标,禁止提交订单",
|
||||||
|
step="purchase_submit_once",
|
||||||
|
diagnostics={"candidate_count": len(targets)},
|
||||||
|
)
|
||||||
|
left, top, right, bottom = targets[0]
|
||||||
|
self._submit_attempted = True
|
||||||
|
device.click((left + right) // 2, (top + bottom) // 2)
|
||||||
|
except PddPurchaseError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
self._submit_attempted = True
|
||||||
|
self._raise_device_or_page_error(exc, "purchase_submit_once")
|
||||||
|
|
||||||
|
|
||||||
|
def create_u2_live_purchase_adapter(
|
||||||
|
device_address: str, cancelled: Callable[[], bool]
|
||||||
|
) -> PddLivePurchaseAdapter:
|
||||||
|
"""为已通过本地绑定授权的任务创建一次 live 会话。"""
|
||||||
|
|
||||||
|
return U2PddLivePurchaseAdapter(
|
||||||
|
device_address,
|
||||||
|
device_service=_PURCHASE_DEVICE_SERVICE,
|
||||||
|
cancelled=cancelled,
|
||||||
|
)
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ from .current_client_service import CurrentClientService
|
|||||||
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
|
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
|
||||||
from .pdd_ui import PDDTaskPage, TaskRow
|
from .pdd_ui import PDDTaskPage, TaskRow
|
||||||
from .purchase_task_service import PurchaseAdapterFactory
|
from .purchase_task_service import PurchaseAdapterFactory
|
||||||
|
from .purchase_task_service import LivePurchaseAdapterFactory
|
||||||
|
from .live_purchase_authorization import LivePurchaseAuthorizationService
|
||||||
from .purchase_reconcile_service import PurchaseReconcileFactory
|
from .purchase_reconcile_service import PurchaseReconcileFactory
|
||||||
from .selected_android_device_service import SelectedAndroidDeviceService
|
from .selected_android_device_service import SelectedAndroidDeviceService
|
||||||
from .settings_repository import SettingsRepository
|
from .settings_repository import SettingsRepository
|
||||||
@@ -125,6 +127,12 @@ class ClaimTaskWorker(QObject):
|
|||||||
android_device_service: SelectedAndroidDeviceService,
|
android_device_service: SelectedAndroidDeviceService,
|
||||||
collect_service_factory: Optional[CollectServiceFactory] = None,
|
collect_service_factory: Optional[CollectServiceFactory] = None,
|
||||||
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
|
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
|
||||||
|
live_purchase_adapter_factory: Optional[
|
||||||
|
LivePurchaseAdapterFactory
|
||||||
|
] = None,
|
||||||
|
live_authorization_service: Optional[
|
||||||
|
LivePurchaseAuthorizationService
|
||||||
|
] = None,
|
||||||
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
||||||
selected_task_id: str = "",
|
selected_task_id: str = "",
|
||||||
device_connection_checker: Optional[Callable[[str], None]] = None,
|
device_connection_checker: Optional[Callable[[str], None]] = None,
|
||||||
@@ -137,6 +145,8 @@ class ClaimTaskWorker(QObject):
|
|||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
self._collect_service_factory = collect_service_factory
|
self._collect_service_factory = collect_service_factory
|
||||||
self._purchase_adapter_factory = purchase_adapter_factory
|
self._purchase_adapter_factory = purchase_adapter_factory
|
||||||
|
self._live_purchase_adapter_factory = live_purchase_adapter_factory
|
||||||
|
self._live_authorization_service = live_authorization_service
|
||||||
self._purchase_reconcile_factory = purchase_reconcile_factory
|
self._purchase_reconcile_factory = purchase_reconcile_factory
|
||||||
self._selected_task_id = selected_task_id
|
self._selected_task_id = selected_task_id
|
||||||
self._device_connection_checker = (
|
self._device_connection_checker = (
|
||||||
@@ -179,6 +189,17 @@ class ClaimTaskWorker(QObject):
|
|||||||
)
|
)
|
||||||
result = service.execute_selected(self._selected_task_id)
|
result = service.execute_selected(self._selected_task_id)
|
||||||
else:
|
else:
|
||||||
|
purchase_mode = "dry_run"
|
||||||
|
if self._live_authorization_service is not None:
|
||||||
|
purchase_mode = (
|
||||||
|
self._live_authorization_service.purchase_mode_for(
|
||||||
|
client.client_id,
|
||||||
|
android_serial or "",
|
||||||
|
live_adapter_ready=(
|
||||||
|
self._live_purchase_adapter_factory is not None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
dispatcher = TaskDispatcher(
|
dispatcher = TaskDispatcher(
|
||||||
self._gateway,
|
self._gateway,
|
||||||
self._task_repository,
|
self._task_repository,
|
||||||
@@ -186,6 +207,10 @@ class ClaimTaskWorker(QObject):
|
|||||||
android_serial or "",
|
android_serial or "",
|
||||||
collect_service_factory=self._collect_service_factory,
|
collect_service_factory=self._collect_service_factory,
|
||||||
purchase_adapter_factory=self._purchase_adapter_factory,
|
purchase_adapter_factory=self._purchase_adapter_factory,
|
||||||
|
live_purchase_adapter_factory=(
|
||||||
|
self._live_purchase_adapter_factory
|
||||||
|
),
|
||||||
|
purchase_mode=purchase_mode,
|
||||||
purchase_reconcile_factory=self._purchase_reconcile_factory,
|
purchase_reconcile_factory=self._purchase_reconcile_factory,
|
||||||
cancelled=lambda: self._cancelled,
|
cancelled=lambda: self._cancelled,
|
||||||
device_connection_checker=self._device_connection_checker,
|
device_connection_checker=self._device_connection_checker,
|
||||||
@@ -308,6 +333,9 @@ class PDDTaskPageEvent(QObject):
|
|||||||
settings_repository: Optional[SettingsRepository] = None,
|
settings_repository: Optional[SettingsRepository] = None,
|
||||||
collect_service_factory: Optional[CollectServiceFactory] = None,
|
collect_service_factory: Optional[CollectServiceFactory] = None,
|
||||||
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
|
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
|
||||||
|
live_purchase_adapter_factory: Optional[
|
||||||
|
LivePurchaseAdapterFactory
|
||||||
|
] = None,
|
||||||
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
||||||
device_connection_checker: Optional[Callable[[str], None]] = None,
|
device_connection_checker: Optional[Callable[[str], None]] = None,
|
||||||
next_task_delay_ms: int = 500,
|
next_task_delay_ms: int = 500,
|
||||||
@@ -337,6 +365,7 @@ class PDDTaskPageEvent(QObject):
|
|||||||
self._detail_windows: Dict[str, TaskDetailWindow] = {}
|
self._detail_windows: Dict[str, TaskDetailWindow] = {}
|
||||||
self._collect_service_factory = collect_service_factory
|
self._collect_service_factory = collect_service_factory
|
||||||
self._purchase_adapter_factory = purchase_adapter_factory
|
self._purchase_adapter_factory = purchase_adapter_factory
|
||||||
|
self._live_purchase_adapter_factory = live_purchase_adapter_factory
|
||||||
self._purchase_reconcile_factory = purchase_reconcile_factory
|
self._purchase_reconcile_factory = purchase_reconcile_factory
|
||||||
self._device_connection_checker = (
|
self._device_connection_checker = (
|
||||||
device_connection_checker
|
device_connection_checker
|
||||||
@@ -360,6 +389,9 @@ class PDDTaskPageEvent(QObject):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
settings = settings_repository or SettingsRepository()
|
settings = settings_repository or SettingsRepository()
|
||||||
|
self._live_authorization_service = LivePurchaseAuthorizationService(
|
||||||
|
settings
|
||||||
|
)
|
||||||
self._client_service = CurrentClientService(settings)
|
self._client_service = CurrentClientService(settings)
|
||||||
self._selected_android_device_service = SelectedAndroidDeviceService(
|
self._selected_android_device_service = SelectedAndroidDeviceService(
|
||||||
settings
|
settings
|
||||||
@@ -788,6 +820,8 @@ class PDDTaskPageEvent(QObject):
|
|||||||
android_device_service=self._selected_android_device_service,
|
android_device_service=self._selected_android_device_service,
|
||||||
collect_service_factory=self._collect_service_factory,
|
collect_service_factory=self._collect_service_factory,
|
||||||
purchase_adapter_factory=self._purchase_adapter_factory,
|
purchase_adapter_factory=self._purchase_adapter_factory,
|
||||||
|
live_purchase_adapter_factory=self._live_purchase_adapter_factory,
|
||||||
|
live_authorization_service=self._live_authorization_service,
|
||||||
purchase_reconcile_factory=self._purchase_reconcile_factory,
|
purchase_reconcile_factory=self._purchase_reconcile_factory,
|
||||||
device_connection_checker=self._device_connection_checker,
|
device_connection_checker=self._device_connection_checker,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""一条本地采购任务的安全演练流程。
|
"""一条本地采购任务的安全执行流程。
|
||||||
|
|
||||||
本模块固定为 ``dry_run``。它会核对商品、动态规格、数量和价格,并停在最终
|
``dry_run`` 始终停在提交前;``live`` 只在独立 Adapter 就绪时允许一次提交,
|
||||||
提交订单之前;代码中没有提交订单或付款入口。
|
并且必须先持久化不可逆标记。任何路径都不包含付款。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,6 +12,7 @@ from typing import Callable, Mapping
|
|||||||
|
|
||||||
from .admin_gateway import AdminGateway, AdminGatewayError, ClientInfo
|
from .admin_gateway import AdminGateway, AdminGatewayError, ClientInfo
|
||||||
from .pdd_purchase_adapter import (
|
from .pdd_purchase_adapter import (
|
||||||
|
PddLivePurchaseAdapter,
|
||||||
PddPurchaseAdapter,
|
PddPurchaseAdapter,
|
||||||
PddPurchaseError,
|
PddPurchaseError,
|
||||||
PurchasePageState,
|
PurchasePageState,
|
||||||
@@ -51,6 +52,9 @@ class PurchaseTaskOutcome:
|
|||||||
PurchaseAdapterFactory = Callable[
|
PurchaseAdapterFactory = Callable[
|
||||||
[str, Callable[[], bool]], PddPurchaseAdapter
|
[str, Callable[[], bool]], PddPurchaseAdapter
|
||||||
]
|
]
|
||||||
|
LivePurchaseAdapterFactory = Callable[
|
||||||
|
[str, Callable[[], bool]], PddLivePurchaseAdapter
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class PurchaseTaskService:
|
class PurchaseTaskService:
|
||||||
@@ -87,7 +91,7 @@ class PurchaseTaskService:
|
|||||||
return self.execute_selected(task.remote_task_id)
|
return self.execute_selected(task.remote_task_id)
|
||||||
|
|
||||||
def execute_selected(self, remote_task_id: str) -> PurchaseTaskOutcome:
|
def execute_selected(self, remote_task_id: str) -> PurchaseTaskOutcome:
|
||||||
"""对指定的本地采购任务执行一次演练。"""
|
"""按任务不可变执行模式安全执行一次采购。"""
|
||||||
|
|
||||||
if not self._device_address:
|
if not self._device_address:
|
||||||
raise ValueError("请先在设置页选择并保存 Android 设备")
|
raise ValueError("请先在设置页选择并保存 Android 设备")
|
||||||
@@ -104,6 +108,15 @@ class PurchaseTaskService:
|
|||||||
try:
|
try:
|
||||||
target = self._target_from_task(started.task)
|
target = self._target_from_task(started.task)
|
||||||
adapter = self._factory(self._device_address, self._cancelled)
|
adapter = self._factory(self._device_address, self._cancelled)
|
||||||
|
execution_mode = started.task.execution_mode
|
||||||
|
if execution_mode == "live" and not isinstance(
|
||||||
|
adapter, PddLivePurchaseAdapter
|
||||||
|
):
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_LIVE_ADAPTER_UNAVAILABLE",
|
||||||
|
"真实采购执行器未就绪,未进入商品页面",
|
||||||
|
step="purchase_prepare",
|
||||||
|
)
|
||||||
|
|
||||||
step = "purchase_open_goods"
|
step = "purchase_open_goods"
|
||||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||||
@@ -150,6 +163,15 @@ class PurchaseTaskService:
|
|||||||
)
|
)
|
||||||
self._validate_checkout_values(state, target)
|
self._validate_checkout_values(state, target)
|
||||||
|
|
||||||
|
if execution_mode == "live":
|
||||||
|
assert isinstance(adapter, PddLivePurchaseAdapter)
|
||||||
|
return self._submit_live_once(
|
||||||
|
remote_task_id,
|
||||||
|
started.attempt_id,
|
||||||
|
target,
|
||||||
|
adapter,
|
||||||
|
)
|
||||||
|
|
||||||
step = "purchase_dry_run_stopped"
|
step = "purchase_dry_run_stopped"
|
||||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||||
adapter.stop_before_submit()
|
adapter.stop_before_submit()
|
||||||
@@ -182,6 +204,64 @@ class PurchaseTaskService:
|
|||||||
adapter.close()
|
adapter.close()
|
||||||
return self._submit(event)
|
return self._submit(event)
|
||||||
|
|
||||||
|
def _submit_live_once(
|
||||||
|
self,
|
||||||
|
remote_task_id: str,
|
||||||
|
attempt_id: str,
|
||||||
|
target: PurchaseTarget,
|
||||||
|
adapter: PddLivePurchaseAdapter,
|
||||||
|
) -> PurchaseTaskOutcome:
|
||||||
|
"""最终复核、先落不可逆标记、单击一次,然后只转核单。"""
|
||||||
|
|
||||||
|
step = "purchase_live_final_check"
|
||||||
|
self._enter_step(remote_task_id, attempt_id, step)
|
||||||
|
state = adapter.read_state()
|
||||||
|
self._validate_live_confirmation(state, target)
|
||||||
|
if self._cancelled():
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_CANCELLED",
|
||||||
|
"用户已在不可逆标记写入前停止真实采购",
|
||||||
|
step=step,
|
||||||
|
)
|
||||||
|
|
||||||
|
irreversible_at = self._repository.mark_purchase_irreversible(
|
||||||
|
remote_task_id, attempt_id
|
||||||
|
)
|
||||||
|
submitted_at = None
|
||||||
|
message = "订单提交点击已执行,结果待只读核对;绝不重新下单"
|
||||||
|
try:
|
||||||
|
adapter.submit_order_once()
|
||||||
|
submitted_at = utc_now_iso()
|
||||||
|
except Exception as exc:
|
||||||
|
message = (
|
||||||
|
"订单提交点击结果不确定,已转只读核对;"
|
||||||
|
f"绝不重新下单:{str(exc) or type(exc).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._repository.move_purchase_to_reconcile(
|
||||||
|
remote_task_id,
|
||||||
|
attempt_id,
|
||||||
|
order_submitted_at=submitted_at,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# 不可逆标记已经提交成功。这里绝不能保存成普通失败或再次执行;
|
||||||
|
# 重启恢复会根据 irreversible_action_at 强制进入只读核单。
|
||||||
|
return PurchaseTaskOutcome(
|
||||||
|
"manual_review",
|
||||||
|
(
|
||||||
|
f"任务 {remote_task_id} 已在 {irreversible_at} 进入不可逆阶段,"
|
||||||
|
f"但核单状态保存失败:{exc};请关闭自动获取并重启,绝不重下"
|
||||||
|
),
|
||||||
|
remote_task_id,
|
||||||
|
)
|
||||||
|
return PurchaseTaskOutcome(
|
||||||
|
"manual_review",
|
||||||
|
f"任务 {remote_task_id} {message}",
|
||||||
|
remote_task_id,
|
||||||
|
)
|
||||||
|
|
||||||
def _enter_step(
|
def _enter_step(
|
||||||
self, remote_task_id: str, attempt_id: str, step: str
|
self, remote_task_id: str, attempt_id: str, step: str
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -328,6 +408,35 @@ class PurchaseTaskService:
|
|||||||
step="purchase_verify_quantity_price",
|
step="purchase_verify_quantity_price",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_live_confirmation(
|
||||||
|
state: PurchasePageState, target: PurchaseTarget
|
||||||
|
) -> None:
|
||||||
|
"""在不可逆标记前核对最新确认页和唯一提交目标。"""
|
||||||
|
|
||||||
|
if state.page_kind != "order_confirmation":
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_CONFIRMATION_LOST",
|
||||||
|
"最终提交前确认页已变化,禁止真实下单",
|
||||||
|
step="purchase_live_final_check",
|
||||||
|
)
|
||||||
|
PurchaseTaskService._validate_checkout_values(state, target)
|
||||||
|
if not state.in_stock:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_OUT_OF_STOCK",
|
||||||
|
"当前规格库存不足或已售罄,禁止真实下单",
|
||||||
|
step="purchase_live_final_check",
|
||||||
|
)
|
||||||
|
if state.submit_candidate_count != 1:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||||
|
"最终提交按钮不是唯一可靠目标,禁止真实下单",
|
||||||
|
step="purchase_live_final_check",
|
||||||
|
diagnostics={
|
||||||
|
"candidate_count": state.submit_candidate_count
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_common_state(state: PurchasePageState) -> None:
|
def _validate_common_state(state: PurchasePageState) -> None:
|
||||||
if state.page_kind == "captcha":
|
if state.page_kind == "captcha":
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ class SettingsPage(QWidget):
|
|||||||
android_device_service=None,
|
android_device_service=None,
|
||||||
update_service=None,
|
update_service=None,
|
||||||
update_credential_store=None,
|
update_credential_store=None,
|
||||||
|
live_purchase_adapter_ready: bool = False,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("settingsPage")
|
self.setObjectName("settingsPage")
|
||||||
@@ -227,6 +228,7 @@ class SettingsPage(QWidget):
|
|||||||
android_device_service=android_device_service,
|
android_device_service=android_device_service,
|
||||||
update_service=update_service,
|
update_service=update_service,
|
||||||
update_credential_store=update_credential_store,
|
update_credential_store=update_credential_store,
|
||||||
|
live_purchase_adapter_ready=live_purchase_adapter_ready,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
@@ -296,6 +298,34 @@ class SettingsPage(QWidget):
|
|||||||
self.pddAppStatusLabel.setWordWrap(True)
|
self.pddAppStatusLabel.setWordWrap(True)
|
||||||
self.androidDeviceCard = self._build_android_device_card()
|
self.androidDeviceCard = self._build_android_device_card()
|
||||||
|
|
||||||
|
self.livePurchaseStatusLabel = CaptionLabel(
|
||||||
|
"真实下单默认关闭,仅允许创建未付款订单", self
|
||||||
|
)
|
||||||
|
self.livePurchaseStatusLabel.setAccessibleName("真实下单授权状态")
|
||||||
|
self.livePurchaseStatusLabel.setWordWrap(True)
|
||||||
|
self.livePurchaseClientLabel = CaptionLabel("—", self)
|
||||||
|
self.livePurchaseDeviceLabel = CaptionLabel("—", self)
|
||||||
|
self.livePurchaseConfirmedAtLabel = CaptionLabel("—", self)
|
||||||
|
self.livePurchaseConfirmationInput = LineEdit(self)
|
||||||
|
self.livePurchaseConfirmationInput.setPlaceholderText(
|
||||||
|
"请输入“创建未付款订单”"
|
||||||
|
)
|
||||||
|
self.livePurchaseConfirmationInput.setClearButtonEnabled(True)
|
||||||
|
self.livePurchaseConfirmationInput.setAccessibleName(
|
||||||
|
"真实下单确认文字"
|
||||||
|
)
|
||||||
|
self.livePurchaseEnableButton = PushButton(
|
||||||
|
FIF.ACCEPT, "启用真实下单", self
|
||||||
|
)
|
||||||
|
self.livePurchaseEnableButton.setAccessibleName(
|
||||||
|
"为当前 Client 和 Android 设备启用真实下单"
|
||||||
|
)
|
||||||
|
self.livePurchaseDisableButton = PushButton(
|
||||||
|
FIF.CANCEL, "关闭真实下单", self
|
||||||
|
)
|
||||||
|
self.livePurchaseDisableButton.setAccessibleName("关闭真实下单")
|
||||||
|
self.livePurchaseCard = self._build_live_purchase_card()
|
||||||
|
|
||||||
self.currentVersionLabel = CaptionLabel(__version__, self)
|
self.currentVersionLabel = CaptionLabel(__version__, self)
|
||||||
self.currentVersionLabel.setAccessibleName("当前软件版本")
|
self.currentVersionLabel.setAccessibleName("当前软件版本")
|
||||||
self.updateManifestUrlInput = LineEdit(self)
|
self.updateManifestUrlInput = LineEdit(self)
|
||||||
@@ -331,6 +361,7 @@ class SettingsPage(QWidget):
|
|||||||
contentLayout.addWidget(TitleLabel("设置", content))
|
contentLayout.addWidget(TitleLabel("设置", content))
|
||||||
contentLayout.addWidget(self.currentDeviceCard)
|
contentLayout.addWidget(self.currentDeviceCard)
|
||||||
contentLayout.addWidget(self.androidDeviceCard)
|
contentLayout.addWidget(self.androidDeviceCard)
|
||||||
|
contentLayout.addWidget(self.livePurchaseCard)
|
||||||
contentLayout.addWidget(self.softwareUpdateCard)
|
contentLayout.addWidget(self.softwareUpdateCard)
|
||||||
contentLayout.addStretch(1)
|
contentLayout.addStretch(1)
|
||||||
|
|
||||||
@@ -455,6 +486,52 @@ class SettingsPage(QWidget):
|
|||||||
layout.addLayout(commandLayout)
|
layout.addLayout(commandLayout)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
def _build_live_purchase_card(self) -> CardWidget:
|
||||||
|
card = CardWidget(self)
|
||||||
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
|
layout = QVBoxLayout(card)
|
||||||
|
layout.setContentsMargins(24, 20, 24, 22)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
titleLayout = QHBoxLayout()
|
||||||
|
titleLayout.setSpacing(12)
|
||||||
|
titleLayout.addWidget(SubtitleLabel("真实下单(不支付)", card))
|
||||||
|
titleLayout.addStretch(1)
|
||||||
|
titleLayout.addWidget(self.livePurchaseStatusLabel, 1)
|
||||||
|
layout.addLayout(titleLayout)
|
||||||
|
|
||||||
|
form = QFormLayout()
|
||||||
|
form.setHorizontalSpacing(16)
|
||||||
|
form.setVerticalSpacing(12)
|
||||||
|
form.addRow(
|
||||||
|
CaptionLabel("绑定 Client", card), self.livePurchaseClientLabel
|
||||||
|
)
|
||||||
|
form.addRow(
|
||||||
|
CaptionLabel("绑定设备", card), self.livePurchaseDeviceLabel
|
||||||
|
)
|
||||||
|
form.addRow(
|
||||||
|
CaptionLabel("确认时间", card), self.livePurchaseConfirmedAtLabel
|
||||||
|
)
|
||||||
|
confirmationLabel = CaptionLabel("确认文字", card)
|
||||||
|
confirmationLabel.setBuddy(self.livePurchaseConfirmationInput)
|
||||||
|
form.addRow(confirmationLabel, self.livePurchaseConfirmationInput)
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
warning = CaptionLabel(
|
||||||
|
"启用后只允许提交一次订单并停在支付前;验证码、风控、登录失效、"
|
||||||
|
"规格或价格不一致时立即停止。",
|
||||||
|
card,
|
||||||
|
)
|
||||||
|
warning.setWordWrap(True)
|
||||||
|
layout.addWidget(warning)
|
||||||
|
|
||||||
|
commandLayout = QHBoxLayout()
|
||||||
|
commandLayout.addStretch(1)
|
||||||
|
commandLayout.addWidget(self.livePurchaseDisableButton)
|
||||||
|
commandLayout.addWidget(self.livePurchaseEnableButton)
|
||||||
|
layout.addLayout(commandLayout)
|
||||||
|
return card
|
||||||
|
|
||||||
def set_client_info(self, device_id: str, device_name: str) -> None:
|
def set_client_info(self, device_id: str, device_name: str) -> None:
|
||||||
"""显示后续设备身份服务提供的当前客户端信息。"""
|
"""显示后续设备身份服务提供的当前客户端信息。"""
|
||||||
|
|
||||||
@@ -482,6 +559,28 @@ class SettingsPage(QWidget):
|
|||||||
|
|
||||||
self.updateStatusLabel.setText(message)
|
self.updateStatusLabel.setText(message)
|
||||||
|
|
||||||
|
def set_live_purchase_authorization(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
client_id: str = "",
|
||||||
|
device_serial: str = "",
|
||||||
|
confirmed_at: str = "",
|
||||||
|
message: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""显示真实下单授权,不通过颜色单独表达状态。"""
|
||||||
|
|
||||||
|
self.livePurchaseClientLabel.setText(client_id or "—")
|
||||||
|
self.livePurchaseDeviceLabel.setText(device_serial or "—")
|
||||||
|
self.livePurchaseConfirmedAtLabel.setText(confirmed_at or "—")
|
||||||
|
if message:
|
||||||
|
status = message
|
||||||
|
elif enabled:
|
||||||
|
status = "已启用:仅限绑定 Client 和设备,且不会自动支付"
|
||||||
|
else:
|
||||||
|
status = "已关闭:所有采购任务只允许演练"
|
||||||
|
self.livePurchaseStatusLabel.setText(status)
|
||||||
|
|
||||||
def set_saved_android_device(self, serial: str) -> None:
|
def set_saved_android_device(self, serial: str) -> None:
|
||||||
"""显示已经保存并实际用于自动化的 Android 设备。"""
|
"""显示已经保存并实际用于自动化的 Android 设备。"""
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,14 @@ from .current_client_service import (
|
|||||||
)
|
)
|
||||||
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
|
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
|
||||||
from .selected_android_device_service import SelectedAndroidDeviceService
|
from .selected_android_device_service import SelectedAndroidDeviceService
|
||||||
|
from .live_purchase_authorization import (
|
||||||
|
LIVE_CONFIRMATION_TEXT,
|
||||||
|
LivePurchaseAuthorization,
|
||||||
|
LivePurchaseAuthorizationService,
|
||||||
|
)
|
||||||
from .settings_repository import SettingsRepository
|
from .settings_repository import SettingsRepository
|
||||||
from .settings_ui import AndroidDeviceRow
|
from .settings_ui import AndroidDeviceRow
|
||||||
|
from .task_models import TaskType
|
||||||
from .update_ui_event import UpdateUiEventBinder
|
from .update_ui_event import UpdateUiEventBinder
|
||||||
|
|
||||||
DEVICE_ID_PLACEHOLDER = "待生成"
|
DEVICE_ID_PLACEHOLDER = "待生成"
|
||||||
@@ -294,6 +300,85 @@ class AndroidDeviceSettingWorker(QObject):
|
|||||||
self.completed.emit()
|
self.completed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
class LivePurchaseAuthorizationWorker(QObject):
|
||||||
|
"""后台保存 live 授权,并尽力把最新能力登记到 Admin。"""
|
||||||
|
|
||||||
|
saved = pyqtSignal(object)
|
||||||
|
failed = pyqtSignal(str)
|
||||||
|
registrationFailed = pyqtSignal(str)
|
||||||
|
completed = pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service: LivePurchaseAuthorizationService,
|
||||||
|
gateway: Optional[ClientRegistrationGateway],
|
||||||
|
client_id: str,
|
||||||
|
client_name: str,
|
||||||
|
device_serial: str,
|
||||||
|
action: str,
|
||||||
|
confirmation_text: str,
|
||||||
|
gateway_error: str = "",
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._service = service
|
||||||
|
self._gateway = gateway
|
||||||
|
self._client_id = str(client_id or "").strip()
|
||||||
|
self._client_name = str(client_name or "").strip()
|
||||||
|
self._device_serial = device_serial
|
||||||
|
self._action = action
|
||||||
|
self._confirmation_text = confirmation_text
|
||||||
|
self._gateway_error = gateway_error
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
if self._action == "enable":
|
||||||
|
authorization = self._service.enable(
|
||||||
|
self._client_id,
|
||||||
|
self._device_serial,
|
||||||
|
self._confirmation_text,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
authorization = self._service.disable()
|
||||||
|
except Exception as exc:
|
||||||
|
self.failed.emit(str(exc) or "真实下单授权保存失败")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.saved.emit(authorization)
|
||||||
|
if not self._client_id:
|
||||||
|
return
|
||||||
|
if self._gateway is None:
|
||||||
|
self.registrationFailed.emit(
|
||||||
|
self._gateway_error or "Admin Gateway 尚未配置"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
device = (
|
||||||
|
AndroidDeviceInfo(self._device_serial)
|
||||||
|
if self._device_serial
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
supported_types = (
|
||||||
|
(TaskType.COLLECT, TaskType.PURCHASE)
|
||||||
|
if device is not None
|
||||||
|
else (TaskType.COLLECT,)
|
||||||
|
)
|
||||||
|
capabilities = ClaimCapabilities(
|
||||||
|
device=device,
|
||||||
|
supported_types=supported_types,
|
||||||
|
purchase_mode="live" if authorization.enabled else "dry_run",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._gateway.register_client(
|
||||||
|
ClientInfo(self._client_id, self._client_name),
|
||||||
|
capabilities,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.registrationFailed.emit(str(exc) or "Admin 登记失败")
|
||||||
|
finally:
|
||||||
|
self.completed.emit()
|
||||||
|
|
||||||
|
|
||||||
class SettingsPageEventBinder(QObject):
|
class SettingsPageEventBinder(QObject):
|
||||||
"""绑定设备管理控件,并向应用层发出稳定事件。"""
|
"""绑定设备管理控件,并向应用层发出稳定事件。"""
|
||||||
|
|
||||||
@@ -302,6 +387,7 @@ class SettingsPageEventBinder(QObject):
|
|||||||
currentDeviceSaveRequested = pyqtSignal(str, str)
|
currentDeviceSaveRequested = pyqtSignal(str, str)
|
||||||
saveRequested = pyqtSignal(str, str)
|
saveRequested = pyqtSignal(str, str)
|
||||||
deleteRequested = pyqtSignal(str)
|
deleteRequested = pyqtSignal(str)
|
||||||
|
liveAuthorizationRequested = pyqtSignal(str, str)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -311,6 +397,7 @@ class SettingsPageEventBinder(QObject):
|
|||||||
android_device_service: Optional[AndroidDeviceService] = None,
|
android_device_service: Optional[AndroidDeviceService] = None,
|
||||||
update_service=None,
|
update_service=None,
|
||||||
update_credential_store=None,
|
update_credential_store=None,
|
||||||
|
live_purchase_adapter_ready: bool = False,
|
||||||
):
|
):
|
||||||
super().__init__(page)
|
super().__init__(page)
|
||||||
self._page = page
|
self._page = page
|
||||||
@@ -341,6 +428,14 @@ class SettingsPageEventBinder(QObject):
|
|||||||
self._android_setting_thread: Optional[QThread] = None
|
self._android_setting_thread: Optional[QThread] = None
|
||||||
self._android_setting_worker: Optional[AndroidDeviceSettingWorker] = None
|
self._android_setting_worker: Optional[AndroidDeviceSettingWorker] = None
|
||||||
self._saved_android_serial = ""
|
self._saved_android_serial = ""
|
||||||
|
self._live_purchase_busy = False
|
||||||
|
self._live_purchase_thread: Optional[QThread] = None
|
||||||
|
self._live_purchase_worker: Optional[
|
||||||
|
LivePurchaseAuthorizationWorker
|
||||||
|
] = None
|
||||||
|
self._live_purchase_adapter_ready = bool(
|
||||||
|
live_purchase_adapter_ready
|
||||||
|
)
|
||||||
self._android_device_service = (
|
self._android_device_service = (
|
||||||
android_device_service or AndroidDeviceService()
|
android_device_service or AndroidDeviceService()
|
||||||
)
|
)
|
||||||
@@ -354,6 +449,10 @@ class SettingsPageEventBinder(QObject):
|
|||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
self._client_service = CurrentClientService(repository)
|
self._client_service = CurrentClientService(repository)
|
||||||
|
self._live_purchase_service = LivePurchaseAuthorizationService(
|
||||||
|
repository
|
||||||
|
)
|
||||||
|
self._live_authorization = self._live_purchase_service.load()
|
||||||
self._selected_android_device_service = SelectedAndroidDeviceService(
|
self._selected_android_device_service = SelectedAndroidDeviceService(
|
||||||
repository
|
repository
|
||||||
)
|
)
|
||||||
@@ -384,6 +483,18 @@ class SettingsPageEventBinder(QObject):
|
|||||||
page.saveButton.clicked.connect(self._request_save)
|
page.saveButton.clicked.connect(self._request_save)
|
||||||
self.saveRequested.connect(self._start_save_android_device)
|
self.saveRequested.connect(self._start_save_android_device)
|
||||||
page.deleteButton.clicked.connect(self._request_delete)
|
page.deleteButton.clicked.connect(self._request_delete)
|
||||||
|
page.livePurchaseEnableButton.clicked.connect(
|
||||||
|
self._request_enable_live_purchase
|
||||||
|
)
|
||||||
|
page.livePurchaseDisableButton.clicked.connect(
|
||||||
|
self._request_disable_live_purchase
|
||||||
|
)
|
||||||
|
page.livePurchaseConfirmationInput.textChanged.connect(
|
||||||
|
self._sync_button_state
|
||||||
|
)
|
||||||
|
self.liveAuthorizationRequested.connect(
|
||||||
|
self._start_live_authorization
|
||||||
|
)
|
||||||
self.deleteRequested.connect(self._start_delete_android_device)
|
self.deleteRequested.connect(self._start_delete_android_device)
|
||||||
page.deviceTableModel.checkedDeviceChanged.connect(
|
page.deviceTableModel.checkedDeviceChanged.connect(
|
||||||
self._on_checked_device_changed
|
self._on_checked_device_changed
|
||||||
@@ -395,6 +506,7 @@ class SettingsPageEventBinder(QObject):
|
|||||||
|
|
||||||
self._load_current_client()
|
self._load_current_client()
|
||||||
self._load_selected_android_device()
|
self._load_selected_android_device()
|
||||||
|
self._show_live_authorization(self._live_authorization)
|
||||||
self._sync_button_state()
|
self._sync_button_state()
|
||||||
if self._saved_android_serial:
|
if self._saved_android_serial:
|
||||||
QTimer.singleShot(0, self._request_restore_saved_android_device)
|
QTimer.singleShot(0, self._request_restore_saved_android_device)
|
||||||
@@ -430,7 +542,22 @@ class SettingsPageEventBinder(QObject):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
device = self._selected_android_device()
|
device = self._selected_android_device()
|
||||||
capabilities = ClaimCapabilities(device=device)
|
displayed_client_id = self._page.deviceIdInput.text().strip()
|
||||||
|
purchase_mode = self._live_purchase_service.purchase_mode_for(
|
||||||
|
displayed_client_id,
|
||||||
|
device.address if device is not None else "",
|
||||||
|
live_adapter_ready=self._live_purchase_adapter_ready,
|
||||||
|
)
|
||||||
|
supported_types = (
|
||||||
|
(TaskType.COLLECT, TaskType.PURCHASE)
|
||||||
|
if device is not None and self._live_purchase_adapter_ready
|
||||||
|
else (TaskType.COLLECT,)
|
||||||
|
)
|
||||||
|
capabilities = ClaimCapabilities(
|
||||||
|
device=device,
|
||||||
|
supported_types=supported_types,
|
||||||
|
purchase_mode=purchase_mode,
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
self._page.set_current_device_status(f"保存失败:{exc}")
|
self._page.set_current_device_status(f"保存失败:{exc}")
|
||||||
return
|
return
|
||||||
@@ -463,6 +590,128 @@ class SettingsPageEventBinder(QObject):
|
|||||||
self._worker = worker
|
self._worker = worker
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def _request_enable_live_purchase(self) -> None:
|
||||||
|
if self._live_purchase_busy:
|
||||||
|
return
|
||||||
|
if not self._live_purchase_adapter_ready:
|
||||||
|
self._page.set_live_purchase_authorization(
|
||||||
|
enabled=False,
|
||||||
|
message="真实下单执行器未就绪,只允许采购演练",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
confirmation = self._page.livePurchaseConfirmationInput.text()
|
||||||
|
self.liveAuthorizationRequested.emit("enable", confirmation)
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def _request_disable_live_purchase(self) -> None:
|
||||||
|
if not self._live_purchase_busy:
|
||||||
|
self.liveAuthorizationRequested.emit("disable", "")
|
||||||
|
|
||||||
|
@pyqtSlot(str, str)
|
||||||
|
def _start_live_authorization(
|
||||||
|
self, action: str, confirmation_text: str
|
||||||
|
) -> None:
|
||||||
|
if self._closing or self._live_purchase_busy:
|
||||||
|
return
|
||||||
|
current = self._client_service.load()
|
||||||
|
serial = self._selected_android_device_service.load()
|
||||||
|
if action == "enable" and (
|
||||||
|
not current.client_id or not serial
|
||||||
|
):
|
||||||
|
self._page.set_live_purchase_authorization(
|
||||||
|
enabled=False,
|
||||||
|
message="请先保存当前 Client 和 Android 设备",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._live_purchase_busy = True
|
||||||
|
self._sync_button_state()
|
||||||
|
self._page.set_live_purchase_authorization(
|
||||||
|
enabled=self._live_authorization.enabled,
|
||||||
|
client_id=self._live_authorization.client_id,
|
||||||
|
device_serial=self._live_authorization.device_serial,
|
||||||
|
confirmed_at=self._live_authorization.confirmed_at,
|
||||||
|
message=(
|
||||||
|
"正在启用真实下单…"
|
||||||
|
if action == "enable"
|
||||||
|
else "正在关闭真实下单…"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
thread = QThread(self)
|
||||||
|
worker = LivePurchaseAuthorizationWorker(
|
||||||
|
self._live_purchase_service,
|
||||||
|
self._admin_gateway,
|
||||||
|
current.client_id,
|
||||||
|
current.client_name,
|
||||||
|
serial,
|
||||||
|
action,
|
||||||
|
confirmation_text,
|
||||||
|
self._gateway_error,
|
||||||
|
)
|
||||||
|
worker.moveToThread(thread)
|
||||||
|
thread.started.connect(worker.run)
|
||||||
|
worker.saved.connect(self._on_live_authorization_saved)
|
||||||
|
worker.failed.connect(self._on_live_authorization_failed)
|
||||||
|
worker.registrationFailed.connect(
|
||||||
|
self._on_live_registration_failed
|
||||||
|
)
|
||||||
|
worker.completed.connect(thread.quit)
|
||||||
|
worker.completed.connect(worker.deleteLater)
|
||||||
|
thread.finished.connect(self._on_live_authorization_finished)
|
||||||
|
thread.finished.connect(thread.deleteLater)
|
||||||
|
self._live_purchase_thread = thread
|
||||||
|
self._live_purchase_worker = worker
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
@pyqtSlot(object)
|
||||||
|
def _on_live_authorization_saved(
|
||||||
|
self, authorization: LivePurchaseAuthorization
|
||||||
|
) -> None:
|
||||||
|
if self._closing:
|
||||||
|
return
|
||||||
|
self._live_authorization = authorization
|
||||||
|
self._page.livePurchaseConfirmationInput.clear()
|
||||||
|
self._show_live_authorization(authorization)
|
||||||
|
|
||||||
|
@pyqtSlot(str)
|
||||||
|
def _on_live_authorization_failed(self, message: str) -> None:
|
||||||
|
if not self._closing:
|
||||||
|
self._show_live_authorization(
|
||||||
|
self._live_authorization,
|
||||||
|
f"授权修改失败:{message};原设置未改变",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pyqtSlot(str)
|
||||||
|
def _on_live_registration_failed(self, message: str) -> None:
|
||||||
|
if not self._closing:
|
||||||
|
state = "已启用" if self._live_authorization.enabled else "已关闭"
|
||||||
|
self._show_live_authorization(
|
||||||
|
self._live_authorization,
|
||||||
|
f"本地{state},Admin 登记失败:{message};领取时会再次声明能力",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def _on_live_authorization_finished(self) -> None:
|
||||||
|
self._live_purchase_worker = None
|
||||||
|
self._live_purchase_thread = None
|
||||||
|
self._live_purchase_busy = False
|
||||||
|
if not self._closing:
|
||||||
|
self._sync_button_state()
|
||||||
|
|
||||||
|
def _show_live_authorization(
|
||||||
|
self,
|
||||||
|
authorization: LivePurchaseAuthorization,
|
||||||
|
message: str = "",
|
||||||
|
) -> None:
|
||||||
|
self._page.set_live_purchase_authorization(
|
||||||
|
enabled=authorization.enabled,
|
||||||
|
client_id=authorization.client_id,
|
||||||
|
device_serial=authorization.device_serial,
|
||||||
|
confirmed_at=authorization.confirmed_at,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def _request_search(self) -> None:
|
def _request_search(self) -> None:
|
||||||
if (
|
if (
|
||||||
@@ -871,6 +1120,25 @@ class SettingsPageEventBinder(QObject):
|
|||||||
self._page.deleteButton.setEnabled(
|
self._page.deleteButton.setEnabled(
|
||||||
device_commands_enabled and bool(self._saved_android_serial)
|
device_commands_enabled and bool(self._saved_android_serial)
|
||||||
)
|
)
|
||||||
|
live_commands_enabled = (
|
||||||
|
not self._closing
|
||||||
|
and not self._busy
|
||||||
|
and not self._current_device_busy
|
||||||
|
and not self._live_purchase_busy
|
||||||
|
)
|
||||||
|
confirmation_matches = (
|
||||||
|
self._page.livePurchaseConfirmationInput.text().strip()
|
||||||
|
== LIVE_CONFIRMATION_TEXT
|
||||||
|
)
|
||||||
|
self._page.livePurchaseEnableButton.setEnabled(
|
||||||
|
live_commands_enabled
|
||||||
|
and self._live_purchase_adapter_ready
|
||||||
|
and not self._live_authorization.enabled
|
||||||
|
and confirmation_matches
|
||||||
|
)
|
||||||
|
self._page.livePurchaseDisableButton.setEnabled(
|
||||||
|
live_commands_enabled and self._live_authorization.enabled
|
||||||
|
)
|
||||||
|
|
||||||
def set_busy(self, busy: bool, message: str = "") -> None:
|
def set_busy(self, busy: bool, message: str = "") -> None:
|
||||||
"""切换界面忙碌状态,防止用户重复提交设备命令。"""
|
"""切换界面忙碌状态,防止用户重复提交设备命令。"""
|
||||||
@@ -1178,6 +1446,11 @@ class SettingsPageEventBinder(QObject):
|
|||||||
thread.quit()
|
thread.quit()
|
||||||
thread.wait(4000)
|
thread.wait(4000)
|
||||||
|
|
||||||
|
live_thread = self._live_purchase_thread
|
||||||
|
if live_thread is not None and live_thread.isRunning():
|
||||||
|
live_thread.quit()
|
||||||
|
live_thread.wait(4000)
|
||||||
|
|
||||||
search_worker = self._search_worker
|
search_worker = self._search_worker
|
||||||
search_thread = self._search_thread
|
search_thread = self._search_thread
|
||||||
if search_worker is not None:
|
if search_worker is not None:
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ CURRENT_STEP_TEXT = {
|
|||||||
"purchase_verify_quantity_price": "正在核对数量和价格",
|
"purchase_verify_quantity_price": "正在核对数量和价格",
|
||||||
"purchase_enter_confirmation": "正在进入提交前确认页",
|
"purchase_enter_confirmation": "正在进入提交前确认页",
|
||||||
"purchase_verify_confirmation": "正在核对提交前确认页",
|
"purchase_verify_confirmation": "正在核对提交前确认页",
|
||||||
|
"purchase_live_final_check": "正在执行真实下单最终安全核对",
|
||||||
|
"purchase_irreversible_step_entered": "已进入不可逆阶段,禁止重新下单",
|
||||||
|
"purchase_submit_once": "已单击一次提交订单",
|
||||||
"purchase_dry_run_stopped": "采购演练已在提交前停止",
|
"purchase_dry_run_stopped": "采购演练已在提交前停止",
|
||||||
"purchase_recovery_ready": "上次演练中断,已安全等待恢复",
|
"purchase_recovery_ready": "上次演练中断,已安全等待恢复",
|
||||||
"reconcile_purchase": "只允许核对订单",
|
"reconcile_purchase": "只允许核对订单",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from .android_device_service import (
|
|||||||
AndroidDeviceService,
|
AndroidDeviceService,
|
||||||
)
|
)
|
||||||
from .purchase_task_service import (
|
from .purchase_task_service import (
|
||||||
|
LivePurchaseAdapterFactory,
|
||||||
PurchaseAdapterFactory,
|
PurchaseAdapterFactory,
|
||||||
PurchaseTaskService,
|
PurchaseTaskService,
|
||||||
)
|
)
|
||||||
@@ -95,6 +96,7 @@ def admin_task_to_new_claimed_task(task: AdminTask) -> NewClaimedTask:
|
|||||||
original_task = {
|
original_task = {
|
||||||
"id": task.task_id,
|
"id": task.task_id,
|
||||||
"type": task.task_type.value,
|
"type": task.task_type.value,
|
||||||
|
"execution_mode": task.execution_mode,
|
||||||
"version": task.version,
|
"version": task.version,
|
||||||
"priority": task.priority,
|
"priority": task.priority,
|
||||||
"payload": payload,
|
"payload": payload,
|
||||||
@@ -105,6 +107,7 @@ def admin_task_to_new_claimed_task(task: AdminTask) -> NewClaimedTask:
|
|||||||
remote_task_id=task.task_id,
|
remote_task_id=task.task_id,
|
||||||
task_type=task.task_type,
|
task_type=task.task_type,
|
||||||
goods_url=goods_url.strip(),
|
goods_url=goods_url.strip(),
|
||||||
|
execution_mode=task.execution_mode,
|
||||||
goods_id=goods_id.strip() if isinstance(goods_id, str) else None,
|
goods_id=goods_id.strip() if isinstance(goods_id, str) else None,
|
||||||
target_color=target_color,
|
target_color=target_color,
|
||||||
target_size=target_size,
|
target_size=target_size,
|
||||||
@@ -128,6 +131,10 @@ class TaskDispatcher:
|
|||||||
*,
|
*,
|
||||||
collect_service_factory: Optional[CollectServiceFactory] = None,
|
collect_service_factory: Optional[CollectServiceFactory] = None,
|
||||||
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
|
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
|
||||||
|
live_purchase_adapter_factory: Optional[
|
||||||
|
LivePurchaseAdapterFactory
|
||||||
|
] = None,
|
||||||
|
purchase_mode: str = "dry_run",
|
||||||
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
||||||
cancelled: Callable[[], bool] = lambda: False,
|
cancelled: Callable[[], bool] = lambda: False,
|
||||||
device_connection_checker: Optional[Callable[[str], None]] = None,
|
device_connection_checker: Optional[Callable[[str], None]] = None,
|
||||||
@@ -138,6 +145,10 @@ class TaskDispatcher:
|
|||||||
self._device_address = str(device_address or "").strip()
|
self._device_address = str(device_address or "").strip()
|
||||||
self._collect_factory = collect_service_factory
|
self._collect_factory = collect_service_factory
|
||||||
self._purchase_factory = purchase_adapter_factory
|
self._purchase_factory = purchase_adapter_factory
|
||||||
|
self._live_purchase_factory = live_purchase_adapter_factory
|
||||||
|
if purchase_mode not in {"dry_run", "live"}:
|
||||||
|
raise ValueError("purchase_mode 只允许 dry_run 或 live")
|
||||||
|
self._purchase_mode = purchase_mode
|
||||||
self._reconcile_factory = purchase_reconcile_factory
|
self._reconcile_factory = purchase_reconcile_factory
|
||||||
self._cancelled = cancelled
|
self._cancelled = cancelled
|
||||||
self._device_connection_checker = (
|
self._device_connection_checker = (
|
||||||
@@ -167,24 +178,38 @@ class TaskDispatcher:
|
|||||||
return ClaimCapabilities(
|
return ClaimCapabilities(
|
||||||
device=device,
|
device=device,
|
||||||
supported_types=tuple(supported),
|
supported_types=tuple(supported),
|
||||||
purchase_mode="dry_run",
|
purchase_mode=(
|
||||||
|
"live"
|
||||||
|
if self.purchase_ready
|
||||||
|
and self._purchase_mode == "live"
|
||||||
|
and self._live_purchase_factory is not None
|
||||||
|
else "dry_run"
|
||||||
|
),
|
||||||
schema_versions=(1,),
|
schema_versions=(1,),
|
||||||
)
|
)
|
||||||
|
|
||||||
def execute_one(self) -> TaskDispatchOutcome:
|
def execute_one(self) -> TaskDispatchOutcome:
|
||||||
"""严格按 Outbox、本地任务、Admin 新任务的顺序处理。"""
|
"""不可逆采购优先核单,其余按 Outbox、本地任务、Admin 顺序处理。"""
|
||||||
|
|
||||||
pending = self._repository.next_pending_outbox()
|
unresolved_reader = getattr(
|
||||||
if pending is not None:
|
self._repository, "unresolved_irreversible_purchase", None
|
||||||
return self._submit_pending(pending)
|
)
|
||||||
if not self._device_address:
|
unresolved = (
|
||||||
raise AndroidDeviceSearchError(
|
unresolved_reader() if unresolved_reader is not None else None
|
||||||
"请先在设置页选择并保存 Android 设备"
|
)
|
||||||
|
if unresolved is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"任务 {unresolved.remote_task_id} 已进入不可逆阶段但尚未转入核单;"
|
||||||
|
"已停止所有采购,绝不重新下单"
|
||||||
)
|
)
|
||||||
self._device_connection_checker(self._device_address)
|
|
||||||
|
|
||||||
reconcile_task = self._repository.next_purchase_reconcile_task()
|
reconcile_task = self._repository.next_purchase_reconcile_task()
|
||||||
if reconcile_task is not None:
|
if reconcile_task is not None:
|
||||||
|
if not self._device_address:
|
||||||
|
raise AndroidDeviceSearchError(
|
||||||
|
"存在只允许核对的采购任务;请先连接并保存原 Android 设备"
|
||||||
|
)
|
||||||
|
self._device_connection_checker(self._device_address)
|
||||||
if self._reconcile_factory is None:
|
if self._reconcile_factory is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"任务 {reconcile_task.remote_task_id} 只允许核对订单,"
|
f"任务 {reconcile_task.remote_task_id} 只允许核对订单,"
|
||||||
@@ -200,6 +225,15 @@ class TaskDispatcher:
|
|||||||
outcome.kind, outcome.message, outcome.task_id
|
outcome.kind, outcome.message, outcome.task_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
pending = self._repository.next_pending_outbox()
|
||||||
|
if pending is not None:
|
||||||
|
return self._submit_pending(pending)
|
||||||
|
if not self._device_address:
|
||||||
|
raise AndroidDeviceSearchError(
|
||||||
|
"请先在设置页选择并保存 Android 设备"
|
||||||
|
)
|
||||||
|
self._device_connection_checker(self._device_address)
|
||||||
|
|
||||||
if not self.purchase_ready:
|
if not self.purchase_ready:
|
||||||
pending_purchase = self._repository.next_purchase_task()
|
pending_purchase = self._repository.next_purchase_task()
|
||||||
if pending_purchase is not None:
|
if pending_purchase is not None:
|
||||||
@@ -244,16 +278,25 @@ class TaskDispatcher:
|
|||||||
)
|
)
|
||||||
outcome = service.execute_selected(task.remote_task_id)
|
outcome = service.execute_selected(task.remote_task_id)
|
||||||
else:
|
else:
|
||||||
if self._purchase_factory is None:
|
factory = self._purchase_factory
|
||||||
|
if task.execution_mode == "live":
|
||||||
|
if self.claim_capabilities().purchase_mode != "live":
|
||||||
|
raise RuntimeError(
|
||||||
|
f"真实采购任务 {task.remote_task_id} 的本地授权已关闭或绑定不一致;"
|
||||||
|
"任务保持待执行,不会降级为演练"
|
||||||
|
)
|
||||||
|
factory = self._live_purchase_factory
|
||||||
|
if factory is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"采购演练执行器未就绪,已停止领取以避免任务卡住"
|
f"采购任务 {task.remote_task_id} 的 {task.execution_mode} "
|
||||||
|
"执行器未就绪,已停止且不会改变任务模式"
|
||||||
)
|
)
|
||||||
purchase_service = PurchaseTaskService(
|
purchase_service = PurchaseTaskService(
|
||||||
self._gateway,
|
self._gateway,
|
||||||
self._repository,
|
self._repository,
|
||||||
self._client,
|
self._client,
|
||||||
self._device_address,
|
self._device_address,
|
||||||
self._purchase_factory,
|
factory,
|
||||||
cancelled=self._cancelled,
|
cancelled=self._cancelled,
|
||||||
)
|
)
|
||||||
outcome = purchase_service.execute_selected(task.remote_task_id)
|
outcome = purchase_service.execute_selected(task.remote_task_id)
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class NewClaimedTask:
|
|||||||
remote_task_id: str
|
remote_task_id: str
|
||||||
task_type: TaskType
|
task_type: TaskType
|
||||||
goods_url: str
|
goods_url: str
|
||||||
|
execution_mode: str = "dry_run"
|
||||||
goods_id: Optional[str] = None
|
goods_id: Optional[str] = None
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
target_color: Optional[str] = None
|
target_color: Optional[str] = None
|
||||||
@@ -79,6 +80,13 @@ class NewClaimedTask:
|
|||||||
raise ValueError("remote_task_id 不能为空")
|
raise ValueError("remote_task_id 不能为空")
|
||||||
if not self.goods_url.strip():
|
if not self.goods_url.strip():
|
||||||
raise ValueError("goods_url 不能为空")
|
raise ValueError("goods_url 不能为空")
|
||||||
|
if self.execution_mode not in {"dry_run", "live"}:
|
||||||
|
raise ValueError("execution_mode 只允许 dry_run 或 live")
|
||||||
|
if (
|
||||||
|
self.task_type is not TaskType.PURCHASE
|
||||||
|
and self.execution_mode != "dry_run"
|
||||||
|
):
|
||||||
|
raise ValueError("只有采购任务允许 execution_mode=live")
|
||||||
if self.price_cent is not None and self.price_cent < 0:
|
if self.price_cent is not None and self.price_cent < 0:
|
||||||
raise ValueError("price_cent 不能小于 0")
|
raise ValueError("price_cent 不能小于 0")
|
||||||
if self.quantity is not None and self.quantity <= 0:
|
if self.quantity is not None and self.quantity <= 0:
|
||||||
@@ -141,6 +149,7 @@ class TaskDetail:
|
|||||||
finished_at: Optional[str]
|
finished_at: Optional[str]
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
execution_mode: str = "dry_run"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -70,14 +70,16 @@ class TaskRepository:
|
|||||||
with connection:
|
with connection:
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"INSERT INTO pdd_tasks ("
|
"INSERT INTO pdd_tasks ("
|
||||||
" remote_task_id, task_type, goods_id, goods_url, title,"
|
" remote_task_id, task_type, execution_mode, goods_id,"
|
||||||
|
" goods_url, title,"
|
||||||
" target_color, target_size, price_cent, quantity, status,"
|
" target_color, target_size, price_cent, quantity, status,"
|
||||||
" priority, version, admin_payload, received_at, created_at,"
|
" priority, version, admin_payload, received_at, created_at,"
|
||||||
" updated_at"
|
" updated_at"
|
||||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
task.remote_task_id.strip(),
|
task.remote_task_id.strip(),
|
||||||
task.task_type.value,
|
task.task_type.value,
|
||||||
|
task.execution_mode,
|
||||||
task.goods_id,
|
task.goods_id,
|
||||||
task.goods_url.strip(),
|
task.goods_url.strip(),
|
||||||
task.title,
|
task.title,
|
||||||
@@ -298,6 +300,22 @@ class TaskRepository:
|
|||||||
connection.close()
|
connection.close()
|
||||||
return self._to_detail(row) if row is not None else None
|
return self._to_detail(row) if row is not None else None
|
||||||
|
|
||||||
|
def unresolved_irreversible_purchase(self) -> Optional[TaskDetail]:
|
||||||
|
"""查找仍在运行且已有不可逆标记的采购,防止继续控制设备。"""
|
||||||
|
|
||||||
|
connection = open_database(self._db_path)
|
||||||
|
try:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT t.* FROM pdd_tasks t JOIN task_runs r ON r.task_id = t.id"
|
||||||
|
" WHERE t.task_type = 'purchase' AND t.status = 'running'"
|
||||||
|
" AND r.run_status = 'running'"
|
||||||
|
" AND r.irreversible_action_at IS NOT NULL"
|
||||||
|
" ORDER BY r.attempt_no DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
return self._to_detail(row) if row is not None else None
|
||||||
|
|
||||||
def latest_task_run(
|
def latest_task_run(
|
||||||
self, remote_task_id: str
|
self, remote_task_id: str
|
||||||
) -> Optional[TaskRunRecord]:
|
) -> Optional[TaskRunRecord]:
|
||||||
@@ -583,6 +601,102 @@ class TaskRepository:
|
|||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
def mark_purchase_irreversible(
|
||||||
|
self, remote_task_id: str, attempt_id: str
|
||||||
|
) -> str:
|
||||||
|
"""事务写入不可逆时间;成功返回后才允许点击提交订单。"""
|
||||||
|
|
||||||
|
now = utc_now_iso()
|
||||||
|
step = "purchase_irreversible_step_entered"
|
||||||
|
connection = open_database(self._db_path)
|
||||||
|
try:
|
||||||
|
with connection:
|
||||||
|
task = connection.execute(
|
||||||
|
"SELECT id, task_type, status, execution_mode FROM pdd_tasks"
|
||||||
|
" WHERE remote_task_id = ?",
|
||||||
|
(remote_task_id,),
|
||||||
|
).fetchone()
|
||||||
|
if task is None:
|
||||||
|
raise ValueError(f"任务 {remote_task_id} 不存在")
|
||||||
|
if task["task_type"] != TaskType.PURCHASE.value:
|
||||||
|
raise ValueError("当前任务不是采购任务")
|
||||||
|
if task["execution_mode"] != "live":
|
||||||
|
raise ValueError("演练任务不能进入不可逆阶段")
|
||||||
|
if task["status"] != TaskStatus.RUNNING.value:
|
||||||
|
raise ValueError("采购任务当前不在执行中")
|
||||||
|
cursor = connection.execute(
|
||||||
|
"UPDATE task_runs SET irreversible_action_at = ?,"
|
||||||
|
" current_step = ?, updated_at = ?"
|
||||||
|
" WHERE task_id = ? AND attempt_id = ?"
|
||||||
|
" AND run_status = 'running'"
|
||||||
|
" AND irreversible_action_at IS NULL",
|
||||||
|
(now, step, now, task["id"], attempt_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise ValueError("不可逆标记写入失败或已经存在")
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE pdd_tasks SET current_step = ?, updated_at = ?"
|
||||||
|
" WHERE id = ?",
|
||||||
|
(step, now, task["id"]),
|
||||||
|
)
|
||||||
|
return now
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def move_purchase_to_reconcile(
|
||||||
|
self,
|
||||||
|
remote_task_id: str,
|
||||||
|
attempt_id: str,
|
||||||
|
*,
|
||||||
|
order_submitted_at: Optional[str] = None,
|
||||||
|
message: str = "订单提交结果待核对,绝不重新下单",
|
||||||
|
) -> None:
|
||||||
|
"""不可逆点击后转入只读核单状态,不创建可重试下单路径。"""
|
||||||
|
|
||||||
|
now = utc_now_iso()
|
||||||
|
connection = open_database(self._db_path)
|
||||||
|
try:
|
||||||
|
with connection:
|
||||||
|
task = connection.execute(
|
||||||
|
"SELECT id, task_type, status FROM pdd_tasks"
|
||||||
|
" WHERE remote_task_id = ?",
|
||||||
|
(remote_task_id,),
|
||||||
|
).fetchone()
|
||||||
|
if task is None:
|
||||||
|
raise ValueError(f"任务 {remote_task_id} 不存在")
|
||||||
|
if task["task_type"] != TaskType.PURCHASE.value:
|
||||||
|
raise ValueError("当前任务不是采购任务")
|
||||||
|
cursor = connection.execute(
|
||||||
|
"UPDATE task_runs SET run_status = 'manual_review',"
|
||||||
|
" current_step = 'reconcile_purchase',"
|
||||||
|
" order_submitted_at = COALESCE(order_submitted_at, ?),"
|
||||||
|
" error_code = 'PURCHASE_OUTCOME_UNKNOWN',"
|
||||||
|
" error_message = ?, finished_at = ?, updated_at = ?"
|
||||||
|
" WHERE task_id = ? AND attempt_id = ?"
|
||||||
|
" AND run_status = 'running'"
|
||||||
|
" AND irreversible_action_at IS NOT NULL",
|
||||||
|
(
|
||||||
|
order_submitted_at,
|
||||||
|
message,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
task["id"],
|
||||||
|
attempt_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise ValueError("不可逆采购执行记录不存在或已经结束")
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE pdd_tasks SET status = 'manual_review',"
|
||||||
|
" current_step = 'reconcile_purchase',"
|
||||||
|
" last_error_code = 'PURCHASE_OUTCOME_UNKNOWN',"
|
||||||
|
" last_error_message = ?, finished_at = ?, updated_at = ?"
|
||||||
|
" WHERE id = ?",
|
||||||
|
(message, now, now, task["id"]),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
def save_purchase_reconciliation(
|
def save_purchase_reconciliation(
|
||||||
self,
|
self,
|
||||||
remote_task_id: str,
|
remote_task_id: str,
|
||||||
@@ -1369,6 +1483,7 @@ class TaskRepository:
|
|||||||
id=row["id"],
|
id=row["id"],
|
||||||
remote_task_id=row["remote_task_id"],
|
remote_task_id=row["remote_task_id"],
|
||||||
task_type=TaskType(row["task_type"]),
|
task_type=TaskType(row["task_type"]),
|
||||||
|
execution_mode=row["execution_mode"],
|
||||||
goods_id=row["goods_id"],
|
goods_id=row["goods_id"],
|
||||||
goods_url=row["goods_url"],
|
goods_url=row["goods_url"],
|
||||||
title=row["title"],
|
title=row["title"],
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ from qfluentwidgets import (
|
|||||||
|
|
||||||
from .pdd_ui import PDDTaskPage
|
from .pdd_ui import PDDTaskPage
|
||||||
from .pdd_ui_event import PDDTaskPageEvent
|
from .pdd_ui_event import PDDTaskPageEvent
|
||||||
from .pdd_u2_purchase_adapter import create_u2_purchase_adapter
|
from .pdd_u2_purchase_adapter import (
|
||||||
|
create_u2_live_purchase_adapter,
|
||||||
|
create_u2_purchase_adapter,
|
||||||
|
)
|
||||||
from .settings_ui import SettingsPage
|
from .settings_ui import SettingsPage
|
||||||
from .task_repository import TaskRepository
|
from .task_repository import TaskRepository
|
||||||
from .update_service import mark_current_version_healthy
|
from .update_service import mark_current_version_healthy
|
||||||
@@ -51,12 +54,14 @@ class MainWindow(FluentWindow):
|
|||||||
self,
|
self,
|
||||||
settings_repository=settings_repository,
|
settings_repository=settings_repository,
|
||||||
admin_gateway=admin_gateway,
|
admin_gateway=admin_gateway,
|
||||||
|
live_purchase_adapter_ready=True,
|
||||||
)
|
)
|
||||||
self.pddTaskPageEvent = PDDTaskPageEvent(
|
self.pddTaskPageEvent = PDDTaskPageEvent(
|
||||||
self.pddTaskPage,
|
self.pddTaskPage,
|
||||||
task_repository or TaskRepository(),
|
task_repository or TaskRepository(),
|
||||||
self,
|
self,
|
||||||
purchase_adapter_factory=create_u2_purchase_adapter,
|
purchase_adapter_factory=create_u2_purchase_adapter,
|
||||||
|
live_purchase_adapter_factory=create_u2_live_purchase_adapter,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.pddTaskPage.openSettingsRequested.connect(
|
self.pddTaskPage.openSettingsRequested.connect(
|
||||||
|
|||||||
+65
-3
@@ -6,7 +6,7 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.db import DatabaseVersionError, initialize_database, open_database
|
from src.db import DatabaseVersionError, initialize_database, open_database
|
||||||
from src.db_schema import MIGRATION_1
|
from src.db_schema import MIGRATION_1, MIGRATION_2
|
||||||
|
|
||||||
|
|
||||||
EXPECTED_TABLES = {
|
EXPECTED_TABLES = {
|
||||||
@@ -63,7 +63,7 @@ class DatabaseInitializationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertTrue(EXPECTED_TABLES.issubset(tables))
|
self.assertTrue(EXPECTED_TABLES.issubset(tables))
|
||||||
self.assertTrue(EXPECTED_INDEXES.issubset(indexes))
|
self.assertTrue(EXPECTED_INDEXES.issubset(indexes))
|
||||||
self.assertEqual(version, 2)
|
self.assertEqual(version, 3)
|
||||||
|
|
||||||
def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None:
|
def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None:
|
||||||
connection = open_database(self.db_path)
|
connection = open_database(self.db_path)
|
||||||
@@ -106,7 +106,7 @@ class DatabaseInitializationTests(unittest.TestCase):
|
|||||||
connection.close()
|
connection.close()
|
||||||
self.assertIn("result_data", columns)
|
self.assertIn("result_data", columns)
|
||||||
self.assertEqual(attempt_id, "ATTEMPT-OLD")
|
self.assertEqual(attempt_id, "ATTEMPT-OLD")
|
||||||
self.assertEqual(version, 2)
|
self.assertEqual(version, 3)
|
||||||
|
|
||||||
def test_initialize_can_run_twice_without_losing_data(self) -> None:
|
def test_initialize_can_run_twice_without_losing_data(self) -> None:
|
||||||
initialize_database(self.db_path)
|
initialize_database(self.db_path)
|
||||||
@@ -133,6 +133,44 @@ class DatabaseInitializationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(value, "true")
|
self.assertEqual(value, "true")
|
||||||
|
|
||||||
|
def test_v2_database_adds_safe_execution_mode_default(self) -> None:
|
||||||
|
connection = open_database(self.db_path)
|
||||||
|
try:
|
||||||
|
with connection:
|
||||||
|
for statement in MIGRATION_1 + MIGRATION_2:
|
||||||
|
connection.execute(statement)
|
||||||
|
connection.execute("PRAGMA user_version = 2")
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO pdd_tasks"
|
||||||
|
" (remote_task_id, task_type, goods_url, status, received_at,"
|
||||||
|
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
"PUR-OLD",
|
||||||
|
"purchase",
|
||||||
|
"https://example.test",
|
||||||
|
"claimed",
|
||||||
|
"2026-08-10T00:00:00Z",
|
||||||
|
"2026-08-10T00:00:00Z",
|
||||||
|
"2026-08-10T00:00:00Z",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
initialize_database(self.db_path)
|
||||||
|
|
||||||
|
connection = open_database(self.db_path)
|
||||||
|
try:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT execution_mode FROM pdd_tasks"
|
||||||
|
" WHERE remote_task_id = 'PUR-OLD'"
|
||||||
|
).fetchone()
|
||||||
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
self.assertEqual(row[0], "dry_run")
|
||||||
|
self.assertEqual(version, 3)
|
||||||
|
|
||||||
def test_new_connection_uses_required_pragmas(self) -> None:
|
def test_new_connection_uses_required_pragmas(self) -> None:
|
||||||
initialize_database(self.db_path)
|
initialize_database(self.db_path)
|
||||||
connection = open_database(self.db_path)
|
connection = open_database(self.db_path)
|
||||||
@@ -181,6 +219,30 @@ class DatabaseInitializationTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
def test_execution_mode_constraint_is_enforced(self) -> None:
|
||||||
|
initialize_database(self.db_path)
|
||||||
|
connection = open_database(self.db_path)
|
||||||
|
try:
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError):
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO pdd_tasks"
|
||||||
|
" (remote_task_id, task_type, execution_mode, goods_url,"
|
||||||
|
" status, received_at, created_at, updated_at)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
"PUR-INVALID-MODE",
|
||||||
|
"purchase",
|
||||||
|
"unknown",
|
||||||
|
"https://example.test/goods",
|
||||||
|
"claimed",
|
||||||
|
"2026-08-10T00:00:00Z",
|
||||||
|
"2026-08-10T00:00:00Z",
|
||||||
|
"2026-08-10T00:00:00Z",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
def test_task_run_and_outbox_foreign_keys_are_enforced(self) -> None:
|
def test_task_run_and_outbox_foreign_keys_are_enforced(self) -> None:
|
||||||
initialize_database(self.db_path)
|
initialize_database(self.db_path)
|
||||||
connection = open_database(self.db_path)
|
connection = open_database(self.db_path)
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ class HttpAdminGatewayTest(unittest.TestCase):
|
|||||||
"type": "purchase",
|
"type": "purchase",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"priority": 0,
|
"priority": 0,
|
||||||
|
"execution_mode": "live",
|
||||||
"payload": {
|
"payload": {
|
||||||
"goods_url": "https://example.test/goods/PUR-001",
|
"goods_url": "https://example.test/goods/PUR-001",
|
||||||
"goods_id": "737116531267",
|
"goods_id": "737116531267",
|
||||||
@@ -292,11 +293,12 @@ class HttpAdminGatewayTest(unittest.TestCase):
|
|||||||
ClientInfo("CLIENT-001"),
|
ClientInfo("CLIENT-001"),
|
||||||
ClaimCapabilities(
|
ClaimCapabilities(
|
||||||
supported_types=(TaskType.COLLECT, TaskType.PURCHASE),
|
supported_types=(TaskType.COLLECT, TaskType.PURCHASE),
|
||||||
purchase_mode="dry_run",
|
purchase_mode="live",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(task.task_type, TaskType.PURCHASE)
|
self.assertEqual(task.task_type, TaskType.PURCHASE)
|
||||||
|
self.assertEqual(task.execution_mode, "live")
|
||||||
self.assertEqual(task.payload["max_price_cent"], 4200)
|
self.assertEqual(task.payload["max_price_cent"], 4200)
|
||||||
|
|
||||||
for missing in ("goods_id", "options", "quantity", "max_price_cent"):
|
for missing in ("goods_id", "options", "quantity", "max_price_cent"):
|
||||||
@@ -318,6 +320,23 @@ class HttpAdminGatewayTest(unittest.TestCase):
|
|||||||
raised.exception.code, "ADMIN_INVALID_RESPONSE"
|
raised.exception.code, "ADMIN_INVALID_RESPONSE"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
invalid_mode_task = dict(valid_task)
|
||||||
|
invalid_mode_task["execution_mode"] = "unsafe"
|
||||||
|
invalid_gateway = HttpAdminGateway(
|
||||||
|
opener=RecordingOpener(
|
||||||
|
FakeResponse(200, {"task": invalid_mode_task})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaises(AdminGatewayError) as raised:
|
||||||
|
invalid_gateway.claim_next(
|
||||||
|
ClientInfo("CLIENT-001"),
|
||||||
|
ClaimCapabilities(
|
||||||
|
supported_types=(TaskType.COLLECT, TaskType.PURCHASE),
|
||||||
|
purchase_mode="live",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(raised.exception.code, "ADMIN_INVALID_RESPONSE")
|
||||||
|
|
||||||
def test_claim_204_returns_none(self):
|
def test_claim_204_returns_none(self):
|
||||||
gateway = HttpAdminGateway(opener=RecordingOpener(FakeResponse(204, {})))
|
gateway = HttpAdminGateway(opener=RecordingOpener(FakeResponse(204, {})))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""真实下单授权服务测试;不连接 Admin 或手机。"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.live_purchase_authorization import (
|
||||||
|
LIVE_CONFIRMATION_TEXT,
|
||||||
|
LIVE_ENABLED_KEY,
|
||||||
|
LivePurchaseAuthorizationService,
|
||||||
|
)
|
||||||
|
from src.settings_repository import SettingsRepository
|
||||||
|
|
||||||
|
|
||||||
|
class LivePurchaseAuthorizationTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||||
|
repository = SettingsRepository(
|
||||||
|
Path(self.temporary_directory.name) / "client.db"
|
||||||
|
)
|
||||||
|
self.repository = repository
|
||||||
|
self.service = LivePurchaseAuthorizationService(repository)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temporary_directory.cleanup()
|
||||||
|
|
||||||
|
def test_missing_setting_and_environment_always_default_to_dry_run(self):
|
||||||
|
os.environ["CMAUTOBUY_PURCHASE_MODE"] = "live"
|
||||||
|
try:
|
||||||
|
self.assertFalse(self.service.load().enabled)
|
||||||
|
self.assertEqual(
|
||||||
|
self.service.purchase_mode_for(
|
||||||
|
"CLIENT-1", "USB-1", live_adapter_ready=True
|
||||||
|
),
|
||||||
|
"dry_run",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.environ.pop("CMAUTOBUY_PURCHASE_MODE", None)
|
||||||
|
|
||||||
|
def test_enable_requires_exact_confirmation_and_binds_identity(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, LIVE_CONFIRMATION_TEXT):
|
||||||
|
self.service.enable("CLIENT-1", "USB-1", "确认")
|
||||||
|
self.assertIsNone(self.repository.get(LIVE_ENABLED_KEY))
|
||||||
|
|
||||||
|
authorization = self.service.enable(
|
||||||
|
"CLIENT-1", "USB-1", LIVE_CONFIRMATION_TEXT
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(authorization.enabled)
|
||||||
|
self.assertTrue(authorization.matches("CLIENT-1", "USB-1"))
|
||||||
|
self.assertEqual(
|
||||||
|
self.service.purchase_mode_for(
|
||||||
|
"CLIENT-1", "USB-1", live_adapter_ready=True
|
||||||
|
),
|
||||||
|
"live",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_binding_mismatch_or_missing_adapter_stays_dry_run(self):
|
||||||
|
self.service.enable("CLIENT-1", "USB-1", LIVE_CONFIRMATION_TEXT)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.service.purchase_mode_for(
|
||||||
|
"CLIENT-2", "USB-1", live_adapter_ready=True
|
||||||
|
),
|
||||||
|
"dry_run",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.service.purchase_mode_for(
|
||||||
|
"CLIENT-1", "USB-2", live_adapter_ready=True
|
||||||
|
),
|
||||||
|
"dry_run",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.service.purchase_mode_for(
|
||||||
|
"CLIENT-1", "USB-1", live_adapter_ready=False
|
||||||
|
),
|
||||||
|
"dry_run",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_disable_is_immediate_and_does_not_require_confirmation(self):
|
||||||
|
self.service.enable("CLIENT-1", "USB-1", LIVE_CONFIRMATION_TEXT)
|
||||||
|
|
||||||
|
authorization = self.service.disable()
|
||||||
|
|
||||||
|
self.assertFalse(authorization.enabled)
|
||||||
|
self.assertFalse(self.service.load().enabled)
|
||||||
@@ -5,6 +5,7 @@ import unittest
|
|||||||
from src.pdd_device_service import PddDeviceService
|
from src.pdd_device_service import PddDeviceService
|
||||||
from src.pdd_purchase_adapter import PddPurchaseError
|
from src.pdd_purchase_adapter import PddPurchaseError
|
||||||
from src.pdd_u2_purchase_adapter import U2PddPurchaseAdapter
|
from src.pdd_u2_purchase_adapter import U2PddPurchaseAdapter
|
||||||
|
from src.pdd_u2_purchase_adapter import U2PddLivePurchaseAdapter
|
||||||
|
|
||||||
|
|
||||||
GOODS_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=753136429979"
|
GOODS_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=753136429979"
|
||||||
@@ -35,7 +36,8 @@ def panel_xml(quantity: int = 1) -> str:
|
|||||||
<node content-desc="减少数量" clickable="true" bounds="[390,700][470,780]"/>
|
<node content-desc="减少数量" clickable="true" bounds="[390,700][470,780]"/>
|
||||||
<node content-desc="增加数量" clickable="true" bounds="[570,700][650,780]"/>
|
<node content-desc="增加数量" clickable="true" bounds="[570,700][650,780]"/>
|
||||||
<node clickable="true" bounds="[0,2181][1080,2328]">
|
<node clickable="true" bounds="[0,2181][1080,2328]">
|
||||||
<node text="提交订单 ¥5.03" bounds="[380,2220][700,2290]"/>
|
<node text="提交订单 ¥5.03" enabled="true" visible-to-user="true"
|
||||||
|
bounds="[380,2220][700,2290]"/>
|
||||||
</node>
|
</node>
|
||||||
</node>
|
</node>
|
||||||
</hierarchy>"""
|
</hierarchy>"""
|
||||||
@@ -103,6 +105,23 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
|||||||
select_size_fn=select_size_fn,
|
select_size_fn=select_size_fn,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _live_adapter(self, device, calls):
|
||||||
|
def select_color_fn(_device, _xml, target, **_kwargs):
|
||||||
|
calls.append(("color", target))
|
||||||
|
return target == "黑色"
|
||||||
|
|
||||||
|
def select_size_fn(_device, _xml, target, **_kwargs):
|
||||||
|
calls.append(("size", target))
|
||||||
|
return target == "3XL【140-165斤】"
|
||||||
|
|
||||||
|
return U2PddLivePurchaseAdapter(
|
||||||
|
"USB-001",
|
||||||
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
select_color_fn=select_color_fn,
|
||||||
|
select_size_fn=select_size_fn,
|
||||||
|
)
|
||||||
|
|
||||||
def test_dry_run_reaches_confirmation_without_clicking_submit(self):
|
def test_dry_run_reaches_confirmation_without_clicking_submit(self):
|
||||||
device = FakeDevice()
|
device = FakeDevice()
|
||||||
calls = []
|
calls = []
|
||||||
@@ -168,6 +187,47 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
|||||||
self.assertEqual(raised.exception.code, "PURCHASE_GOODS_URL_INVALID")
|
self.assertEqual(raised.exception.code, "PURCHASE_GOODS_URL_INVALID")
|
||||||
self.assertEqual(device.opened_urls, [])
|
self.assertEqual(device.opened_urls, [])
|
||||||
|
|
||||||
|
def test_live_adapter_clicks_unique_submit_target_only_once(self):
|
||||||
|
device = FakeDevice()
|
||||||
|
adapter = self._live_adapter(device, [])
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
adapter.select_options(
|
||||||
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||||
|
)
|
||||||
|
adapter.set_quantity(2)
|
||||||
|
|
||||||
|
state = adapter.read_state()
|
||||||
|
adapter.submit_order_once()
|
||||||
|
with self.assertRaises(PddPurchaseError) as raised:
|
||||||
|
adapter.submit_order_once()
|
||||||
|
|
||||||
|
self.assertEqual(state.submit_candidate_count, 1)
|
||||||
|
self.assertEqual(raised.exception.code, "PURCHASE_SUBMIT_ALREADY_ATTEMPTED")
|
||||||
|
# 第一次点击采购入口,第二次且仅一次点击最终提交。
|
||||||
|
self.assertEqual(len(device.clicks), 2)
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
|
def test_live_adapter_rejects_multiple_submit_targets_without_click(self):
|
||||||
|
device = FakeDevice()
|
||||||
|
adapter = self._live_adapter(device, [])
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
adapter.select_options(
|
||||||
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||||
|
)
|
||||||
|
device.special_xml = panel_xml().replace(
|
||||||
|
"</hierarchy>",
|
||||||
|
'<node text="确认购买" enabled="true" visible-to-user="true" '
|
||||||
|
'bounds="[50,2200][300,2300]"/></hierarchy>',
|
||||||
|
)
|
||||||
|
device.mode = "special"
|
||||||
|
|
||||||
|
with self.assertRaises(PddPurchaseError) as raised:
|
||||||
|
adapter.submit_order_once()
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.code, "PURCHASE_SUBMIT_TARGET_AMBIGUOUS")
|
||||||
|
self.assertEqual(len(device.clicks), 1)
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from src.admin_gateway import AdminTask, ClaimCapabilities, ClientInfo
|
|||||||
from src.db import open_database
|
from src.db import open_database
|
||||||
from src.mock_admin_gateway import MockAdminGateway
|
from src.mock_admin_gateway import MockAdminGateway
|
||||||
from src.pdd_purchase_adapter import (
|
from src.pdd_purchase_adapter import (
|
||||||
|
PddLivePurchaseAdapter,
|
||||||
PddPurchaseAdapter,
|
PddPurchaseAdapter,
|
||||||
PddPurchaseError,
|
PddPurchaseError,
|
||||||
PurchasePageState,
|
PurchasePageState,
|
||||||
@@ -279,11 +280,13 @@ class PurchaseRecoveryTest(unittest.TestCase):
|
|||||||
self.assertEqual(detail.current_step, "reconcile_manual_review")
|
self.assertEqual(detail.current_step, "reconcile_manual_review")
|
||||||
self.assertIn("核对设备已断开", run.diagnostics_json["error"])
|
self.assertIn("核对设备已断开", run.diagnostics_json["error"])
|
||||||
|
|
||||||
def test_live_mode_and_order_submission_methods_are_unavailable(self):
|
def test_live_submit_is_isolated_and_payment_methods_are_unavailable(self):
|
||||||
with self.assertRaisesRegex(ValueError, "dry_run"):
|
self.assertEqual(ClaimCapabilities(purchase_mode="live").purchase_mode, "live")
|
||||||
ClaimCapabilities(purchase_mode="live")
|
self.assertFalse(hasattr(PddPurchaseAdapter, "submit_order_once"))
|
||||||
for name in ("submit_order", "pay", "payment"):
|
self.assertTrue(hasattr(PddLivePurchaseAdapter, "submit_order_once"))
|
||||||
|
for name in ("submit_order", "pay", "payment", "cancel_order"):
|
||||||
self.assertFalse(hasattr(PddPurchaseAdapter, name))
|
self.assertFalse(hasattr(PddPurchaseAdapter, name))
|
||||||
|
self.assertFalse(hasattr(PddLivePurchaseAdapter, name))
|
||||||
self.assertFalse(hasattr(PddPurchaseReconcileAdapter, name))
|
self.assertFalse(hasattr(PddPurchaseReconcileAdapter, name))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ from src.admin_gateway import (
|
|||||||
ClientInfo,
|
ClientInfo,
|
||||||
)
|
)
|
||||||
from src.mock_admin_gateway import MockAdminGateway
|
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.purchase_task_service import PurchaseTaskService
|
||||||
from src.task_models import NewClaimedTask, TaskStatus, TaskType
|
from src.task_models import NewClaimedTask, TaskStatus, TaskType
|
||||||
from src.task_repository import TaskRepository
|
from src.task_repository import TaskRepository
|
||||||
@@ -31,12 +36,16 @@ class RecordingDryRunAdapter(PddPurchaseAdapter):
|
|||||||
candidate_count: int = 1,
|
candidate_count: int = 1,
|
||||||
forced_page: str = "",
|
forced_page: str = "",
|
||||||
wrong_options: bool = False,
|
wrong_options: bool = False,
|
||||||
|
in_stock: bool = True,
|
||||||
|
submit_candidate_count: int = 1,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.goods_id = goods_id
|
self.goods_id = goods_id
|
||||||
self.price_cent = price_cent
|
self.price_cent = price_cent
|
||||||
self.candidate_count = candidate_count
|
self.candidate_count = candidate_count
|
||||||
self.forced_page = forced_page
|
self.forced_page = forced_page
|
||||||
self.wrong_options = wrong_options
|
self.wrong_options = wrong_options
|
||||||
|
self.in_stock = in_stock
|
||||||
|
self.submit_candidate_count = submit_candidate_count
|
||||||
self.options = {}
|
self.options = {}
|
||||||
self.quantity = 0
|
self.quantity = 0
|
||||||
self.page_kind = "goods"
|
self.page_kind = "goods"
|
||||||
@@ -57,6 +66,8 @@ class RecordingDryRunAdapter(PddPurchaseAdapter):
|
|||||||
quantity=self.quantity,
|
quantity=self.quantity,
|
||||||
price_cent=self.price_cent,
|
price_cent=self.price_cent,
|
||||||
candidate_count=self.candidate_count,
|
candidate_count=self.candidate_count,
|
||||||
|
in_stock=self.in_stock,
|
||||||
|
submit_candidate_count=self.submit_candidate_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
def select_options(self, options) -> None:
|
def select_options(self, options) -> None:
|
||||||
@@ -78,6 +89,25 @@ class RecordingDryRunAdapter(PddPurchaseAdapter):
|
|||||||
self.calls.append(("close",))
|
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):
|
class PurchaseTaskServiceTest(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.temp_dir = tempfile.TemporaryDirectory()
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
@@ -90,12 +120,18 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
|||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.temp_dir.cleanup()
|
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 = AdminTask(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
task_type=TaskType.PURCHASE,
|
task_type=TaskType.PURCHASE,
|
||||||
version=1,
|
version=1,
|
||||||
priority=10,
|
priority=10,
|
||||||
|
execution_mode=execution_mode,
|
||||||
payload={
|
payload={
|
||||||
"goods_url": (
|
"goods_url": (
|
||||||
"https://mobile.yangkeduo.com/goods.html?"
|
"https://mobile.yangkeduo.com/goods.html?"
|
||||||
@@ -113,7 +149,9 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
|||||||
ClaimCapabilities(
|
ClaimCapabilities(
|
||||||
device=AndroidDeviceInfo("USB-001"),
|
device=AndroidDeviceInfo("USB-001"),
|
||||||
supported_types=(TaskType.PURCHASE,),
|
supported_types=(TaskType.PURCHASE,),
|
||||||
purchase_mode="dry_run",
|
purchase_mode=(
|
||||||
|
"live" if execution_mode == "live" else "dry_run"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert claimed is not None
|
assert claimed is not None
|
||||||
@@ -122,6 +160,7 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
|||||||
remote_task_id=claimed.task_id,
|
remote_task_id=claimed.task_id,
|
||||||
task_type=claimed.task_type,
|
task_type=claimed.task_type,
|
||||||
goods_url=str(claimed.payload["goods_url"]),
|
goods_url=str(claimed.payload["goods_url"]),
|
||||||
|
execution_mode=claimed.execution_mode,
|
||||||
goods_id=str(claimed.payload["goods_id"]),
|
goods_id=str(claimed.payload["goods_id"]),
|
||||||
quantity=int(claimed.payload["quantity"]),
|
quantity=int(claimed.payload["quantity"]),
|
||||||
priority=claimed.priority,
|
priority=claimed.priority,
|
||||||
@@ -236,6 +275,59 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
|||||||
assert detail is not None
|
assert detail is not None
|
||||||
self.assertEqual(detail.last_error_code, expected_code)
|
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):
|
def test_purchase_task_with_missing_safety_fields_is_reported(self):
|
||||||
task_id = "PUR-INVALID"
|
task_id = "PUR-INVALID"
|
||||||
task = AdminTask(
|
task = AdminTask(
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ from src.android_device_service import (
|
|||||||
AndroidWifiConversionResult,
|
AndroidWifiConversionResult,
|
||||||
)
|
)
|
||||||
from src.mock_admin_gateway import MockAdminGateway
|
from src.mock_admin_gateway import MockAdminGateway
|
||||||
|
from src.live_purchase_authorization import (
|
||||||
|
LIVE_CONFIRMATION_TEXT,
|
||||||
|
LIVE_ENABLED_KEY,
|
||||||
|
)
|
||||||
from src.selected_android_device_service import SELECTED_ANDROID_SERIAL_KEY
|
from src.selected_android_device_service import SELECTED_ANDROID_SERIAL_KEY
|
||||||
from src.settings_repository import SettingsRepository
|
from src.settings_repository import SettingsRepository
|
||||||
from src.settings_ui import AndroidDeviceRow, SettingsPage
|
from src.settings_ui import AndroidDeviceRow, SettingsPage
|
||||||
@@ -215,6 +219,63 @@ class SettingsPageEventTest(unittest.TestCase):
|
|||||||
page.eventBinder.shutdown()
|
page.eventBinder.shutdown()
|
||||||
page.deleteLater()
|
page.deleteLater()
|
||||||
|
|
||||||
|
def test_live_purchase_defaults_off_and_requires_exact_confirmation(self):
|
||||||
|
page = SettingsPage(
|
||||||
|
settings_repository=self.repository,
|
||||||
|
admin_gateway=MockAdminGateway(),
|
||||||
|
live_purchase_adapter_ready=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("已关闭", page.livePurchaseStatusLabel.text())
|
||||||
|
self.assertFalse(page.livePurchaseEnableButton.isEnabled())
|
||||||
|
page.livePurchaseConfirmationInput.setText("确认")
|
||||||
|
self.assertFalse(page.livePurchaseEnableButton.isEnabled())
|
||||||
|
page.livePurchaseConfirmationInput.setText(LIVE_CONFIRMATION_TEXT)
|
||||||
|
self.assertTrue(page.livePurchaseEnableButton.isEnabled())
|
||||||
|
page.eventBinder.shutdown()
|
||||||
|
page.deleteLater()
|
||||||
|
|
||||||
|
def test_enable_and_disable_live_purchase_updates_admin_capability(self):
|
||||||
|
self.repository.set_many(
|
||||||
|
{
|
||||||
|
"admin.client_id": "CLIENT-LIVE",
|
||||||
|
"admin.client_name": "采购电脑",
|
||||||
|
SELECTED_ANDROID_SERIAL_KEY: "USB-LIVE",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
gateway = MockAdminGateway()
|
||||||
|
service = SequenceAndroidDeviceService([[]])
|
||||||
|
page = SettingsPage(
|
||||||
|
settings_repository=self.repository,
|
||||||
|
admin_gateway=gateway,
|
||||||
|
android_device_service=service,
|
||||||
|
live_purchase_adapter_ready=True,
|
||||||
|
)
|
||||||
|
self._wait_until(lambda: page.eventBinder._search_thread is None)
|
||||||
|
page.livePurchaseConfirmationInput.setText(LIVE_CONFIRMATION_TEXT)
|
||||||
|
|
||||||
|
page.livePurchaseEnableButton.click()
|
||||||
|
self._wait_until(
|
||||||
|
lambda: page.eventBinder._live_purchase_thread is None
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(self.repository.get(LIVE_ENABLED_KEY), True)
|
||||||
|
self.assertIn("已启用", page.livePurchaseStatusLabel.text())
|
||||||
|
registered = gateway.registered_client("CLIENT-LIVE")
|
||||||
|
self.assertEqual(registered[1].purchase_mode, "live")
|
||||||
|
|
||||||
|
page.livePurchaseDisableButton.click()
|
||||||
|
self._wait_until(
|
||||||
|
lambda: page.eventBinder._live_purchase_thread is None
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(self.repository.get(LIVE_ENABLED_KEY), False)
|
||||||
|
self.assertIn("已关闭", page.livePurchaseStatusLabel.text())
|
||||||
|
registered = gateway.registered_client("CLIENT-LIVE")
|
||||||
|
self.assertEqual(registered[1].purchase_mode, "dry_run")
|
||||||
|
page.eventBinder.shutdown()
|
||||||
|
page.deleteLater()
|
||||||
|
|
||||||
def test_save_persists_locally_and_registers_with_mock(self):
|
def test_save_persists_locally_and_registers_with_mock(self):
|
||||||
gateway = MockAdminGateway()
|
gateway = MockAdminGateway()
|
||||||
page = SettingsPage(
|
page = SettingsPage(
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ from pathlib import Path
|
|||||||
from src.android_device_service import AndroidDeviceSearchError
|
from src.android_device_service import AndroidDeviceSearchError
|
||||||
from src.admin_gateway import AdminTask, ClientInfo, SubmissionReceipt
|
from src.admin_gateway import AdminTask, ClientInfo, SubmissionReceipt
|
||||||
from src.mock_admin_gateway import MockAdminGateway
|
from src.mock_admin_gateway import MockAdminGateway
|
||||||
from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState
|
from src.pdd_purchase_adapter import (
|
||||||
|
PddLivePurchaseAdapter,
|
||||||
|
PddPurchaseAdapter,
|
||||||
|
PurchasePageState,
|
||||||
|
)
|
||||||
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
|
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
|
||||||
from src.task_models import TaskStatus, TaskType
|
from src.task_models import TaskStatus, TaskType
|
||||||
from src.task_repository import TaskRepository
|
from src.task_repository import TaskRepository
|
||||||
@@ -70,12 +74,31 @@ class ReadyPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def purchase_task(task_id="PUR-001"):
|
class ReadyLivePurchaseAdapter(ReadyPurchaseAdapter, PddLivePurchaseAdapter):
|
||||||
|
def read_state(self):
|
||||||
|
state = super().read_state()
|
||||||
|
return PurchasePageState(
|
||||||
|
page_kind=state.page_kind,
|
||||||
|
goods_id=state.goods_id,
|
||||||
|
selected_options=state.selected_options,
|
||||||
|
quantity=state.quantity,
|
||||||
|
price_cent=state.price_cent,
|
||||||
|
candidate_count=state.candidate_count,
|
||||||
|
in_stock=True,
|
||||||
|
submit_candidate_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def submit_order_once(self):
|
||||||
|
self.calls.append(("purchase", "submitted_once"))
|
||||||
|
|
||||||
|
|
||||||
|
def purchase_task(task_id="PUR-001", execution_mode="dry_run"):
|
||||||
return AdminTask(
|
return AdminTask(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
task_type=TaskType.PURCHASE,
|
task_type=TaskType.PURCHASE,
|
||||||
version=1,
|
version=1,
|
||||||
priority=10,
|
priority=10,
|
||||||
|
execution_mode=execution_mode,
|
||||||
payload={
|
payload={
|
||||||
"goods_url": "https://example.test/PUR-GOODS",
|
"goods_url": "https://example.test/PUR-GOODS",
|
||||||
"goods_id": "PUR-GOODS",
|
"goods_id": "PUR-GOODS",
|
||||||
@@ -103,7 +126,14 @@ class TaskDispatcherTest(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.temporary.cleanup()
|
self.temporary.cleanup()
|
||||||
|
|
||||||
def _dispatcher(self, *, purchase_ready, device_checker=lambda _serial: None):
|
def _dispatcher(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
purchase_ready,
|
||||||
|
live_ready=False,
|
||||||
|
purchase_mode="dry_run",
|
||||||
|
device_checker=lambda _serial: None,
|
||||||
|
):
|
||||||
purchase_factory = None
|
purchase_factory = None
|
||||||
if purchase_ready:
|
if purchase_ready:
|
||||||
purchase_factory = (
|
purchase_factory = (
|
||||||
@@ -118,6 +148,16 @@ class TaskDispatcherTest(unittest.TestCase):
|
|||||||
lambda *_args: RecordingCollector(self.calls)
|
lambda *_args: RecordingCollector(self.calls)
|
||||||
),
|
),
|
||||||
purchase_adapter_factory=purchase_factory,
|
purchase_adapter_factory=purchase_factory,
|
||||||
|
live_purchase_adapter_factory=(
|
||||||
|
(
|
||||||
|
lambda _address, _cancelled: ReadyLivePurchaseAdapter(
|
||||||
|
self.calls
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if live_ready
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
purchase_mode=purchase_mode,
|
||||||
device_connection_checker=device_checker,
|
device_connection_checker=device_checker,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,6 +177,61 @@ class TaskDispatcherTest(unittest.TestCase):
|
|||||||
ready.claim_capabilities().purchase_mode, "dry_run"
|
ready.claim_capabilities().purchase_mode, "dry_run"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_live_capability_requires_authorization_and_live_factory(self):
|
||||||
|
no_factory = self._dispatcher(
|
||||||
|
purchase_ready=True, purchase_mode="live"
|
||||||
|
)
|
||||||
|
authorized = self._dispatcher(
|
||||||
|
purchase_ready=True,
|
||||||
|
live_ready=True,
|
||||||
|
purchase_mode="live",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
no_factory.claim_capabilities().purchase_mode, "dry_run"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
authorized.claim_capabilities().purchase_mode, "live"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_live_task_never_runs_after_authorization_is_disabled(self):
|
||||||
|
task = purchase_task(execution_mode="live")
|
||||||
|
self.repository.add_claimed_task(admin_task_to_new_claimed_task(task))
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "授权已关闭"):
|
||||||
|
self._dispatcher(
|
||||||
|
purchase_ready=True,
|
||||||
|
live_ready=True,
|
||||||
|
purchase_mode="dry_run",
|
||||||
|
).execute_one()
|
||||||
|
|
||||||
|
self.assertNotIn(("purchase", "submitted_once"), self.calls)
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.get_task(task.task_id).status,
|
||||||
|
TaskStatus.CLAIMED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_live_task_is_claimed_only_by_authorized_live_dispatcher(self):
|
||||||
|
task = purchase_task(execution_mode="live")
|
||||||
|
self.gateway.enqueue_task(task, self.client.client_id)
|
||||||
|
|
||||||
|
dry_outcome = self._dispatcher(purchase_ready=True).execute_one()
|
||||||
|
live_outcome = self._dispatcher(
|
||||||
|
purchase_ready=True,
|
||||||
|
live_ready=True,
|
||||||
|
purchase_mode="live",
|
||||||
|
).execute_one()
|
||||||
|
|
||||||
|
self.assertEqual(dry_outcome.kind, "no_task")
|
||||||
|
self.assertEqual(live_outcome.kind, "manual_review")
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.get_task(task.task_id).execution_mode,
|
||||||
|
"live",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.calls.count(("purchase", "submitted_once")), 1
|
||||||
|
)
|
||||||
|
|
||||||
def test_claims_saves_then_dispatches_purchase_dry_run(self):
|
def test_claims_saves_then_dispatches_purchase_dry_run(self):
|
||||||
task = purchase_task()
|
task = purchase_task()
|
||||||
self.gateway.enqueue_task(task, self.client.client_id)
|
self.gateway.enqueue_task(task, self.client.client_id)
|
||||||
|
|||||||
@@ -32,10 +32,12 @@ class TaskRepositoryTests(unittest.TestCase):
|
|||||||
task_type: TaskType = TaskType.COLLECT,
|
task_type: TaskType = TaskType.COLLECT,
|
||||||
title: str = "测试商品",
|
title: str = "测试商品",
|
||||||
goods_id: str = "10001",
|
goods_id: str = "10001",
|
||||||
|
execution_mode: str = "dry_run",
|
||||||
) -> NewClaimedTask:
|
) -> NewClaimedTask:
|
||||||
return NewClaimedTask(
|
return NewClaimedTask(
|
||||||
remote_task_id=remote_task_id,
|
remote_task_id=remote_task_id,
|
||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
|
execution_mode=execution_mode,
|
||||||
goods_id=goods_id,
|
goods_id=goods_id,
|
||||||
goods_url=f"https://example.test/goods/{goods_id}",
|
goods_url=f"https://example.test/goods/{goods_id}",
|
||||||
title=title,
|
title=title,
|
||||||
@@ -96,6 +98,43 @@ class TaskRepositoryTests(unittest.TestCase):
|
|||||||
self.assertEqual(detail.admin_payload["task_id"], "TASK-001")
|
self.assertEqual(detail.admin_payload["task_id"], "TASK-001")
|
||||||
self.assertEqual(detail.pdd_data["schema_version"], 1)
|
self.assertEqual(detail.pdd_data["schema_version"], 1)
|
||||||
|
|
||||||
|
def test_live_mode_and_irreversible_marker_are_persisted_once(self) -> None:
|
||||||
|
self.repository.add_claimed_task(
|
||||||
|
self._task(
|
||||||
|
"PURCHASE-LIVE",
|
||||||
|
TaskType.PURCHASE,
|
||||||
|
execution_mode="live",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
detail = self.repository.get_task("PURCHASE-LIVE")
|
||||||
|
self.assertEqual(detail.execution_mode, "live")
|
||||||
|
|
||||||
|
started = self.repository.start_purchase_run(
|
||||||
|
"PURCHASE-LIVE", "192.168.0.173:5555"
|
||||||
|
)
|
||||||
|
marked_at = self.repository.mark_purchase_irreversible(
|
||||||
|
"PURCHASE-LIVE", started.attempt_id
|
||||||
|
)
|
||||||
|
run = self.repository.latest_task_run("PURCHASE-LIVE")
|
||||||
|
self.assertEqual(run.irreversible_action_at, marked_at)
|
||||||
|
with self.assertRaisesRegex(ValueError, "已经存在"):
|
||||||
|
self.repository.mark_purchase_irreversible(
|
||||||
|
"PURCHASE-LIVE", started.attempt_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dry_run_cannot_enter_irreversible_stage(self) -> None:
|
||||||
|
self.repository.add_claimed_task(
|
||||||
|
self._task("PURCHASE-DRY", TaskType.PURCHASE)
|
||||||
|
)
|
||||||
|
started = self.repository.start_purchase_run(
|
||||||
|
"PURCHASE-DRY", "USB-001"
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "演练任务"):
|
||||||
|
self.repository.mark_purchase_irreversible(
|
||||||
|
"PURCHASE-DRY", started.attempt_id
|
||||||
|
)
|
||||||
|
|
||||||
def test_duplicate_remote_task_id_does_not_overwrite(self) -> None:
|
def test_duplicate_remote_task_id_does_not_overwrite(self) -> None:
|
||||||
self.repository.add_claimed_task(self._task("TASK-001"))
|
self.repository.add_claimed_task(self._task("TASK-001"))
|
||||||
|
|
||||||
|
|||||||
@@ -344,19 +344,21 @@ Client 与 Admin 的任务交互只有三种调用:领一个任务、提交结
|
|||||||
|
|
||||||
### 任务领取与执行
|
### 任务领取与执行
|
||||||
|
|
||||||
1. 优先补交一条本地 Outbox。提交结果不依赖 Android 设备。
|
1. 若存在 `irreversible_action_at`,只允许先核对该采购订单;不得补交其他结果、执行任务或领取任务。
|
||||||
2. 没有待提交结果时,在工作线程通过 ADB 检查已保存设备号是否仍为 `device` 状态。检查失败立即停止,不执行本地任务,也不领取新任务。
|
2. 没有待核对采购时,优先补交一条本地 Outbox;提交结果不依赖 Android 设备。
|
||||||
3. 执行最早的本地待处理任务;没有本地任务时才调用 Admin `claim`。返回 204 表示暂时没活,按轮询周期退避后再试。
|
3. 没有待提交结果时,在工作线程通过 ADB 检查已保存设备号是否仍为 `device` 状态。检查失败立即停止,不执行本地任务,也不领取新任务。
|
||||||
4. Client 持久化新领取的任务,状态置 `claimed`。
|
4. 执行最早的本地待处理任务;没有本地任务时才调用 Admin `claim`。返回 204 表示暂时没活,按轮询周期退避后再试。
|
||||||
5. 根据 `task_type` 分派给采集或采购执行器。
|
5. Client 持久化新领取的任务,状态置 `claimed`。
|
||||||
6. 工作线程执行,持续更新 `current_step`(只写本地,不上报 Admin)。
|
6. 根据 `task_type` 分派给采集或采购执行器。
|
||||||
7. 完成后在同一事务里写入结果与 Outbox,状态置 `result_pending`。
|
7. 工作线程执行,持续更新 `current_step`(只写本地,不上报 Admin)。
|
||||||
8. Outbox 提交成功、Admin 返回 `accepted: true` 后标记 `succeeded`。
|
8. 完成后在同一事务里写入结果与 Outbox,状态置 `result_pending`。
|
||||||
9. 回到第 1 步处理下一轮。**同一时间只做一个任务。**
|
9. Outbox 提交成功、Admin 返回 `accepted: true` 后标记 `succeeded`,然后继续下一轮。**同一时间只做一个任务。**
|
||||||
|
|
||||||
`TaskDispatcher` 是能力声明的唯一入口。没有可用采购演练 Adapter、没有已保存
|
`TaskDispatcher` 是能力声明的唯一入口。没有可用采购 Adapter、没有已保存
|
||||||
Android 设备或本地持久化未准备好时,只声明 `collect`;条件满足时才声明
|
Android 设备或本地持久化未准备好时,只声明 `collect`;条件满足时才声明
|
||||||
`collect,purchase`,且 `purchase_mode` 永远是 `dry_run`。领取响应必须先完整校验并
|
`collect,purchase`。真实下单授权默认关闭;只有本地授权同时匹配当前 Client ID、
|
||||||
|
已保存 Android 设备且 live Adapter 就绪时才声明 `purchase_mode=live`,否则声明
|
||||||
|
`dry_run`。领取响应必须先完整校验并
|
||||||
写入 SQLite,Repository 提交成功后才能分派,避免任务已在 Admin 领取却在本地丢失。
|
写入 SQLite,Repository 提交成功后才能分派,避免任务已在 Admin 领取却在本地丢失。
|
||||||
|
|
||||||
中途 Admin 是否取消了这个任务、是否重派给了别人,Client 不查也不管,做完照样提交——
|
中途 Admin 是否取消了这个任务、是否重派给了别人,Client 不查也不管,做完照样提交——
|
||||||
@@ -385,11 +387,15 @@ reconcile_purchase(task, run) -> PurchaseResult | ManualReview
|
|||||||
采购演练通过 `PddPurchaseAdapter` 的窄接口逐步读取最新页面状态。该接口只提供
|
采购演练通过 `PddPurchaseAdapter` 的窄接口逐步读取最新页面状态。该接口只提供
|
||||||
打开商品、读取状态、精确选择动态规格、设置数量、进入提交前确认页和停止,
|
打开商品、读取状态、精确选择动态规格、设置数量、进入提交前确认页和停止,
|
||||||
**不提供提交订单或付款方法**。这样即使应用层调用错误,也没有可误触的真实下单入口。
|
**不提供提交订单或付款方法**。这样即使应用层调用错误,也没有可误触的真实下单入口。
|
||||||
|
真实采购另用 `PddLivePurchaseAdapter`,只增加 `submit_order_once`。服务层在最新
|
||||||
|
页面复核规格、数量、价格、库存和唯一提交目标后,先提交
|
||||||
|
`irreversible_action_at` 事务,再允许 Adapter 点击一次;之后无论点击结果是否明确,
|
||||||
|
都只进入订单核对。该接口不提供付款或取消订单方法。
|
||||||
采购规格使用完整 `options` 对象精确比较,不假定只有颜色和尺码两个维度。
|
采购规格使用完整 `options` 对象精确比较,不假定只有颜色和尺码两个维度。
|
||||||
只读核对另用 `PddPurchaseReconcileAdapter`,只暴露 `read_order_match`
|
只读核对另用 `PddPurchaseReconcileAdapter`,只暴露 `read_order_match`
|
||||||
和 `close`,不暴露选规格、设数量、下单或付款方法。
|
和 `close`,不暴露选规格、设数量、下单或付款方法。
|
||||||
|
|
||||||
正式 Client 由 `pdd_u2_purchase_adapter.py` 实现上述演练接口,并在
|
正式 Client 由 `pdd_u2_purchase_adapter.py` 分别实现演练和 live 接口,并在
|
||||||
`ui_main.py` 注入工厂。Adapter 通过 `PddDeviceService` 独占连接,每次判断
|
`ui_main.py` 注入工厂。Adapter 通过 `PddDeviceService` 独占连接,每次判断
|
||||||
都重新读取当前包名和控件树。当前 Admin 下发的 `color` 和 `size`
|
都重新读取当前包名和控件树。当前 Admin 下发的 `color` 和 `size`
|
||||||
使用精确文字匹配;其他动态维度直接停止,不做相似匹配。原生 PDD
|
使用精确文字匹配;其他动态维度直接停止,不做相似匹配。原生 PDD
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ CREATE TABLE pdd_tasks (
|
|||||||
remote_task_id TEXT NOT NULL UNIQUE,
|
remote_task_id TEXT NOT NULL UNIQUE,
|
||||||
task_type TEXT NOT NULL
|
task_type TEXT NOT NULL
|
||||||
CHECK (task_type IN ('collect', 'purchase')),
|
CHECK (task_type IN ('collect', 'purchase')),
|
||||||
|
execution_mode TEXT NOT NULL DEFAULT 'dry_run'
|
||||||
|
CHECK (execution_mode IN ('dry_run', 'live')),
|
||||||
goods_id TEXT,
|
goods_id TEXT,
|
||||||
goods_url TEXT NOT NULL,
|
goods_url TEXT NOT NULL,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
@@ -167,6 +169,7 @@ CREATE INDEX idx_pdd_tasks_goods_id
|
|||||||
### 字段语义
|
### 字段语义
|
||||||
|
|
||||||
- `remote_task_id`:跨 Admin、Client、日志、提交和归档使用的稳定任务编号。Mock 任务也必须分配稳定编号。
|
- `remote_task_id`:跨 Admin、Client、日志、提交和归档使用的稳定任务编号。Mock 任务也必须分配稳定编号。
|
||||||
|
- `execution_mode`:Admin 领取时确定的不可变执行模式;历史记录默认为 `dry_run`,Client 不得把任务自行提升为 `live`。
|
||||||
- `price_cent`:列表摘要价格;采集任务通常为最低可用 SKU 价格,详细价格以 `pdd_data.skus` 为准。
|
- `price_cent`:列表摘要价格;采集任务通常为最低可用 SKU 价格,详细价格以 `pdd_data.skus` 为准。
|
||||||
- `status`:**本机执行状态**,只由本机的执行流程和 Outbox 提交响应驱动。Admin 那边把任务标成什么,本地不知道也不需要知道(见 §3.1)。
|
- `status`:**本机执行状态**,只由本机的执行流程和 Outbox 提交响应驱动。Admin 那边把任务标成什么,本地不知道也不需要知道(见 §3.1)。
|
||||||
- `current_step`:当前安全步骤,例如 `open_goods`、`collect_skus`、`select_options`、`placing_order`、`reconcile_order`。
|
- `current_step`:当前安全步骤,例如 `open_goods`、`collect_skus`、`select_options`、`placing_order`、`reconcile_order`。
|
||||||
@@ -407,12 +410,17 @@ CREATE TABLE app_settings (
|
|||||||
- `automation.poll_interval_seconds`
|
- `automation.poll_interval_seconds`
|
||||||
- `automation.max_retries`
|
- `automation.max_retries`
|
||||||
- `automation.dry_run`
|
- `automation.dry_run`
|
||||||
|
- `purchase.live_enabled`
|
||||||
|
- `purchase.live_client_id`
|
||||||
|
- `purchase.live_device_serial`
|
||||||
|
- `purchase.live_confirmed_at`
|
||||||
- `safety.max_quantity`
|
- `safety.max_quantity`
|
||||||
- `safety.price_tolerance_cent`
|
- `safety.price_tolerance_cent`
|
||||||
- `diagnostics.artifact_directory`
|
- `diagnostics.artifact_directory`
|
||||||
- `diagnostics.retention_days`
|
- `diagnostics.retention_days`
|
||||||
|
|
||||||
访问令牌、密码和 Cookie 不得存入本表。
|
访问令牌、密码和 Cookie 不得存入本表。
|
||||||
|
`purchase.live_*` 只保存非敏感的本地安全门禁状态;缺失、损坏或绑定不一致时一律按关闭处理。
|
||||||
|
|
||||||
## 7. 任务状态转换
|
## 7. 任务状态转换
|
||||||
|
|
||||||
|
|||||||
@@ -210,9 +210,10 @@ POST /api/v1/client/tasks/claim
|
|||||||
无可领取任务时返回 `204 No Content`。
|
无可领取任务时返回 `204 No Content`。
|
||||||
|
|
||||||
Client 的 `HttpAdminGateway.claim_next` 已实现本接口,并原样序列化应用层已经
|
Client 的 `HttpAdminGateway.claim_next` 已实现本接口,并原样序列化应用层已经
|
||||||
安全确认的 `ClaimCapabilities`。没有采购演练 Adapter 或设备未准备好时只声明
|
安全确认的 `ClaimCapabilities`。没有采购 Adapter 或设备未准备好时只声明
|
||||||
`supported_types: ["collect"]`;条件满足时声明 `collect,purchase`,但
|
`supported_types: ["collect"]`;条件满足时声明 `collect,purchase`。只有设置页
|
||||||
`purchase_mode` 固定为 `dry_run`。一次调用最多领取一个任务,结果或失败通过
|
授权同时匹配当前 Client ID、已保存 Android 设备且 live Adapter 就绪时才声明
|
||||||
|
`purchase_mode: "live"`,其他情况均为 `dry_run`。一次调用最多领取一个任务,结果或失败通过
|
||||||
本页 §6 或 §7 提交,完整请求会先进入本地 Outbox。
|
本页 §6 或 §7 提交,完整请求会先进入本地 Outbox。
|
||||||
|
|
||||||
采购响应在写入本地前必须校验非空 `goods_url`、`goods_id`、动态 `options`、
|
采购响应在写入本地前必须校验非空 `goods_url`、`goods_id`、动态 `options`、
|
||||||
|
|||||||
@@ -64,8 +64,8 @@
|
|||||||
才向 Admin 领取一条。领取、手机采集和提交都在单独的工作线程完成,一轮结束且
|
才向 Admin 领取一条。领取、手机采集和提交都在单独的工作线程完成,一轮结束且
|
||||||
线程完全退出后,由主线程的单次定时器安排下一轮。暂无任务时默认 5 秒后重试;
|
线程完全退出后,由主线程的单次定时器安排下一轮。暂无任务时默认 5 秒后重试;
|
||||||
可恢复的 Admin 错误按 5、10、20、30 秒退避。需要人工处理、不可恢复错误、设备
|
可恢复的 Admin 错误按 5、10、20、30 秒退避。需要人工处理、不可恢复错误、设备
|
||||||
或配置错误会停止自动获取。采购演练执行器和 Android 设备都准备好时,可以领取
|
或配置错误会停止自动获取。采购 Adapter 和 Android 设备都准备好时,可以领取
|
||||||
采购任务;界面必须明确显示“演练”,任何路径都不得显示成真实下单。
|
采购任务;任务详情必须按任务的不可变执行模式明确显示“演练”或“真实下单”。
|
||||||
|
|
||||||
- 补交本地 Outbox 不依赖手机,可以在 Android 设备断开时继续执行。
|
- 补交本地 Outbox 不依赖手机,可以在 Android 设备断开时继续执行。
|
||||||
- 执行本地任务或向 Admin 领取新任务前,工作线程必须用 `adb devices -l` 检查已保存的精确设备号。USB 和 Wi-Fi 设备都只接受 `device` 状态。
|
- 执行本地任务或向 Admin 领取新任务前,工作线程必须用 `adb devices -l` 检查已保存的精确设备号。USB 和 Wi-Fi 设备都只接受 `device` 状态。
|
||||||
@@ -288,6 +288,14 @@ class TaskTableModel(QAbstractTableModel):
|
|||||||
- 最大重试次数;
|
- 最大重试次数;
|
||||||
- 演练模式开关。
|
- 演练模式开关。
|
||||||
|
|
||||||
|
### 真实下单(不支付)
|
||||||
|
|
||||||
|
- 默认关闭,并明确显示当前状态、绑定 Client、绑定 Android 设备和确认时间;
|
||||||
|
- 只有输入完整确认文字“创建未付款订单”后才能启用,不能通过环境变量或调试参数开启;
|
||||||
|
- 启用和关闭在后台线程保存,执行期间禁用重复操作,失败时保留原状态并说明恢复办法;
|
||||||
|
- 启用仅代表允许创建一笔未付款订单,不允许自动付款;设备或 Client 绑定变化后自动降级为演练;
|
||||||
|
- 关闭操作始终可见,状态不能只用颜色表达。
|
||||||
|
|
||||||
### 安全与诊断
|
### 安全与诊断
|
||||||
|
|
||||||
- 最大购买数量;
|
- 最大购买数量;
|
||||||
|
|||||||
@@ -81,10 +81,11 @@ PDD 解析测试优先使用脱敏的 XML 固件,不要求每次连接真实
|
|||||||
7. 演练模式在代表性商品和设备上通过验收。
|
7. 演练模式在代表性商品和设备上通过验收。
|
||||||
8. 操作人员明确确认真实下单范围和安全设置。
|
8. 操作人员明确确认真实下单范围和安全设置。
|
||||||
|
|
||||||
真实下单不得通过调试参数、默认配置或界面误操作意外开启。
|
真实下单不得通过调试参数、默认配置或界面误操作意外开启。当前版本仅在设置页
|
||||||
当前版本的 `ClaimCapabilities` 仅接受 `purchase_mode=dry_run`,采购
|
精确输入确认文字后保存授权,并绑定 Client ID 和已保存 Android 设备;任一绑定
|
||||||
Adapter 不提供提交订单或付款方法。开发者不能把自动化测试通过当成真实
|
不一致都降级为 `purchase_mode=dry_run`。演练 Adapter 不提供提交方法,live Adapter
|
||||||
下单门禁放行;仍需项目负责人和实际操作人员共同确认。
|
只提供一次性提交方法,两者都不提供付款或取消订单方法。开发者不能把自动化测试
|
||||||
|
通过当成真机门禁放行;仍需项目负责人和实际操作人员共同确认。
|
||||||
|
|
||||||
### 3.1 当前自动化安全检查
|
### 3.1 当前自动化安全检查
|
||||||
|
|
||||||
@@ -93,12 +94,14 @@ Adapter 不提供提交订单或付款方法。开发者不能把自动化测试
|
|||||||
- 有不可逆标记的中断只调用只读核对,采购 Adapter 调用次数为 0。
|
- 有不可逆标记的中断只调用只读核对,采购 Adapter 调用次数为 0。
|
||||||
- 已落库 Outbox 只补交,不重跑手机流程。
|
- 已落库 Outbox 只补交,不重跑手机流程。
|
||||||
- 停止、设备断开、验证码、登录失效和结果不确定都保留稳定错误和诊断。
|
- 停止、设备断开、验证码、登录失效和结果不确定都保留稳定错误和诊断。
|
||||||
- `purchase_mode=live` 会在数据对象创建时被拒绝。
|
- `execution_mode=live` 只允许采购任务,且 Client 不得自行升级 Admin 下发的模式。
|
||||||
|
- 最终点击前重新读取页面并核对规格、数量、价格、库存和唯一提交目标。
|
||||||
|
- `irreversible_action_at` 必须在点击前以事务提交;点击最多一次,返回或抛错后均只进入核单。
|
||||||
- 正式 uiautomator2 采购 Adapter 只点击商品页采购入口和目标规格,
|
- 正式 uiautomator2 采购 Adapter 只点击商品页采购入口和目标规格,
|
||||||
`enter_confirmation` 与 `stop_before_submit` 均为只读检查。
|
演练的 `enter_confirmation` 与 `stop_before_submit` 均为只读检查。
|
||||||
- 单元测试必须断言到达最终确认页后没有产生第二次底部按钮点击。
|
- 单元测试必须断言演练没有最终点击、live 只有一次最终点击,且模糊目标不会点击。
|
||||||
|
|
||||||
上述只证明演练和恢复代码的默认安全性,**不代表真实下单已放行**。
|
上述只证明本地安全边界;真实设备联调仍须按独立验收工单获得当次明确授权。
|
||||||
|
|
||||||
## 4. 自动化防护
|
## 4. 自动化防护
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user