feat: 保存并登记当前 Client (#11)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"""Client 访问 Admin 的稳定边界和简单数据对象。
|
||||
|
||||
AdminGateway 只有领取任务、提交成功结果、提交失败结果三个业务方法。
|
||||
AdminGateway 只有登记、领取任务、提交成功结果、提交失败结果四个业务方法。
|
||||
业务层不应直接依赖 HTTP 请求或 Mock 的内部实现。
|
||||
"""
|
||||
|
||||
@@ -13,13 +13,16 @@ from .task_models import TaskType
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClientInfo:
|
||||
"""发起领取请求的 Client 身份,不保存访问令牌。"""
|
||||
"""Client 身份和可选名称,不保存访问令牌。"""
|
||||
|
||||
client_id: str
|
||||
name: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.client_id.strip():
|
||||
raise ValueError("client_id 不能为空")
|
||||
if len(self.name.strip()) > 50:
|
||||
raise ValueError("client.name 最多 50 个字")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -43,7 +46,7 @@ class AndroidDeviceInfo:
|
||||
class ClaimCapabilities:
|
||||
"""Client 领取任务时声明的设备与执行能力。"""
|
||||
|
||||
device: AndroidDeviceInfo
|
||||
device: Optional[AndroidDeviceInfo] = None
|
||||
supported_types: Tuple[TaskType, ...] = (
|
||||
TaskType.COLLECT,
|
||||
TaskType.PURCHASE,
|
||||
@@ -96,6 +99,15 @@ class SubmissionReceipt:
|
||||
accepted_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegistrationReceipt:
|
||||
"""Admin 已登记当前 Client 的确认。"""
|
||||
|
||||
registered: bool
|
||||
client_id: str
|
||||
registered_at: str
|
||||
|
||||
|
||||
class AdminGatewayError(RuntimeError):
|
||||
"""带稳定错误代码和可重试标志的 Admin 边界错误。"""
|
||||
|
||||
@@ -114,8 +126,18 @@ class AdminGatewayError(RuntimeError):
|
||||
self.details = dict(details or {})
|
||||
|
||||
|
||||
class AdminGateway(ABC):
|
||||
"""Admin 边界;不得增加状态查询、心跳或租约方法。"""
|
||||
class ClientRegistrationGateway(ABC):
|
||||
"""设置页只依赖登记能力,不依赖任务领取和提交。"""
|
||||
|
||||
@abstractmethod
|
||||
def register_client(
|
||||
self, client: ClientInfo, capabilities: ClaimCapabilities
|
||||
) -> RegistrationReceipt:
|
||||
"""幂等登记或更新当前 Client。"""
|
||||
|
||||
|
||||
class AdminGateway(ClientRegistrationGateway):
|
||||
"""完整 Admin 边界;不得增加状态查询、心跳或租约方法。"""
|
||||
|
||||
@abstractmethod
|
||||
def claim_next(
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""当前 Windows Client 身份的本地保存与读取服务。"""
|
||||
|
||||
import hashlib
|
||||
import platform
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .settings_repository import SettingsRepository
|
||||
|
||||
|
||||
CLIENT_ID_KEY = "admin.client_id"
|
||||
CLIENT_NAME_KEY = "admin.client_name"
|
||||
|
||||
|
||||
def generate_client_device_id() -> str:
|
||||
"""根据本机特征生成稳定设备号,只保留不可逆哈希。"""
|
||||
|
||||
fingerprint = "|".join(
|
||||
(
|
||||
platform.system(),
|
||||
platform.node(),
|
||||
platform.machine(),
|
||||
f"{uuid.getnode():012x}",
|
||||
)
|
||||
)
|
||||
digest = hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()[:16]
|
||||
return f"CLIENT-{digest.upper()}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurrentClientSettings:
|
||||
"""本地已经确认保存的当前 Client 信息。"""
|
||||
|
||||
client_id: str
|
||||
client_name: str
|
||||
|
||||
|
||||
class CurrentClientService:
|
||||
"""保证设备号只生成一次,并与名称一起原子保存。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SettingsRepository,
|
||||
device_id_factory: Callable[[], str] = generate_client_device_id,
|
||||
):
|
||||
self._repository = repository
|
||||
self._device_id_factory = device_id_factory
|
||||
|
||||
def load(self) -> CurrentClientSettings:
|
||||
"""读取已保存的信息;缺少的字段返回空字符串。"""
|
||||
|
||||
client_id = self._clean_text(self._repository.get(CLIENT_ID_KEY, ""))
|
||||
client_name = self._clean_text(
|
||||
self._repository.get(CLIENT_NAME_KEY, "")
|
||||
)
|
||||
return CurrentClientSettings(client_id, client_name)
|
||||
|
||||
def save(self, client_name: str) -> CurrentClientSettings:
|
||||
"""保存名称;设备号不存在时生成,存在时保持不变。"""
|
||||
|
||||
normalized_name = self._clean_text(client_name)
|
||||
if len(normalized_name) > 50:
|
||||
raise ValueError("设备名最多 50 个字")
|
||||
|
||||
existing = self.load()
|
||||
client_id = existing.client_id
|
||||
if not client_id:
|
||||
client_id = self._clean_text(self._device_id_factory())
|
||||
if not client_id:
|
||||
raise ValueError("生成的设备号不能为空")
|
||||
|
||||
self._repository.set_many(
|
||||
{
|
||||
CLIENT_ID_KEY: client_id,
|
||||
CLIENT_NAME_KEY: normalized_name,
|
||||
}
|
||||
)
|
||||
return CurrentClientSettings(client_id, normalized_name)
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value: object) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
@@ -0,0 +1,190 @@
|
||||
"""使用 Python 标准库调用 Admin 登记接口。"""
|
||||
|
||||
import json
|
||||
import socket
|
||||
from http.client import RemoteDisconnected
|
||||
from typing import Callable, Optional
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import ProxyHandler, Request, build_opener
|
||||
from uuid import uuid4
|
||||
|
||||
from .admin_gateway import (
|
||||
AdminGatewayError,
|
||||
ClaimCapabilities,
|
||||
ClientInfo,
|
||||
ClientRegistrationGateway,
|
||||
RegistrationReceipt,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_ADMIN_BASE_URL = "http://127.0.0.1:8080"
|
||||
|
||||
|
||||
class HttpAdminGateway(ClientRegistrationGateway):
|
||||
"""通过 HTTP 登记 Client;访问令牌只保存在内存。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = DEFAULT_ADMIN_BASE_URL,
|
||||
token: str = "",
|
||||
timeout_seconds: float = 3.0,
|
||||
opener: Optional[Callable] = None,
|
||||
):
|
||||
normalized_url = base_url.strip().rstrip("/")
|
||||
parsed = urlparse(normalized_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError("Admin 服务地址必须是有效的 http 或 https 地址")
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("请求超时必须大于 0 秒")
|
||||
|
||||
self._base_url = normalized_url
|
||||
self._token = token.strip()
|
||||
self._timeout_seconds = timeout_seconds
|
||||
# Admin 通常运行在本机或局域网。明确禁用环境代理,避免
|
||||
# HTTP_PROXY 把 127.0.0.1 请求错误转发到代理服务器。
|
||||
self._opener = opener or build_opener(ProxyHandler({})).open
|
||||
|
||||
def register_client(
|
||||
self, client: ClientInfo, capabilities: ClaimCapabilities
|
||||
) -> RegistrationReceipt:
|
||||
"""调用独立登记接口,不领取或修改任务。"""
|
||||
|
||||
request_id = str(uuid4())
|
||||
payload = {
|
||||
"client": {"name": client.name.strip()},
|
||||
"supported_types": [
|
||||
task_type.value for task_type in capabilities.supported_types
|
||||
],
|
||||
"capabilities": {
|
||||
"purchase_mode": capabilities.purchase_mode,
|
||||
"schema_versions": list(capabilities.schema_versions),
|
||||
},
|
||||
}
|
||||
if capabilities.device is not None:
|
||||
payload["device"] = {
|
||||
"address": capabilities.device.address.strip(),
|
||||
"platform": capabilities.device.platform,
|
||||
"pdd_package": capabilities.device.pdd_package.strip(),
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Accept": "application/json",
|
||||
"X-Client-Id": client.client_id.strip(),
|
||||
"X-Request-Id": request_id,
|
||||
}
|
||||
if self._token:
|
||||
headers["Authorization"] = f"Bearer {self._token}"
|
||||
|
||||
request = Request(
|
||||
f"{self._base_url}/api/v1/client/registration",
|
||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="PUT",
|
||||
)
|
||||
|
||||
try:
|
||||
with self._opener(request, timeout=self._timeout_seconds) as response:
|
||||
status = getattr(response, "status", None)
|
||||
if status is None:
|
||||
status = response.getcode()
|
||||
body = response.read()
|
||||
except HTTPError as exc:
|
||||
self._raise_http_error(exc, request_id)
|
||||
except (
|
||||
URLError,
|
||||
RemoteDisconnected,
|
||||
ConnectionError,
|
||||
socket.timeout,
|
||||
TimeoutError,
|
||||
) as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
if isinstance(reason, (socket.timeout, TimeoutError)):
|
||||
raise AdminGatewayError(
|
||||
"ADMIN_TIMEOUT", "Admin 请求超时,请稍后重试", True, request_id
|
||||
) from exc
|
||||
raise AdminGatewayError(
|
||||
"ADMIN_UNAVAILABLE", "无法连接 Admin,请检查服务地址", True, request_id
|
||||
) from exc
|
||||
|
||||
if status != 200:
|
||||
raise AdminGatewayError(
|
||||
"ADMIN_UNEXPECTED_RESPONSE",
|
||||
f"Admin 返回了未预期的状态码 {status}",
|
||||
status >= 500,
|
||||
request_id,
|
||||
)
|
||||
|
||||
data = self._decode_json(body, request_id)
|
||||
if (
|
||||
data.get("registered") is not True
|
||||
or data.get("client_id") != client.client_id.strip()
|
||||
or not isinstance(data.get("registered_at"), str)
|
||||
or not data["registered_at"].strip()
|
||||
):
|
||||
raise AdminGatewayError(
|
||||
"ADMIN_INVALID_RESPONSE",
|
||||
"Admin 登记响应缺少必要字段",
|
||||
False,
|
||||
request_id,
|
||||
)
|
||||
return RegistrationReceipt(
|
||||
registered=True,
|
||||
client_id=data["client_id"],
|
||||
registered_at=data["registered_at"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _decode_json(body: bytes, request_id: str) -> dict:
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise AdminGatewayError(
|
||||
"ADMIN_INVALID_RESPONSE",
|
||||
"Admin 返回的不是有效 JSON",
|
||||
False,
|
||||
request_id,
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise AdminGatewayError(
|
||||
"ADMIN_INVALID_RESPONSE",
|
||||
"Admin 返回的 JSON 不是对象",
|
||||
False,
|
||||
request_id,
|
||||
)
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def _raise_http_error(cls, error: HTTPError, request_id: str) -> None:
|
||||
try:
|
||||
body = error.read()
|
||||
except OSError:
|
||||
body = b""
|
||||
|
||||
try:
|
||||
data = cls._decode_json(body, request_id)
|
||||
details = data.get("error", {})
|
||||
if not isinstance(details, dict):
|
||||
raise ValueError
|
||||
code = str(details.get("code") or "ADMIN_HTTP_ERROR")
|
||||
message = str(details.get("message") or "Admin 拒绝了登记请求")
|
||||
retryable = bool(details.get("retryable", error.code >= 500))
|
||||
response_request_id = str(details.get("request_id") or request_id)
|
||||
extra = details.get("details")
|
||||
if not isinstance(extra, dict):
|
||||
extra = {}
|
||||
except (AdminGatewayError, ValueError):
|
||||
code = "ADMIN_HTTP_ERROR"
|
||||
message = f"Admin 登记失败,状态码 {error.code}"
|
||||
retryable = error.code >= 500
|
||||
response_request_id = request_id
|
||||
extra = {}
|
||||
|
||||
raise AdminGatewayError(
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
response_request_id,
|
||||
extra,
|
||||
) from error
|
||||
@@ -15,6 +15,7 @@ from .admin_gateway import (
|
||||
AdminTask,
|
||||
ClaimCapabilities,
|
||||
ClientInfo,
|
||||
RegistrationReceipt,
|
||||
SubmissionReceipt,
|
||||
)
|
||||
|
||||
@@ -49,8 +50,41 @@ class MockAdminGateway(AdminGateway):
|
||||
] = {}
|
||||
self._next_error: Optional[AdminGatewayError] = None
|
||||
self._reject_next_submission = False
|
||||
self._registrations: Dict[str, Tuple[ClientInfo, ClaimCapabilities]] = {}
|
||||
self._lock = Lock()
|
||||
|
||||
def register_client(
|
||||
self, client: ClientInfo, capabilities: ClaimCapabilities
|
||||
) -> RegistrationReceipt:
|
||||
"""幂等登记 Client,并保留最后一次上报内容供测试检查。"""
|
||||
|
||||
with self._lock:
|
||||
self._raise_forced_error()
|
||||
normalized = ClientInfo(client.client_id.strip(), client.name.strip())
|
||||
self._registrations[normalized.client_id] = (
|
||||
normalized,
|
||||
deepcopy(capabilities),
|
||||
)
|
||||
return RegistrationReceipt(
|
||||
registered=True,
|
||||
client_id=normalized.client_id,
|
||||
registered_at=utc_now_iso(),
|
||||
)
|
||||
|
||||
@property
|
||||
def registration_count(self) -> int:
|
||||
"""返回不同 Client ID 的登记数量。"""
|
||||
|
||||
return len(self._registrations)
|
||||
|
||||
def registered_client(
|
||||
self, client_id: str
|
||||
) -> Optional[Tuple[ClientInfo, ClaimCapabilities]]:
|
||||
"""测试辅助:返回某个 Client 最后一次登记的资料。"""
|
||||
|
||||
value = self._registrations.get(client_id)
|
||||
return deepcopy(value) if value is not None else None
|
||||
|
||||
def enqueue_task(self, task: AdminTask, assigned_client_id: str) -> None:
|
||||
"""测试辅助:加入一条分配给指定 Client 的任务。"""
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
from typing import Any, Mapping, Optional, Union
|
||||
|
||||
from .db import initialize_database, open_database
|
||||
from .task_models import AppSettingRecord
|
||||
@@ -74,6 +74,35 @@ class SettingsRepository:
|
||||
connection.close()
|
||||
return AppSettingRecord(key, value, now)
|
||||
|
||||
def set_many(
|
||||
self, values: Mapping[str, Any], updated_at: Optional[str] = None
|
||||
) -> list[AppSettingRecord]:
|
||||
"""在同一事务中保存多个非敏感设置。"""
|
||||
|
||||
if not values:
|
||||
return []
|
||||
|
||||
normalized = [
|
||||
(self._validate_key(key), value) for key, value in values.items()
|
||||
]
|
||||
now = updated_at or _utc_now_iso()
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
with connection:
|
||||
for key, value in normalized:
|
||||
connection.execute(
|
||||
"INSERT INTO app_settings"
|
||||
" (setting_key, value_json, updated_at)"
|
||||
" VALUES (?, ?, ?)"
|
||||
" ON CONFLICT(setting_key) DO UPDATE SET"
|
||||
" value_json = excluded.value_json,"
|
||||
" updated_at = excluded.updated_at",
|
||||
(key, json.dumps(value, ensure_ascii=False), now),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
return [AppSettingRecord(key, value, now) for key, value in normalized]
|
||||
|
||||
def delete(self, setting_key: str) -> bool:
|
||||
"""删除设置;确实删除了一条记录时返回 True。"""
|
||||
|
||||
|
||||
@@ -201,7 +201,12 @@ class AndroidDeviceTableModel(QAbstractTableModel):
|
||||
class SettingsPage(QWidget):
|
||||
"""设备管理与后续应用设置的统一页面。"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
settings_repository=None,
|
||||
admin_gateway=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("settingsPage")
|
||||
self._build_ui()
|
||||
@@ -209,7 +214,11 @@ class SettingsPage(QWidget):
|
||||
# 延迟到控件创建完成后绑定,避免事件层在构造过程中访问半成品页面。
|
||||
from .settings_ui_event import SettingsPageEventBinder
|
||||
|
||||
self.eventBinder = SettingsPageEventBinder(self)
|
||||
self.eventBinder = SettingsPageEventBinder(
|
||||
self,
|
||||
settings_repository=settings_repository,
|
||||
admin_gateway=admin_gateway,
|
||||
)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.deviceIdInput = LineEdit(self)
|
||||
@@ -225,6 +234,10 @@ class SettingsPage(QWidget):
|
||||
|
||||
self.currentDeviceSaveButton = PushButton(FIF.SAVE, "保存", self)
|
||||
self.currentDeviceSaveButton.setAccessibleName("保存当前设备信息")
|
||||
self.currentDeviceStatusLabel = CaptionLabel(
|
||||
"设备信息尚未保存", self
|
||||
)
|
||||
self.currentDeviceStatusLabel.setAccessibleName("当前设备保存状态")
|
||||
|
||||
self.currentDeviceCard = self._build_current_device_card()
|
||||
|
||||
@@ -322,6 +335,7 @@ class SettingsPage(QWidget):
|
||||
commandLayout.addStretch(1)
|
||||
commandLayout.addWidget(self.currentDeviceSaveButton)
|
||||
layout.addLayout(commandLayout)
|
||||
layout.addWidget(self.currentDeviceStatusLabel)
|
||||
return card
|
||||
|
||||
def _build_android_device_card(self) -> CardWidget:
|
||||
@@ -357,6 +371,11 @@ class SettingsPage(QWidget):
|
||||
self.deviceIdInput.setText(device_id or "待生成")
|
||||
self.deviceNameInput.setText(device_name)
|
||||
|
||||
def set_current_device_status(self, message: str) -> None:
|
||||
"""显示本地保存和 Admin 登记两个阶段的结果。"""
|
||||
|
||||
self.currentDeviceStatusLabel.setText(message)
|
||||
|
||||
def set_android_devices(self, devices: Iterable[AndroidDeviceRow]) -> None:
|
||||
"""显示后续 ADB 服务返回的设备列表。"""
|
||||
|
||||
|
||||
+243
-26
@@ -1,32 +1,92 @@
|
||||
"""设置页的事件绑定和当前客户端设备号生成。
|
||||
"""设置页事件、当前 Client 本地保存和后台登记。
|
||||
|
||||
本文件把界面操作转换为轻量信号,不直接执行 ADB 或 SQLite。
|
||||
后续应用服务应在后台线程处理阻塞操作,再通过页面更新方法返回结果。
|
||||
本文件不直接写 SQL。SQLite 保存和 Admin HTTP 请求由 Worker 在线程中执行,
|
||||
后台结果只通过信号返回主线程更新页面。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import platform
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtCore import QCoreApplication, QObject, QThread, pyqtSignal, pyqtSlot
|
||||
|
||||
from .admin_gateway import (
|
||||
AdminGatewayError,
|
||||
AndroidDeviceInfo,
|
||||
ClaimCapabilities,
|
||||
ClientInfo,
|
||||
ClientRegistrationGateway,
|
||||
)
|
||||
from .current_client_service import (
|
||||
CurrentClientService,
|
||||
generate_client_device_id,
|
||||
)
|
||||
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
|
||||
from .settings_repository import SettingsRepository
|
||||
|
||||
DEVICE_ID_PLACEHOLDER = "待生成"
|
||||
|
||||
|
||||
def generate_client_device_id() -> str:
|
||||
"""根据本机特征生成设备号,不暴露原始系统和硬件信息。"""
|
||||
class CurrentClientSaveWorker(QObject):
|
||||
"""在线程中先保存本地身份,再登记到 Admin。"""
|
||||
|
||||
fingerprint = "|".join(
|
||||
(
|
||||
platform.system(),
|
||||
platform.node(),
|
||||
platform.machine(),
|
||||
f"{uuid.getnode():012x}",
|
||||
)
|
||||
)
|
||||
digest = hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()[:16].upper()
|
||||
return f"CLIENT-{digest}"
|
||||
localSaved = pyqtSignal(str, str)
|
||||
localSaveFailed = pyqtSignal(str)
|
||||
registrationSucceeded = pyqtSignal(str)
|
||||
registrationFailed = pyqtSignal(str)
|
||||
completed = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: CurrentClientService,
|
||||
gateway: Optional[ClientRegistrationGateway],
|
||||
client_name: str,
|
||||
capabilities: ClaimCapabilities,
|
||||
gateway_error: str = "",
|
||||
):
|
||||
super().__init__()
|
||||
self._service = service
|
||||
self._gateway = gateway
|
||||
self._client_name = client_name
|
||||
self._capabilities = capabilities
|
||||
self._gateway_error = gateway_error
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""阻止尚未开始的远端登记;已经发出的 HTTP 等超时返回。"""
|
||||
|
||||
self._cancelled = True
|
||||
|
||||
@pyqtSlot()
|
||||
def run(self) -> None:
|
||||
try:
|
||||
try:
|
||||
saved = self._service.save(self._client_name)
|
||||
except Exception as exc:
|
||||
self.localSaveFailed.emit(str(exc) or "无法写入本地数据库")
|
||||
return
|
||||
|
||||
self.localSaved.emit(saved.client_id, saved.client_name)
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
if self._gateway is None:
|
||||
self.registrationFailed.emit(
|
||||
self._gateway_error or "Admin Gateway 尚未配置"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
receipt = self._gateway.register_client(
|
||||
ClientInfo(saved.client_id, saved.client_name),
|
||||
self._capabilities,
|
||||
)
|
||||
except AdminGatewayError as exc:
|
||||
self.registrationFailed.emit(str(exc))
|
||||
except Exception as exc:
|
||||
self.registrationFailed.emit(str(exc) or "Admin 登记失败")
|
||||
else:
|
||||
self.registrationSucceeded.emit(receipt.registered_at)
|
||||
finally:
|
||||
self.completed.emit()
|
||||
|
||||
|
||||
class SettingsPageEventBinder(QObject):
|
||||
@@ -38,36 +98,108 @@ class SettingsPageEventBinder(QObject):
|
||||
saveRequested = pyqtSignal(str, str)
|
||||
deleteRequested = pyqtSignal(str)
|
||||
|
||||
def __init__(self, page):
|
||||
def __init__(
|
||||
self,
|
||||
page,
|
||||
settings_repository: Optional[SettingsRepository] = None,
|
||||
admin_gateway: Optional[ClientRegistrationGateway] = None,
|
||||
):
|
||||
super().__init__(page)
|
||||
self._page = page
|
||||
self._busy = False
|
||||
self._current_device_busy = False
|
||||
self._closing = False
|
||||
self._thread: Optional[QThread] = None
|
||||
self._worker: Optional[CurrentClientSaveWorker] = None
|
||||
|
||||
repository = settings_repository or SettingsRepository()
|
||||
self._client_service = CurrentClientService(repository)
|
||||
self._admin_gateway = admin_gateway
|
||||
self._gateway_error = ""
|
||||
if self._admin_gateway is None:
|
||||
base_url = repository.get("admin.base_url", DEFAULT_ADMIN_BASE_URL)
|
||||
timeout_value = repository.get("admin.request_timeout_seconds", 3.0)
|
||||
try:
|
||||
timeout_seconds = float(timeout_value)
|
||||
self._admin_gateway = HttpAdminGateway(
|
||||
base_url if isinstance(base_url, str) else "",
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
self._gateway_error = str(exc)
|
||||
|
||||
page.currentDeviceSaveButton.clicked.connect(
|
||||
self._request_save_current_device
|
||||
)
|
||||
self.currentDeviceSaveRequested.connect(
|
||||
self._start_save_current_device
|
||||
)
|
||||
page.searchButton.clicked.connect(self._request_search)
|
||||
page.connectButton.clicked.connect(self._request_connect)
|
||||
page.saveButton.clicked.connect(self._request_save)
|
||||
page.deleteButton.clicked.connect(self._request_delete)
|
||||
page.addressInput.textChanged.connect(self._sync_button_state)
|
||||
page.deviceTableModel.checkedDeviceChanged.connect(self._sync_button_state)
|
||||
page.destroyed.connect(self.shutdown)
|
||||
application = QCoreApplication.instance()
|
||||
if application is not None:
|
||||
application.aboutToQuit.connect(self.shutdown)
|
||||
|
||||
self._load_current_client()
|
||||
self._sync_button_state()
|
||||
|
||||
@pyqtSlot()
|
||||
def _request_save_current_device(self) -> None:
|
||||
if self._busy:
|
||||
if self._busy or self._current_device_busy:
|
||||
return
|
||||
|
||||
device_id = self._page.deviceIdInput.text().strip()
|
||||
if not device_id or device_id == DEVICE_ID_PLACEHOLDER:
|
||||
device_id = generate_client_device_id()
|
||||
self._page.deviceIdInput.setText(device_id)
|
||||
|
||||
device_name = self._page.deviceNameInput.text().strip()
|
||||
self._page.deviceNameInput.setText(device_name)
|
||||
self.currentDeviceSaveRequested.emit(device_id, device_name)
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def _start_save_current_device(
|
||||
self, _displayed_device_id: str, device_name: str
|
||||
) -> None:
|
||||
if self._closing or self._current_device_busy:
|
||||
return
|
||||
|
||||
try:
|
||||
device = self._selected_android_device()
|
||||
capabilities = ClaimCapabilities(device=device)
|
||||
except ValueError as exc:
|
||||
self._page.set_current_device_status(f"保存失败:{exc}")
|
||||
return
|
||||
|
||||
self._current_device_busy = True
|
||||
self._sync_button_state()
|
||||
self._page.set_current_device_status("正在保存本地设备信息…")
|
||||
|
||||
thread = QThread(self)
|
||||
worker = CurrentClientSaveWorker(
|
||||
self._client_service,
|
||||
self._admin_gateway,
|
||||
device_name,
|
||||
capabilities,
|
||||
self._gateway_error,
|
||||
)
|
||||
worker.moveToThread(thread)
|
||||
|
||||
thread.started.connect(worker.run)
|
||||
worker.localSaved.connect(self._on_local_saved)
|
||||
worker.localSaveFailed.connect(self._on_local_save_failed)
|
||||
worker.registrationSucceeded.connect(self._on_registration_succeeded)
|
||||
worker.registrationFailed.connect(self._on_registration_failed)
|
||||
worker.completed.connect(thread.quit)
|
||||
worker.completed.connect(worker.deleteLater)
|
||||
thread.finished.connect(self._on_save_thread_finished)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
self._thread = thread
|
||||
self._worker = worker
|
||||
thread.start()
|
||||
|
||||
@pyqtSlot()
|
||||
def _request_search(self) -> None:
|
||||
if self._busy:
|
||||
@@ -118,7 +250,9 @@ class SettingsPageEventBinder(QObject):
|
||||
def _sync_button_state(self, *_args) -> None:
|
||||
serial = self._page.deviceTableModel.checked_serial
|
||||
has_address = bool(self._page.addressInput.text().strip())
|
||||
self._page.currentDeviceSaveButton.setEnabled(not self._busy)
|
||||
self._page.currentDeviceSaveButton.setEnabled(
|
||||
not self._busy and not self._current_device_busy
|
||||
)
|
||||
self._page.searchButton.setEnabled(not self._busy)
|
||||
self._page.connectButton.setEnabled(not self._busy and has_address)
|
||||
self._page.saveButton.setEnabled(not self._busy and bool(serial))
|
||||
@@ -131,3 +265,86 @@ class SettingsPageEventBinder(QObject):
|
||||
self._sync_button_state()
|
||||
if message:
|
||||
self._page.deviceStatusLabel.setText(message)
|
||||
|
||||
def _load_current_client(self) -> None:
|
||||
"""页面创建时恢复本地 Client 信息。"""
|
||||
|
||||
try:
|
||||
saved = self._client_service.load()
|
||||
except Exception as exc:
|
||||
self._page.set_current_device_status(
|
||||
f"读取本地设备信息失败:{str(exc) or '数据库不可用'}"
|
||||
)
|
||||
return
|
||||
|
||||
self._page.set_client_info(saved.client_id, saved.client_name)
|
||||
if saved.client_id:
|
||||
self._page.set_current_device_status("本地设备信息已加载")
|
||||
|
||||
def _selected_android_device(self) -> Optional[AndroidDeviceInfo]:
|
||||
serial = self._page.deviceTableModel.checked_serial.strip()
|
||||
return AndroidDeviceInfo(serial) if serial else None
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def _on_local_saved(self, client_id: str, client_name: str) -> None:
|
||||
if self._closing:
|
||||
return
|
||||
self._page.set_client_info(client_id, client_name)
|
||||
self._page.set_current_device_status(
|
||||
"本地已保存,正在登记到 Admin…"
|
||||
)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def _on_local_save_failed(self, message: str) -> None:
|
||||
if self._closing:
|
||||
return
|
||||
self._page.set_current_device_status(f"本地保存失败:{message}")
|
||||
|
||||
@pyqtSlot(str)
|
||||
def _on_registration_succeeded(self, _registered_at: str) -> None:
|
||||
if self._closing:
|
||||
return
|
||||
self._page.set_current_device_status("本地已保存,已登记到 Admin")
|
||||
|
||||
@pyqtSlot(str)
|
||||
def _on_registration_failed(self, message: str) -> None:
|
||||
if self._closing:
|
||||
return
|
||||
self._page.set_current_device_status(
|
||||
f"本地已保存,Admin 登记失败:{message};可再次点击保存重试"
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def _on_save_thread_finished(self) -> None:
|
||||
self._worker = None
|
||||
self._thread = None
|
||||
self._current_device_busy = False
|
||||
if not self._closing:
|
||||
self._sync_button_state()
|
||||
|
||||
@pyqtSlot()
|
||||
def shutdown(self) -> None:
|
||||
"""窗口关闭时忽略迟到结果,并等待短请求安全结束。"""
|
||||
|
||||
if self._closing:
|
||||
return
|
||||
self._closing = True
|
||||
|
||||
worker = self._worker
|
||||
thread = self._thread
|
||||
if worker is not None:
|
||||
worker.cancel()
|
||||
for signal, slot in (
|
||||
(worker.localSaved, self._on_local_saved),
|
||||
(worker.localSaveFailed, self._on_local_save_failed),
|
||||
(worker.registrationSucceeded, self._on_registration_succeeded),
|
||||
(worker.registrationFailed, self._on_registration_failed),
|
||||
):
|
||||
try:
|
||||
signal.disconnect(slot)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
if thread is not None and thread.isRunning():
|
||||
thread.quit()
|
||||
thread.wait(4000)
|
||||
|
||||
+11
-2
@@ -36,11 +36,20 @@ from .task_repository import TaskRepository
|
||||
class MainWindow(FluentWindow):
|
||||
"""应用主窗口。"""
|
||||
|
||||
def __init__(self, task_repository=None):
|
||||
def __init__(
|
||||
self,
|
||||
task_repository=None,
|
||||
settings_repository=None,
|
||||
admin_gateway=None,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.pddTaskPage = PDDTaskPage(self)
|
||||
self.settingsPage = SettingsPage(self)
|
||||
self.settingsPage = SettingsPage(
|
||||
self,
|
||||
settings_repository=settings_repository,
|
||||
admin_gateway=admin_gateway,
|
||||
)
|
||||
self.pddTaskPageEvent = PDDTaskPageEvent(
|
||||
self.pddTaskPage,
|
||||
task_repository or TaskRepository(),
|
||||
|
||||
Reference in New Issue
Block a user