feat: 保存并登记当前 Client (#11)
This commit is contained in:
@@ -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)。
|
[docs/client/04-admin-api-contract.md](docs/client/04-admin-api-contract.md)。
|
||||||
|
|
||||||
改动接口时,两边的文档必须在同一个工单里同步更新,不允许只改一边。
|
改动接口时,两边的文档必须在同一个工单里同步更新,不允许只改一边。
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
- 数据库字段、任务状态和 `pdd_data` 结构以 `docs/client/03-data-model.md` 为准。
|
- 数据库字段、任务状态和 `pdd_data` 结构以 `docs/client/03-data-model.md` 为准。
|
||||||
- Admin 路径、字段和幂等语义以 `docs/client/04-admin-api-contract.md` 为准;Admin 未完成时使用 Mock,不臆造正式响应。
|
- Admin 路径、字段和幂等语义以 `docs/client/04-admin-api-contract.md` 为准;Admin 未完成时使用 Mock,不臆造正式响应。
|
||||||
- Client 与 Admin 只有三个调用:领取、提交结果、提交失败。**不得新增"向 Admin 查询状态"类接口。**
|
- Client 与 Admin 只有四个调用:登记、领取、提交结果、提交失败。**不得新增"向 Admin 查询状态"类接口。**
|
||||||
- 金额使用人民币分整数,时间使用带时区 ISO 8601,稳定任务编号不得使用表格行号代替。
|
- 金额使用人民币分整数,时间使用带时区 ISO 8601,稳定任务编号不得使用表格行号代替。
|
||||||
- 文件路径一律通过 `data_dir()`(可写数据)和 `app_dir()`(只读资源)获取,见 [03 数据模型 §2.2](../docs/client/03-data-model.md)。**不许硬编码路径,不许用 `os.getcwd()` 或 `__file__` 直接拼**,打包成 exe 后会失效。
|
- 文件路径一律通过 `data_dir()`(可写数据)和 `app_dir()`(只读资源)获取,见 [03 数据模型 §2.2](../docs/client/03-data-model.md)。**不许硬编码路径,不许用 `os.getcwd()` 或 `__file__` 直接拼**,打包成 exe 后会失效。
|
||||||
- PDD 结果必须先写入 SQLite,再通过 Outbox 提交 Admin。
|
- PDD 结果必须先写入 SQLite,再通过 Outbox 提交 Admin。
|
||||||
|
|||||||
@@ -15,5 +15,4 @@ PyQt-Fluent-Widgets==1.11.3
|
|||||||
# 安卓自动化
|
# 安卓自动化
|
||||||
uiautomator2==3.2.5
|
uiautomator2==3.2.5
|
||||||
|
|
||||||
# 后续接入 Admin HTTP 接口时再放开(当前只用 Mock,不需要装):
|
# Admin HTTP 当前使用 Python 标准库,不需要额外网络依赖。
|
||||||
# requests==2.32.3
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Client 访问 Admin 的稳定边界和简单数据对象。
|
"""Client 访问 Admin 的稳定边界和简单数据对象。
|
||||||
|
|
||||||
AdminGateway 只有领取任务、提交成功结果、提交失败结果三个业务方法。
|
AdminGateway 只有登记、领取任务、提交成功结果、提交失败结果四个业务方法。
|
||||||
业务层不应直接依赖 HTTP 请求或 Mock 的内部实现。
|
业务层不应直接依赖 HTTP 请求或 Mock 的内部实现。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -13,13 +13,16 @@ from .task_models import TaskType
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ClientInfo:
|
class ClientInfo:
|
||||||
"""发起领取请求的 Client 身份,不保存访问令牌。"""
|
"""Client 身份和可选名称,不保存访问令牌。"""
|
||||||
|
|
||||||
client_id: str
|
client_id: str
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not self.client_id.strip():
|
if not self.client_id.strip():
|
||||||
raise ValueError("client_id 不能为空")
|
raise ValueError("client_id 不能为空")
|
||||||
|
if len(self.name.strip()) > 50:
|
||||||
|
raise ValueError("client.name 最多 50 个字")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -43,7 +46,7 @@ class AndroidDeviceInfo:
|
|||||||
class ClaimCapabilities:
|
class ClaimCapabilities:
|
||||||
"""Client 领取任务时声明的设备与执行能力。"""
|
"""Client 领取任务时声明的设备与执行能力。"""
|
||||||
|
|
||||||
device: AndroidDeviceInfo
|
device: Optional[AndroidDeviceInfo] = None
|
||||||
supported_types: Tuple[TaskType, ...] = (
|
supported_types: Tuple[TaskType, ...] = (
|
||||||
TaskType.COLLECT,
|
TaskType.COLLECT,
|
||||||
TaskType.PURCHASE,
|
TaskType.PURCHASE,
|
||||||
@@ -96,6 +99,15 @@ class SubmissionReceipt:
|
|||||||
accepted_at: str
|
accepted_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RegistrationReceipt:
|
||||||
|
"""Admin 已登记当前 Client 的确认。"""
|
||||||
|
|
||||||
|
registered: bool
|
||||||
|
client_id: str
|
||||||
|
registered_at: str
|
||||||
|
|
||||||
|
|
||||||
class AdminGatewayError(RuntimeError):
|
class AdminGatewayError(RuntimeError):
|
||||||
"""带稳定错误代码和可重试标志的 Admin 边界错误。"""
|
"""带稳定错误代码和可重试标志的 Admin 边界错误。"""
|
||||||
|
|
||||||
@@ -114,8 +126,18 @@ class AdminGatewayError(RuntimeError):
|
|||||||
self.details = dict(details or {})
|
self.details = dict(details or {})
|
||||||
|
|
||||||
|
|
||||||
class AdminGateway(ABC):
|
class ClientRegistrationGateway(ABC):
|
||||||
"""Admin 边界;不得增加状态查询、心跳或租约方法。"""
|
"""设置页只依赖登记能力,不依赖任务领取和提交。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def register_client(
|
||||||
|
self, client: ClientInfo, capabilities: ClaimCapabilities
|
||||||
|
) -> RegistrationReceipt:
|
||||||
|
"""幂等登记或更新当前 Client。"""
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGateway(ClientRegistrationGateway):
|
||||||
|
"""完整 Admin 边界;不得增加状态查询、心跳或租约方法。"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def claim_next(
|
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,
|
AdminTask,
|
||||||
ClaimCapabilities,
|
ClaimCapabilities,
|
||||||
ClientInfo,
|
ClientInfo,
|
||||||
|
RegistrationReceipt,
|
||||||
SubmissionReceipt,
|
SubmissionReceipt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,8 +50,41 @@ class MockAdminGateway(AdminGateway):
|
|||||||
] = {}
|
] = {}
|
||||||
self._next_error: Optional[AdminGatewayError] = None
|
self._next_error: Optional[AdminGatewayError] = None
|
||||||
self._reject_next_submission = False
|
self._reject_next_submission = False
|
||||||
|
self._registrations: Dict[str, Tuple[ClientInfo, ClaimCapabilities]] = {}
|
||||||
self._lock = Lock()
|
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:
|
def enqueue_task(self, task: AdminTask, assigned_client_id: str) -> None:
|
||||||
"""测试辅助:加入一条分配给指定 Client 的任务。"""
|
"""测试辅助:加入一条分配给指定 Client 的任务。"""
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
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 .db import initialize_database, open_database
|
||||||
from .task_models import AppSettingRecord
|
from .task_models import AppSettingRecord
|
||||||
@@ -74,6 +74,35 @@ class SettingsRepository:
|
|||||||
connection.close()
|
connection.close()
|
||||||
return AppSettingRecord(key, value, now)
|
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:
|
def delete(self, setting_key: str) -> bool:
|
||||||
"""删除设置;确实删除了一条记录时返回 True。"""
|
"""删除设置;确实删除了一条记录时返回 True。"""
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,12 @@ class AndroidDeviceTableModel(QAbstractTableModel):
|
|||||||
class SettingsPage(QWidget):
|
class SettingsPage(QWidget):
|
||||||
"""设备管理与后续应用设置的统一页面。"""
|
"""设备管理与后续应用设置的统一页面。"""
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent=None,
|
||||||
|
settings_repository=None,
|
||||||
|
admin_gateway=None,
|
||||||
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("settingsPage")
|
self.setObjectName("settingsPage")
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
@@ -209,7 +214,11 @@ class SettingsPage(QWidget):
|
|||||||
# 延迟到控件创建完成后绑定,避免事件层在构造过程中访问半成品页面。
|
# 延迟到控件创建完成后绑定,避免事件层在构造过程中访问半成品页面。
|
||||||
from .settings_ui_event import SettingsPageEventBinder
|
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:
|
def _build_ui(self) -> None:
|
||||||
self.deviceIdInput = LineEdit(self)
|
self.deviceIdInput = LineEdit(self)
|
||||||
@@ -225,6 +234,10 @@ class SettingsPage(QWidget):
|
|||||||
|
|
||||||
self.currentDeviceSaveButton = PushButton(FIF.SAVE, "保存", self)
|
self.currentDeviceSaveButton = PushButton(FIF.SAVE, "保存", self)
|
||||||
self.currentDeviceSaveButton.setAccessibleName("保存当前设备信息")
|
self.currentDeviceSaveButton.setAccessibleName("保存当前设备信息")
|
||||||
|
self.currentDeviceStatusLabel = CaptionLabel(
|
||||||
|
"设备信息尚未保存", self
|
||||||
|
)
|
||||||
|
self.currentDeviceStatusLabel.setAccessibleName("当前设备保存状态")
|
||||||
|
|
||||||
self.currentDeviceCard = self._build_current_device_card()
|
self.currentDeviceCard = self._build_current_device_card()
|
||||||
|
|
||||||
@@ -322,6 +335,7 @@ class SettingsPage(QWidget):
|
|||||||
commandLayout.addStretch(1)
|
commandLayout.addStretch(1)
|
||||||
commandLayout.addWidget(self.currentDeviceSaveButton)
|
commandLayout.addWidget(self.currentDeviceSaveButton)
|
||||||
layout.addLayout(commandLayout)
|
layout.addLayout(commandLayout)
|
||||||
|
layout.addWidget(self.currentDeviceStatusLabel)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
def _build_android_device_card(self) -> CardWidget:
|
def _build_android_device_card(self) -> CardWidget:
|
||||||
@@ -357,6 +371,11 @@ class SettingsPage(QWidget):
|
|||||||
self.deviceIdInput.setText(device_id or "待生成")
|
self.deviceIdInput.setText(device_id or "待生成")
|
||||||
self.deviceNameInput.setText(device_name)
|
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:
|
def set_android_devices(self, devices: Iterable[AndroidDeviceRow]) -> None:
|
||||||
"""显示后续 ADB 服务返回的设备列表。"""
|
"""显示后续 ADB 服务返回的设备列表。"""
|
||||||
|
|
||||||
|
|||||||
+243
-26
@@ -1,32 +1,92 @@
|
|||||||
"""设置页的事件绑定和当前客户端设备号生成。
|
"""设置页事件、当前 Client 本地保存和后台登记。
|
||||||
|
|
||||||
本文件把界面操作转换为轻量信号,不直接执行 ADB 或 SQLite。
|
本文件不直接写 SQL。SQLite 保存和 Admin HTTP 请求由 Worker 在线程中执行,
|
||||||
后续应用服务应在后台线程处理阻塞操作,再通过页面更新方法返回结果。
|
后台结果只通过信号返回主线程更新页面。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
from typing import Optional
|
||||||
import platform
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
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 = "待生成"
|
DEVICE_ID_PLACEHOLDER = "待生成"
|
||||||
|
|
||||||
|
|
||||||
def generate_client_device_id() -> str:
|
class CurrentClientSaveWorker(QObject):
|
||||||
"""根据本机特征生成设备号,不暴露原始系统和硬件信息。"""
|
"""在线程中先保存本地身份,再登记到 Admin。"""
|
||||||
|
|
||||||
fingerprint = "|".join(
|
localSaved = pyqtSignal(str, str)
|
||||||
(
|
localSaveFailed = pyqtSignal(str)
|
||||||
platform.system(),
|
registrationSucceeded = pyqtSignal(str)
|
||||||
platform.node(),
|
registrationFailed = pyqtSignal(str)
|
||||||
platform.machine(),
|
completed = pyqtSignal()
|
||||||
f"{uuid.getnode():012x}",
|
|
||||||
)
|
def __init__(
|
||||||
)
|
self,
|
||||||
digest = hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()[:16].upper()
|
service: CurrentClientService,
|
||||||
return f"CLIENT-{digest}"
|
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):
|
class SettingsPageEventBinder(QObject):
|
||||||
@@ -38,36 +98,108 @@ class SettingsPageEventBinder(QObject):
|
|||||||
saveRequested = pyqtSignal(str, str)
|
saveRequested = pyqtSignal(str, str)
|
||||||
deleteRequested = pyqtSignal(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)
|
super().__init__(page)
|
||||||
self._page = page
|
self._page = page
|
||||||
self._busy = False
|
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(
|
page.currentDeviceSaveButton.clicked.connect(
|
||||||
self._request_save_current_device
|
self._request_save_current_device
|
||||||
)
|
)
|
||||||
|
self.currentDeviceSaveRequested.connect(
|
||||||
|
self._start_save_current_device
|
||||||
|
)
|
||||||
page.searchButton.clicked.connect(self._request_search)
|
page.searchButton.clicked.connect(self._request_search)
|
||||||
page.connectButton.clicked.connect(self._request_connect)
|
page.connectButton.clicked.connect(self._request_connect)
|
||||||
page.saveButton.clicked.connect(self._request_save)
|
page.saveButton.clicked.connect(self._request_save)
|
||||||
page.deleteButton.clicked.connect(self._request_delete)
|
page.deleteButton.clicked.connect(self._request_delete)
|
||||||
page.addressInput.textChanged.connect(self._sync_button_state)
|
page.addressInput.textChanged.connect(self._sync_button_state)
|
||||||
page.deviceTableModel.checkedDeviceChanged.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()
|
self._sync_button_state()
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def _request_save_current_device(self) -> None:
|
def _request_save_current_device(self) -> None:
|
||||||
if self._busy:
|
if self._busy or self._current_device_busy:
|
||||||
return
|
return
|
||||||
|
|
||||||
device_id = self._page.deviceIdInput.text().strip()
|
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()
|
device_name = self._page.deviceNameInput.text().strip()
|
||||||
self._page.deviceNameInput.setText(device_name)
|
self._page.deviceNameInput.setText(device_name)
|
||||||
self.currentDeviceSaveRequested.emit(device_id, 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()
|
@pyqtSlot()
|
||||||
def _request_search(self) -> None:
|
def _request_search(self) -> None:
|
||||||
if self._busy:
|
if self._busy:
|
||||||
@@ -118,7 +250,9 @@ class SettingsPageEventBinder(QObject):
|
|||||||
def _sync_button_state(self, *_args) -> None:
|
def _sync_button_state(self, *_args) -> None:
|
||||||
serial = self._page.deviceTableModel.checked_serial
|
serial = self._page.deviceTableModel.checked_serial
|
||||||
has_address = bool(self._page.addressInput.text().strip())
|
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.searchButton.setEnabled(not self._busy)
|
||||||
self._page.connectButton.setEnabled(not self._busy and has_address)
|
self._page.connectButton.setEnabled(not self._busy and has_address)
|
||||||
self._page.saveButton.setEnabled(not self._busy and bool(serial))
|
self._page.saveButton.setEnabled(not self._busy and bool(serial))
|
||||||
@@ -131,3 +265,86 @@ class SettingsPageEventBinder(QObject):
|
|||||||
self._sync_button_state()
|
self._sync_button_state()
|
||||||
if message:
|
if message:
|
||||||
self._page.deviceStatusLabel.setText(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):
|
class MainWindow(FluentWindow):
|
||||||
"""应用主窗口。"""
|
"""应用主窗口。"""
|
||||||
|
|
||||||
def __init__(self, task_repository=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
task_repository=None,
|
||||||
|
settings_repository=None,
|
||||||
|
admin_gateway=None,
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.pddTaskPage = PDDTaskPage(self)
|
self.pddTaskPage = PDDTaskPage(self)
|
||||||
self.settingsPage = SettingsPage(self)
|
self.settingsPage = SettingsPage(
|
||||||
|
self,
|
||||||
|
settings_repository=settings_repository,
|
||||||
|
admin_gateway=admin_gateway,
|
||||||
|
)
|
||||||
self.pddTaskPageEvent = PDDTaskPageEvent(
|
self.pddTaskPageEvent = PDDTaskPageEvent(
|
||||||
self.pddTaskPage,
|
self.pddTaskPage,
|
||||||
task_repository or TaskRepository(),
|
task_repository or TaskRepository(),
|
||||||
|
|||||||
@@ -60,14 +60,46 @@ class MockAdminGatewayContractTest(unittest.TestCase):
|
|||||||
"reported_at": "2026-08-06T08:03:00Z",
|
"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(
|
self.assertEqual(
|
||||||
AdminGateway.__abstractmethods__,
|
AdminGateway.__abstractmethods__,
|
||||||
{"claim_next", "submit_result", "submit_failure"},
|
{
|
||||||
|
"register_client",
|
||||||
|
"claim_next",
|
||||||
|
"submit_result",
|
||||||
|
"submit_failure",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
for forbidden in ("get_status", "heartbeat", "renew_lease"):
|
for forbidden in ("get_status", "heartbeat", "renew_lease"):
|
||||||
self.assertFalse(hasattr(AdminGateway, forbidden))
|
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):
|
def test_claim_returns_none_when_no_task_exists(self):
|
||||||
self.assertIsNone(
|
self.assertIsNone(
|
||||||
self.gateway.claim_next(self.client, self.all_capabilities)
|
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()
|
||||||
@@ -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()
|
||||||
@@ -11,6 +11,8 @@ from PyQt5.QtWidgets import QApplication
|
|||||||
|
|
||||||
from src.pdd_ui import PDDTaskPage
|
from src.pdd_ui import PDDTaskPage
|
||||||
from src.pdd_ui_event import PDDTaskPageEvent, summary_to_row
|
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_models import NewClaimedTask, TaskStatus, TaskSummary, TaskType
|
||||||
from src.task_repository import TaskRepository
|
from src.task_repository import TaskRepository
|
||||||
from src.ui_main import MainWindow
|
from src.ui_main import MainWindow
|
||||||
@@ -130,7 +132,11 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
|||||||
self.assertFalse(hasattr(row, "pdd_data"))
|
self.assertFalse(hasattr(row, "pdd_data"))
|
||||||
|
|
||||||
def test_main_window_keeps_event_object_alive(self):
|
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.assertIsInstance(window.pddTaskPageEvent, PDDTaskPageEvent)
|
||||||
self.assertEqual(window.pddTaskPage.taskModel.data_row_count(), 0)
|
self.assertEqual(window.pddTaskPage.taskModel.data_row_count(), 0)
|
||||||
|
|||||||
@@ -48,6 +48,27 @@ class SettingsRepositoryTests(unittest.TestCase):
|
|||||||
self.assertFalse(self.repository.delete("automation.dry_run"))
|
self.assertFalse(self.repository.delete("automation.dry_run"))
|
||||||
self.assertIsNone(self.repository.get("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:
|
def test_empty_setting_key_is_rejected(self) -> None:
|
||||||
with self.assertRaisesRegex(ValueError, "setting_key"):
|
with self.assertRaisesRegex(ValueError, "setting_key"):
|
||||||
self.repository.set(" ", True)
|
self.repository.set(" ", True)
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user