diff --git a/AGENTS.md b/AGENTS.md index 526546c..d01e685 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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)。 改动接口时,两边的文档必须在同一个工单里同步更新,不允许只改一边。 diff --git a/client/AGENTS.md b/client/AGENTS.md index 678001f..16958b8 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -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。 diff --git a/client/requirements.txt b/client/requirements.txt index 70f28da..6230477 100644 --- a/client/requirements.txt +++ b/client/requirements.txt @@ -15,5 +15,4 @@ PyQt-Fluent-Widgets==1.11.3 # 安卓自动化 uiautomator2==3.2.5 -# 后续接入 Admin HTTP 接口时再放开(当前只用 Mock,不需要装): -# requests==2.32.3 +# Admin HTTP 当前使用 Python 标准库,不需要额外网络依赖。 diff --git a/client/src/admin_gateway.py b/client/src/admin_gateway.py index 29086ac..9890a55 100644 --- a/client/src/admin_gateway.py +++ b/client/src/admin_gateway.py @@ -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( diff --git a/client/src/current_client_service.py b/client/src/current_client_service.py new file mode 100644 index 0000000..d24987b --- /dev/null +++ b/client/src/current_client_service.py @@ -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 "" diff --git a/client/src/http_admin_gateway.py b/client/src/http_admin_gateway.py new file mode 100644 index 0000000..ccf23de --- /dev/null +++ b/client/src/http_admin_gateway.py @@ -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 diff --git a/client/src/mock_admin_gateway.py b/client/src/mock_admin_gateway.py index ee4f47d..d16fbfa 100644 --- a/client/src/mock_admin_gateway.py +++ b/client/src/mock_admin_gateway.py @@ -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 的任务。""" diff --git a/client/src/settings_repository.py b/client/src/settings_repository.py index 25ba768..9c0a8d9 100644 --- a/client/src/settings_repository.py +++ b/client/src/settings_repository.py @@ -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。""" diff --git a/client/src/settings_ui.py b/client/src/settings_ui.py index aa3c6a1..f619d95 100644 --- a/client/src/settings_ui.py +++ b/client/src/settings_ui.py @@ -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 服务返回的设备列表。""" diff --git a/client/src/settings_ui_event.py b/client/src/settings_ui_event.py index bde57cc..58b88a8 100644 --- a/client/src/settings_ui_event.py +++ b/client/src/settings_ui_event.py @@ -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) diff --git a/client/src/ui_main.py b/client/src/ui_main.py index 74b3323..857c9b5 100644 --- a/client/src/ui_main.py +++ b/client/src/ui_main.py @@ -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(), diff --git a/client/test/test_admin_gateway_contract.py b/client/test/test_admin_gateway_contract.py index 8c91341..a9a04f9 100644 --- a/client/test/test_admin_gateway_contract.py +++ b/client/test/test_admin_gateway_contract.py @@ -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) diff --git a/client/test/test_current_client_service.py b/client/test/test_current_client_service.py new file mode 100644 index 0000000..961c525 --- /dev/null +++ b/client/test/test_current_client_service.py @@ -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() diff --git a/client/test/test_http_admin_gateway.py b/client/test/test_http_admin_gateway.py new file mode 100644 index 0000000..6052288 --- /dev/null +++ b/client/test/test_http_admin_gateway.py @@ -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() diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py index 8e29b41..63e7484 100644 --- a/client/test/test_pdd_ui_event.py +++ b/client/test/test_pdd_ui_event.py @@ -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) diff --git a/client/test/test_settings_repository.py b/client/test/test_settings_repository.py index cc0295f..642fb1b 100644 --- a/client/test/test_settings_repository.py +++ b/client/test/test_settings_repository.py @@ -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) diff --git a/client/test/test_settings_ui_event.py b/client/test/test_settings_ui_event.py new file mode 100644 index 0000000..4eddc9f --- /dev/null +++ b/client/test/test_settings_ui_event.py @@ -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()