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

This commit is contained in:
chengma
2026-08-10 16:35:38 +08:00
parent 7106f6b024
commit e960cab1fb
30 changed files with 1562 additions and 66 deletions
+274 -1
View File
@@ -35,8 +35,14 @@ 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
from .update_ui_event import UpdateUiEventBinder
DEVICE_ID_PLACEHOLDER = "待生成"
@@ -294,6 +300,85 @@ 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):
"""绑定设备管理控件,并向应用层发出稳定事件。"""
@@ -302,6 +387,7 @@ class SettingsPageEventBinder(QObject):
currentDeviceSaveRequested = pyqtSignal(str, str)
saveRequested = pyqtSignal(str, str)
deleteRequested = pyqtSignal(str)
liveAuthorizationRequested = pyqtSignal(str, str)
def __init__(
self,
@@ -311,6 +397,7 @@ class SettingsPageEventBinder(QObject):
android_device_service: Optional[AndroidDeviceService] = None,
update_service=None,
update_credential_store=None,
live_purchase_adapter_ready: bool = False,
):
super().__init__(page)
self._page = page
@@ -341,6 +428,14 @@ 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._live_purchase_adapter_ready = bool(
live_purchase_adapter_ready
)
self._android_device_service = (
android_device_service or AndroidDeviceService()
)
@@ -354,6 +449,10 @@ 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
)
@@ -384,6 +483,18 @@ 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
@@ -395,6 +506,7 @@ 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)
@@ -430,7 +542,22 @@ class SettingsPageEventBinder(QObject):
try:
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:
self._page.set_current_device_status(f"保存失败:{exc}")
return
@@ -463,6 +590,128 @@ 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 (
@@ -871,6 +1120,25 @@ 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:
"""切换界面忙碌状态,防止用户重复提交设备命令。"""
@@ -1178,6 +1446,11 @@ 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: