feat: 保存并登记当前 Client (#11)

This commit is contained in:
chengma
2026-08-06 17:57:49 +08:00
parent 262f48c778
commit fab5778049
17 changed files with 1078 additions and 43 deletions
+1 -1
View File
@@ -255,7 +255,7 @@ Gitea 使用约定(**首次使用前需由项目负责人补全**):
### 两者的接口边界
Admin 和 Client 通过三个 HTTP 接口交互,**契约以 Client 侧文档为准**:
Admin 和 Client 通过四个 HTTP 接口交互,**契约以 Client 侧文档为准**:
[docs/client/04-admin-api-contract.md](docs/client/04-admin-api-contract.md)。
改动接口时,两边的文档必须在同一个工单里同步更新,不允许只改一边。
+1 -1
View File
@@ -42,7 +42,7 @@
- 数据库字段、任务状态和 `pdd_data` 结构以 `docs/client/03-data-model.md` 为准。
- Admin 路径、字段和幂等语义以 `docs/client/04-admin-api-contract.md` 为准;Admin 未完成时使用 Mock,不臆造正式响应。
- Client 与 Admin 只有三个调用:领取、提交结果、提交失败。**不得新增"向 Admin 查询状态"类接口。**
- Client 与 Admin 只有四个调用:登记、领取、提交结果、提交失败。**不得新增"向 Admin 查询状态"类接口。**
- 金额使用人民币分整数,时间使用带时区 ISO 8601,稳定任务编号不得使用表格行号代替。
- 文件路径一律通过 `data_dir()`(可写数据)和 `app_dir()`(只读资源)获取,见 [03 数据模型 §2.2](../docs/client/03-data-model.md)。**不许硬编码路径,不许用 `os.getcwd()` 或 `__file__` 直接拼**,打包成 exe 后会失效。
- PDD 结果必须先写入 SQLite,再通过 Outbox 提交 Admin。
+1 -2
View File
@@ -15,5 +15,4 @@ PyQt-Fluent-Widgets==1.11.3
# 安卓自动化
uiautomator2==3.2.5
# 后续接入 Admin HTTP 接口时再放开(当前只用 Mock,不需要装):
# requests==2.32.3
# Admin HTTP 当前使用 Python 标准库,不需要额外网络依赖。
+27 -5
View File
@@ -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(
+83
View File
@@ -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 ""
+190
View File
@@ -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
+34
View File
@@ -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 的任务。"""
+30 -1
View File
@@ -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。"""
+21 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
+34 -2
View File
@@ -60,14 +60,46 @@ class MockAdminGatewayContractTest(unittest.TestCase):
"reported_at": "2026-08-06T08:03:00Z",
}
def test_gateway_has_only_three_business_methods(self):
def test_gateway_has_only_four_business_methods(self):
self.assertEqual(
AdminGateway.__abstractmethods__,
{"claim_next", "submit_result", "submit_failure"},
{
"register_client",
"claim_next",
"submit_result",
"submit_failure",
},
)
for forbidden in ("get_status", "heartbeat", "renew_lease"):
self.assertFalse(hasattr(AdminGateway, forbidden))
def test_registration_is_idempotent_and_keeps_latest_profile(self):
first = self.gateway.register_client(
ClientInfo("client-001", "办公室电脑"), self.all_capabilities
)
second = self.gateway.register_client(
ClientInfo("client-001", "仓库电脑"), self.all_capabilities
)
self.assertTrue(first.registered)
self.assertEqual(second.client_id, "client-001")
self.assertEqual(self.gateway.registration_count, 1)
saved_client, saved_capabilities = self.gateway.registered_client(
"client-001"
)
self.assertEqual(saved_client.name, "仓库电脑")
self.assertEqual(saved_capabilities, self.all_capabilities)
def test_registration_can_simulate_admin_failure(self):
self.gateway.fail_next_call_temporarily()
with self.assertRaises(AdminGatewayError) as context:
self.gateway.register_client(self.client, self.all_capabilities)
self.assertEqual(context.exception.code, "ADMIN_UNAVAILABLE")
self.assertTrue(context.exception.retryable)
self.assertEqual(self.gateway.registration_count, 0)
def test_claim_returns_none_when_no_task_exists(self):
self.assertIsNone(
self.gateway.claim_next(self.client, self.all_capabilities)
@@ -0,0 +1,57 @@
"""当前 Client 本地身份服务测试。"""
import tempfile
import unittest
from pathlib import Path
from src.current_client_service import CurrentClientService
from src.settings_repository import SettingsRepository
class CurrentClientServiceTest(unittest.TestCase):
def setUp(self):
self.temp_directory = tempfile.TemporaryDirectory()
path = Path(self.temp_directory.name) / "client.db"
self.repository = SettingsRepository(path)
self.generated_count = 0
def generate_id():
self.generated_count += 1
return f"CLIENT-GENERATED-{self.generated_count}"
self.service = CurrentClientService(self.repository, generate_id)
def tearDown(self):
self.temp_directory.cleanup()
def test_first_save_generates_id_and_later_save_reuses_it(self):
first = self.service.save(" 办公室电脑 ")
second = self.service.save("仓库电脑")
self.assertEqual(first.client_id, "CLIENT-GENERATED-1")
self.assertEqual(second.client_id, first.client_id)
self.assertEqual(second.client_name, "仓库电脑")
self.assertEqual(self.generated_count, 1)
self.assertEqual(self.service.load(), second)
def test_empty_name_is_allowed_but_more_than_50_chars_is_rejected(self):
saved = self.service.save(" ")
self.assertEqual(saved.client_name, "")
with self.assertRaisesRegex(ValueError, "最多 50"):
self.service.save("测" * 51)
self.assertEqual(self.service.load(), saved)
def test_non_string_corrupted_values_are_treated_as_missing(self):
self.repository.set("admin.client_id", {"bad": "value"})
self.repository.set("admin.client_name", 123)
saved = self.service.save("新设备")
self.assertEqual(saved.client_id, "CLIENT-GENERATED-1")
self.assertEqual(saved.client_name, "新设备")
if __name__ == "__main__":
unittest.main()
+168
View File
@@ -0,0 +1,168 @@
"""Admin 登记 HTTP 契约测试,不访问真实网络。"""
import io
import json
import socket
import unittest
from http.client import RemoteDisconnected
from urllib.error import HTTPError, URLError
from src.admin_gateway import (
AdminGatewayError,
AndroidDeviceInfo,
ClaimCapabilities,
ClientInfo,
)
from src.http_admin_gateway import HttpAdminGateway
class FakeResponse:
def __init__(self, status: int, payload):
self.status = status
self._body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
def getcode(self):
return self.status
def read(self):
return self._body
def __enter__(self):
return self
def __exit__(self, *_args):
return False
class RecordingOpener:
def __init__(self, response):
self.response = response
self.request = None
self.timeout = None
def __call__(self, request, timeout):
self.request = request
self.timeout = timeout
if isinstance(self.response, Exception):
raise self.response
return self.response
class HttpAdminGatewayTest(unittest.TestCase):
def _capabilities(self, with_device=True):
device = (
AndroidDeviceInfo("192.168.0.173:5555") if with_device else None
)
return ClaimCapabilities(device=device)
def test_register_sends_confirmed_contract_and_parses_response(self):
opener = RecordingOpener(
FakeResponse(
200,
{
"registered": True,
"client_id": "CLIENT-001",
"registered_at": "2026-08-06T10:00:00Z",
},
)
)
gateway = HttpAdminGateway(
"http://127.0.0.1:8080/", "secret-token", 2.5, opener
)
receipt = gateway.register_client(
ClientInfo("CLIENT-001", "办公室电脑"), self._capabilities()
)
self.assertTrue(receipt.registered)
self.assertEqual(opener.request.get_method(), "PUT")
self.assertEqual(
opener.request.full_url,
"http://127.0.0.1:8080/api/v1/client/registration",
)
headers = {
key.lower(): value for key, value in opener.request.header_items()
}
self.assertEqual(headers["x-client-id"], "CLIENT-001")
self.assertTrue(headers["x-request-id"])
self.assertEqual(headers["authorization"], "Bearer secret-token")
body = json.loads(opener.request.data.decode("utf-8"))
self.assertEqual(body["client"]["name"], "办公室电脑")
self.assertEqual(body["supported_types"], ["collect", "purchase"])
self.assertEqual(body["device"]["platform"], "android")
self.assertEqual(body["capabilities"]["purchase_mode"], "dry_run")
self.assertEqual(opener.timeout, 2.5)
def test_optional_device_is_omitted(self):
opener = RecordingOpener(
FakeResponse(
200,
{
"registered": True,
"client_id": "CLIENT-001",
"registered_at": "2026-08-06T10:00:00Z",
},
)
)
gateway = HttpAdminGateway(opener=opener)
gateway.register_client(
ClientInfo("CLIENT-001"), self._capabilities(False)
)
body = json.loads(opener.request.data.decode("utf-8"))
self.assertNotIn("device", body)
self.assertNotIn("authorization", {
key.lower(): value for key, value in opener.request.header_items()
})
def test_admin_error_preserves_code_retry_and_request_id(self):
error_body = json.dumps(
{
"error": {
"code": "INVALID_CLIENT_PROFILE",
"message": "资料无效",
"retryable": False,
"request_id": "server-request-id",
"details": {"field": "supported_types"},
}
}
).encode("utf-8")
error = HTTPError(
"http://admin/api/v1/client/registration",
422,
"Unprocessable Entity",
{},
io.BytesIO(error_body),
)
gateway = HttpAdminGateway(opener=RecordingOpener(error))
with self.assertRaises(AdminGatewayError) as context:
gateway.register_client(
ClientInfo("CLIENT-001"), self._capabilities(False)
)
self.assertEqual(context.exception.code, "INVALID_CLIENT_PROFILE")
self.assertFalse(context.exception.retryable)
self.assertEqual(context.exception.request_id, "server-request-id")
def test_timeout_and_connection_failure_are_retryable(self):
for exception, code in (
(URLError(socket.timeout()), "ADMIN_TIMEOUT"),
(URLError("connection refused"), "ADMIN_UNAVAILABLE"),
(RemoteDisconnected("closed"), "ADMIN_UNAVAILABLE"),
):
with self.subTest(code=code):
gateway = HttpAdminGateway(
opener=RecordingOpener(exception)
)
with self.assertRaises(AdminGatewayError) as context:
gateway.register_client(
ClientInfo("CLIENT-001"), self._capabilities(False)
)
self.assertEqual(context.exception.code, code)
self.assertTrue(context.exception.retryable)
if __name__ == "__main__":
unittest.main()
+7 -1
View File
@@ -11,6 +11,8 @@ from PyQt5.QtWidgets import QApplication
from src.pdd_ui import PDDTaskPage
from src.pdd_ui_event import PDDTaskPageEvent, summary_to_row
from src.mock_admin_gateway import MockAdminGateway
from src.settings_repository import SettingsRepository
from src.task_models import NewClaimedTask, TaskStatus, TaskSummary, TaskType
from src.task_repository import TaskRepository
from src.ui_main import MainWindow
@@ -130,7 +132,11 @@ class PDDTaskPageEventTest(unittest.TestCase):
self.assertFalse(hasattr(row, "pdd_data"))
def test_main_window_keeps_event_object_alive(self):
window = MainWindow(task_repository=self.repository)
window = MainWindow(
task_repository=self.repository,
settings_repository=SettingsRepository(self.db_path),
admin_gateway=MockAdminGateway(),
)
self.assertIsInstance(window.pddTaskPageEvent, PDDTaskPageEvent)
self.assertEqual(window.pddTaskPage.taskModel.data_row_count(), 0)
+21
View File
@@ -48,6 +48,27 @@ class SettingsRepositoryTests(unittest.TestCase):
self.assertFalse(self.repository.delete("automation.dry_run"))
self.assertIsNone(self.repository.get("automation.dry_run"))
def test_set_many_saves_values_with_one_timestamp(self) -> None:
records = self.repository.set_many(
{
"admin.client_id": "CLIENT-001",
"admin.client_name": "办公室电脑",
},
"2026-08-06T10:00:00Z",
)
self.assertEqual(len(records), 2)
self.assertEqual(
self.repository.get("admin.client_id"), "CLIENT-001"
)
self.assertEqual(
self.repository.get("admin.client_name"), "办公室电脑"
)
self.assertEqual(
{record.updated_at for record in records},
{"2026-08-06T10:00:00Z"},
)
def test_empty_setting_key_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "setting_key"):
self.repository.set(" ", True)
+149
View File
@@ -0,0 +1,149 @@
"""设置页保存当前 Client 并后台登记的离屏测试。"""
import os
import tempfile
import time
import unittest
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt5.QtCore import QTimer
from PyQt5.QtTest import QTest
from PyQt5.QtWidgets import QApplication
from src.mock_admin_gateway import MockAdminGateway
from src.settings_repository import SettingsRepository
from src.settings_ui import SettingsPage
class SlowMockAdminGateway(MockAdminGateway):
"""让登记停留一小段时间,用来证明主线程仍能处理事件。"""
def register_client(self, client, capabilities):
time.sleep(0.08)
return super().register_client(client, capabilities)
class SettingsPageEventTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def setUp(self):
self.temp_directory = tempfile.TemporaryDirectory()
self.db_path = Path(self.temp_directory.name) / "client.db"
self.repository = SettingsRepository(self.db_path)
def tearDown(self):
self.temp_directory.cleanup()
def _wait_until(self, predicate, timeout_ms=2000):
elapsed = 0
while not predicate() and elapsed < timeout_ms:
QTest.qWait(10)
elapsed += 10
self.assertTrue(predicate(), "等待异步操作超时")
def test_existing_client_info_is_restored_when_page_opens(self):
self.repository.set_many(
{
"admin.client_id": "CLIENT-EXISTING",
"admin.client_name": "办公室电脑",
}
)
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=MockAdminGateway(),
)
self.assertEqual(page.deviceIdInput.text(), "CLIENT-EXISTING")
self.assertEqual(page.deviceNameInput.text(), "办公室电脑")
self.assertEqual(page.currentDeviceStatusLabel.text(), "本地设备信息已加载")
page.eventBinder.shutdown()
page.deleteLater()
def test_save_persists_locally_and_registers_with_mock(self):
gateway = MockAdminGateway()
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=gateway,
)
page.deviceNameInput.setText(" 办公室电脑 ")
page.currentDeviceSaveButton.click()
self.assertFalse(page.currentDeviceSaveButton.isEnabled())
self._wait_until(lambda: gateway.registration_count == 1)
self._wait_until(lambda: page.eventBinder._thread is None)
client_id = self.repository.get("admin.client_id")
self.assertTrue(client_id.startswith("CLIENT-"))
self.assertEqual(
self.repository.get("admin.client_name"), "办公室电脑"
)
self.assertEqual(page.deviceIdInput.text(), client_id)
self.assertEqual(
page.currentDeviceStatusLabel.text(),
"本地已保存,已登记到 Admin",
)
self.assertTrue(page.currentDeviceSaveButton.isEnabled())
page.eventBinder.shutdown()
page.deleteLater()
def test_admin_failure_keeps_local_values_and_allows_retry(self):
gateway = MockAdminGateway()
gateway.fail_next_call_temporarily()
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=gateway,
)
page.deviceNameInput.setText("仓库电脑")
page.currentDeviceSaveButton.click()
self._wait_until(lambda: page.eventBinder._thread is None)
self.assertTrue(self.repository.get("admin.client_id"))
self.assertEqual(self.repository.get("admin.client_name"), "仓库电脑")
self.assertIn("本地已保存,Admin 登记失败", page.currentDeviceStatusLabel.text())
self.assertIn("可再次点击保存重试", page.currentDeviceStatusLabel.text())
self.assertTrue(page.currentDeviceSaveButton.isEnabled())
page.eventBinder.shutdown()
page.deleteLater()
def test_slow_registration_does_not_block_main_event_loop(self):
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=SlowMockAdminGateway(),
)
timer_fired = []
QTimer.singleShot(10, lambda: timer_fired.append(True))
page.currentDeviceSaveButton.click()
self._wait_until(lambda: bool(timer_fired), timeout_ms=500)
self._wait_until(lambda: page.eventBinder._thread is None)
self.assertTrue(timer_fired)
page.eventBinder.shutdown()
page.deleteLater()
def test_shutdown_ignores_late_registration_result(self):
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=SlowMockAdminGateway(),
)
page.currentDeviceSaveButton.click()
self._wait_until(
lambda: "正在登记" in page.currentDeviceStatusLabel.text()
)
status_before_close = page.currentDeviceStatusLabel.text()
page.eventBinder.shutdown()
QTest.qWait(120)
self.assertEqual(page.currentDeviceStatusLabel.text(), status_before_close)
page.deleteLater()
if __name__ == "__main__":
unittest.main()