feat: 移除 Client 真实采购手工授权 (#114)
This commit is contained in:
+1
-1
@@ -60,7 +60,7 @@
|
||||
|
||||
## 采购安全
|
||||
|
||||
- Admin 新建采购任务固定下发 `live`;Client 本地真实采购授权仍默认关闭,未满足质量文档中的设备绑定和采购安全门禁不得声明 live 能力或领取任务。
|
||||
- Admin 新建采购任务固定下发 `live`;Client 不提供手工启用或关闭真实采购的请求、设置或调试开关。Client 身份、已选 Android 设备和真实采购执行器就绪时自动声明 live;任一项未就绪时不得领取真实采购任务。
|
||||
- 高风险点击前必须使用最新页面状态重新校验商品、规格、数量、价格和目标坐标。
|
||||
- 进入不可逆下单阶段前先持久化执行步骤。
|
||||
- 不可逆阶段发生崩溃或结果不确定时,只能核对订单或转人工处理,禁止自动重新下单。
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"""真实下单的本地授权状态。
|
||||
|
||||
授权只来自设置页的明确确认,并绑定当前 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"
|
||||
@@ -52,7 +52,6 @@ from .pdd_device_service import (
|
||||
)
|
||||
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 .selected_android_device_service import SelectedAndroidDeviceService
|
||||
from .settings_repository import SettingsRepository
|
||||
@@ -140,9 +139,6 @@ class ClaimTaskWorker(QObject):
|
||||
live_purchase_adapter_factory: Optional[
|
||||
LivePurchaseAdapterFactory
|
||||
] = None,
|
||||
live_authorization_service: Optional[
|
||||
LivePurchaseAuthorizationService
|
||||
] = None,
|
||||
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
||||
selected_task_id: str = "",
|
||||
device_connection_checker: Optional[Callable[[str], None]] = None,
|
||||
@@ -156,7 +152,6 @@ class ClaimTaskWorker(QObject):
|
||||
self._collect_service_factory = collect_service_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._selected_task_id = selected_task_id
|
||||
self._device_connection_checker = (
|
||||
@@ -222,18 +217,12 @@ class ClaimTaskWorker(QObject):
|
||||
)
|
||||
result = service.execute_selected(selected_task_id)
|
||||
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
|
||||
),
|
||||
)
|
||||
)
|
||||
purchase_mode = (
|
||||
"live"
|
||||
if android_serial
|
||||
and self._live_purchase_adapter_factory is not None
|
||||
else "dry_run"
|
||||
)
|
||||
dispatcher = TaskDispatcher(
|
||||
self._gateway,
|
||||
self._task_repository,
|
||||
@@ -496,9 +485,6 @@ class PDDTaskPageEvent(QObject):
|
||||
pass
|
||||
|
||||
settings = settings_repository or SettingsRepository()
|
||||
self._live_authorization_service = LivePurchaseAuthorizationService(
|
||||
settings
|
||||
)
|
||||
self._client_service = CurrentClientService(settings)
|
||||
self._selected_android_device_service = SelectedAndroidDeviceService(
|
||||
settings
|
||||
@@ -1024,7 +1010,6 @@ class PDDTaskPageEvent(QObject):
|
||||
collect_service_factory=self._collect_service_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,
|
||||
device_connection_checker=self._device_connection_checker,
|
||||
)
|
||||
|
||||
@@ -298,34 +298,6 @@ class SettingsPage(QWidget):
|
||||
self.pddAppStatusLabel.setWordWrap(True)
|
||||
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.setAccessibleName("当前软件版本")
|
||||
self.updateManifestUrlInput = LineEdit(self)
|
||||
@@ -361,7 +333,6 @@ class SettingsPage(QWidget):
|
||||
contentLayout.addWidget(TitleLabel("设置", content))
|
||||
contentLayout.addWidget(self.currentDeviceCard)
|
||||
contentLayout.addWidget(self.androidDeviceCard)
|
||||
contentLayout.addWidget(self.livePurchaseCard)
|
||||
contentLayout.addWidget(self.softwareUpdateCard)
|
||||
contentLayout.addStretch(1)
|
||||
|
||||
@@ -486,52 +457,6 @@ class SettingsPage(QWidget):
|
||||
layout.addLayout(commandLayout)
|
||||
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:
|
||||
"""显示后续设备身份服务提供的当前客户端信息。"""
|
||||
|
||||
@@ -559,28 +484,6 @@ class SettingsPage(QWidget):
|
||||
|
||||
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:
|
||||
"""显示已经保存并实际用于自动化的 Android 设备。"""
|
||||
|
||||
|
||||
+14
-259
@@ -35,11 +35,6 @@ from .current_client_service import (
|
||||
)
|
||||
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
|
||||
from .selected_android_device_service import SelectedAndroidDeviceService
|
||||
from .live_purchase_authorization import (
|
||||
LIVE_CONFIRMATION_TEXT,
|
||||
LivePurchaseAuthorization,
|
||||
LivePurchaseAuthorizationService,
|
||||
)
|
||||
from .settings_repository import SettingsRepository
|
||||
from .settings_ui import AndroidDeviceRow
|
||||
from .task_models import TaskType
|
||||
@@ -300,85 +295,6 @@ class AndroidDeviceSettingWorker(QObject):
|
||||
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):
|
||||
"""绑定设备管理控件,并向应用层发出稳定事件。"""
|
||||
|
||||
@@ -387,7 +303,6 @@ class SettingsPageEventBinder(QObject):
|
||||
currentDeviceSaveRequested = pyqtSignal(str, str)
|
||||
saveRequested = pyqtSignal(str, str)
|
||||
deleteRequested = pyqtSignal(str)
|
||||
liveAuthorizationRequested = pyqtSignal(str, str)
|
||||
androidDeviceConfigurationChanged = pyqtSignal(str)
|
||||
|
||||
def __init__(
|
||||
@@ -429,11 +344,7 @@ class SettingsPageEventBinder(QObject):
|
||||
self._android_setting_thread: Optional[QThread] = None
|
||||
self._android_setting_worker: Optional[AndroidDeviceSettingWorker] = None
|
||||
self._saved_android_serial = ""
|
||||
self._live_purchase_busy = False
|
||||
self._live_purchase_thread: Optional[QThread] = None
|
||||
self._live_purchase_worker: Optional[
|
||||
LivePurchaseAuthorizationWorker
|
||||
] = None
|
||||
self._refresh_registration_after_android_setting = False
|
||||
self._live_purchase_adapter_ready = bool(
|
||||
live_purchase_adapter_ready
|
||||
)
|
||||
@@ -450,10 +361,6 @@ class SettingsPageEventBinder(QObject):
|
||||
parent=self,
|
||||
)
|
||||
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(
|
||||
repository
|
||||
)
|
||||
@@ -484,18 +391,6 @@ class SettingsPageEventBinder(QObject):
|
||||
page.saveButton.clicked.connect(self._request_save)
|
||||
self.saveRequested.connect(self._start_save_android_device)
|
||||
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)
|
||||
page.deviceTableModel.checkedDeviceChanged.connect(
|
||||
self._on_checked_device_changed
|
||||
@@ -507,7 +402,6 @@ class SettingsPageEventBinder(QObject):
|
||||
|
||||
self._load_current_client()
|
||||
self._load_selected_android_device()
|
||||
self._show_live_authorization(self._live_authorization)
|
||||
self._sync_button_state()
|
||||
if self._saved_android_serial:
|
||||
QTimer.singleShot(0, self._request_restore_saved_android_device)
|
||||
@@ -543,11 +437,10 @@ class SettingsPageEventBinder(QObject):
|
||||
|
||||
try:
|
||||
device = self._selected_android_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,
|
||||
purchase_mode = (
|
||||
"live"
|
||||
if device is not None and self._live_purchase_adapter_ready
|
||||
else "dry_run"
|
||||
)
|
||||
supported_types = (
|
||||
(TaskType.COLLECT, TaskType.PURCHASE)
|
||||
@@ -591,128 +484,6 @@ class SettingsPageEventBinder(QObject):
|
||||
self._worker = worker
|
||||
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()
|
||||
def _request_search(self) -> None:
|
||||
if (
|
||||
@@ -1121,25 +892,6 @@ class SettingsPageEventBinder(QObject):
|
||||
self._page.deleteButton.setEnabled(
|
||||
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:
|
||||
"""切换界面忙碌状态,防止用户重复提交设备命令。"""
|
||||
@@ -1182,7 +934,7 @@ class SettingsPageEventBinder(QObject):
|
||||
)
|
||||
|
||||
def _selected_android_device(self) -> Optional[AndroidDeviceInfo]:
|
||||
serial = self._page.deviceTableModel.checked_serial.strip()
|
||||
serial = self._selected_android_device_service.load().strip()
|
||||
return AndroidDeviceInfo(serial) if serial else None
|
||||
|
||||
@pyqtSlot(object)
|
||||
@@ -1364,6 +1116,10 @@ class SettingsPageEventBinder(QObject):
|
||||
"当前使用设备:未选择(本地配置已删除)"
|
||||
)
|
||||
self.androidDeviceConfigurationChanged.emit("")
|
||||
displayed_client_id = self._page.deviceIdInput.text().strip()
|
||||
self._refresh_registration_after_android_setting = bool(
|
||||
displayed_client_id and displayed_client_id != DEVICE_ID_PLACEHOLDER
|
||||
)
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def _on_android_device_setting_failed(
|
||||
@@ -1375,6 +1131,7 @@ class SettingsPageEventBinder(QObject):
|
||||
self._page.deviceStatusLabel.setText(
|
||||
f"{action_text}失败:{message};原设备配置未更改,可重试"
|
||||
)
|
||||
self._refresh_registration_after_android_setting = False
|
||||
|
||||
@pyqtSlot()
|
||||
def _on_android_device_setting_finished(self) -> None:
|
||||
@@ -1383,6 +1140,9 @@ class SettingsPageEventBinder(QObject):
|
||||
self._android_setting_busy = False
|
||||
if not self._closing:
|
||||
self._sync_button_state()
|
||||
if self._refresh_registration_after_android_setting:
|
||||
self._refresh_registration_after_android_setting = False
|
||||
QTimer.singleShot(0, self._request_save_current_device)
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def _on_local_saved(self, client_id: str, client_name: str) -> None:
|
||||
@@ -1449,11 +1209,6 @@ class SettingsPageEventBinder(QObject):
|
||||
thread.quit()
|
||||
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_thread = self._search_thread
|
||||
if search_worker is not None:
|
||||
|
||||
@@ -310,7 +310,7 @@ class TaskDispatcher:
|
||||
if task.execution_mode == "live":
|
||||
if self.claim_capabilities().purchase_mode != "live":
|
||||
raise RuntimeError(
|
||||
f"真实采购任务 {task.remote_task_id} 的本地授权已关闭或绑定不一致;"
|
||||
f"真实采购任务 {task.remote_task_id} 的设备或真实采购执行器未就绪;"
|
||||
"任务保持待执行,不会降级为演练"
|
||||
)
|
||||
factory = self._live_purchase_factory
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""真实下单授权服务测试;不连接 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)
|
||||
@@ -1249,6 +1249,27 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_ready_runtime_automatically_declares_live_capability(self):
|
||||
gateway = RecordingClaimGateway(None)
|
||||
page = PDDTaskPage()
|
||||
events = PDDTaskPageEvent(
|
||||
page,
|
||||
self.repository,
|
||||
claim_gateway=gateway,
|
||||
settings_repository=self._saved_settings(),
|
||||
purchase_adapter_factory=fake_purchase_factory,
|
||||
live_purchase_adapter_factory=lambda *_args: None,
|
||||
)
|
||||
|
||||
page.autoFetchRequested.emit()
|
||||
|
||||
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
|
||||
_, capabilities = gateway.calls[0]
|
||||
self.assertEqual(capabilities.purchase_mode, "live")
|
||||
self.assertIn(TaskType.PURCHASE, capabilities.supported_types)
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_no_task_schedules_next_claim_and_stop_cancels_timer(self):
|
||||
gateway = RecordingClaimGateway(None)
|
||||
page = PDDTaskPage()
|
||||
|
||||
@@ -19,13 +19,10 @@ from src.android_device_service import (
|
||||
AndroidWifiConversionResult,
|
||||
)
|
||||
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.settings_repository import SettingsRepository
|
||||
from src.settings_ui import AndroidDeviceRow, SettingsPage
|
||||
from src.task_models import TaskType
|
||||
|
||||
|
||||
class SlowMockAdminGateway(MockAdminGateway):
|
||||
@@ -219,23 +216,20 @@ class SettingsPageEventTest(unittest.TestCase):
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_live_purchase_defaults_off_and_requires_exact_confirmation(self):
|
||||
def test_settings_page_has_no_manual_live_purchase_authorization(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())
|
||||
self.assertFalse(hasattr(page, "livePurchaseCard"))
|
||||
self.assertFalse(hasattr(page, "livePurchaseEnableButton"))
|
||||
self.assertFalse(hasattr(page, "livePurchaseConfirmationInput"))
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_enable_and_disable_live_purchase_updates_admin_capability(self):
|
||||
def test_ready_client_automatically_registers_live_capability(self):
|
||||
self.repository.set_many(
|
||||
{
|
||||
"admin.client_id": "CLIENT-LIVE",
|
||||
@@ -252,27 +246,12 @@ class SettingsPageEventTest(unittest.TestCase):
|
||||
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())
|
||||
page.currentDeviceSaveButton.click()
|
||||
self._wait_until(lambda: gateway.registration_count == 1)
|
||||
self._wait_until(lambda: page.eventBinder._thread is None)
|
||||
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")
|
||||
self.assertIn(TaskType.PURCHASE, registered[1].supported_types)
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
|
||||
@@ -220,11 +220,11 @@ class TaskDispatcherTest(unittest.TestCase):
|
||||
authorized.claim_capabilities().purchase_mode, "live"
|
||||
)
|
||||
|
||||
def test_local_live_task_never_runs_after_authorization_is_disabled(self):
|
||||
def test_local_live_task_never_runs_when_runtime_is_not_ready(self):
|
||||
task = purchase_task(execution_mode="live")
|
||||
self.repository.add_claimed_task(admin_task_to_new_claimed_task(task))
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "授权已关闭"):
|
||||
with self.assertRaisesRegex(RuntimeError, "设备或真实采购执行器未就绪"):
|
||||
self._dispatcher(
|
||||
purchase_ready=True,
|
||||
live_ready=True,
|
||||
|
||||
Reference in New Issue
Block a user