feat: 领取 Admin 采集任务并保存本地 (#30)

This commit is contained in:
chengma
2026-08-07 16:42:31 +08:00
parent 107366f1f0
commit 51062f4c7b
7 changed files with 841 additions and 15 deletions
+7 -3
View File
@@ -136,14 +136,18 @@ class ClientRegistrationGateway(ABC):
"""幂等登记或更新当前 Client。"""
class AdminGateway(ClientRegistrationGateway):
"""完整 Admin 边界;不得增加状态查询、心跳或租约方法。"""
class TaskClaimGateway(ABC):
"""任务页只依赖领取能力,不依赖登记和结果提交。"""
@abstractmethod
def claim_next(
self, client: ClientInfo, capabilities: ClaimCapabilities
) -> Optional[AdminTask]:
"""领取至多一个分配给当前 Client 的任务。"""
"""领取至多一个任务;暂无任务时返回 ``None``。"""
class AdminGateway(ClientRegistrationGateway, TaskClaimGateway):
"""完整 Admin 边界;不得增加状态查询、心跳或租约方法。"""
@abstractmethod
def submit_result(
+160 -7
View File
@@ -1,28 +1,31 @@
"""使用 Python 标准库调用 Admin 登记接口。"""
"""使用 Python 标准库调用 Admin 登记和任务领取接口。"""
import json
import socket
from http.client import RemoteDisconnected
from typing import Callable, Optional
from typing import Callable, Mapping, 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 (
AdminTask,
AdminGatewayError,
ClaimCapabilities,
ClientInfo,
ClientRegistrationGateway,
RegistrationReceipt,
TaskClaimGateway,
)
from .task_models import TaskType
DEFAULT_ADMIN_BASE_URL = "http://127.0.0.1:8080"
class HttpAdminGateway(ClientRegistrationGateway):
"""通过 HTTP 登记 Client;访问令牌只保存在内存。"""
class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway):
"""通过 HTTP 登记 Client 和领取采集任务;令牌只保存在内存。"""
def __init__(
self,
@@ -135,6 +138,151 @@ class HttpAdminGateway(ClientRegistrationGateway):
registered_at=data["registered_at"],
)
def claim_next(
self,
client: ClientInfo,
capabilities: ClaimCapabilities,
) -> Optional[AdminTask]:
"""领取一个采集任务;Admin 返回 204 时返回 ``None``。"""
request_id = str(uuid4())
payload = {
"client": {"name": client.name.strip()},
# #30 只允许领取采集任务。采购能力必须由安全门禁工单开启。
"supported_types": [TaskType.COLLECT.value],
"capabilities": {
"purchase_mode": "dry_run",
"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/tasks/claim",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers=headers,
method="POST",
)
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 == 204:
return None
if status != 200:
raise AdminGatewayError(
"ADMIN_UNEXPECTED_RESPONSE",
f"Admin 返回了未预期的状态码 {status}",
status >= 500,
request_id,
)
return self._parse_claim_response(body, request_id)
@classmethod
def _parse_claim_response(
cls,
body: bytes,
request_id: str,
) -> AdminTask:
data = cls._decode_json(body, request_id)
task = data.get("task")
if not isinstance(task, Mapping):
raise AdminGatewayError(
"ADMIN_INVALID_RESPONSE",
"Admin 领取响应缺少 task 对象",
False,
request_id,
)
task_id = task.get("id")
raw_type = task.get("type")
version = task.get("version")
priority = task.get("priority")
task_payload = task.get("payload")
created_at = task.get("created_at")
updated_at = task.get("updated_at")
valid = (
isinstance(task_id, str)
and bool(task_id.strip())
and isinstance(raw_type, str)
and isinstance(version, int)
and not isinstance(version, bool)
and version > 0
and isinstance(priority, int)
and not isinstance(priority, bool)
and isinstance(task_payload, Mapping)
and isinstance(created_at, str)
and isinstance(updated_at, str)
)
if not valid:
raise AdminGatewayError(
"ADMIN_INVALID_RESPONSE",
"Admin 领取响应的任务字段不完整",
False,
request_id,
)
try:
task_type = TaskType(raw_type)
except ValueError as exc:
raise AdminGatewayError(
"ADMIN_INVALID_RESPONSE",
f"Admin 返回了不支持的任务类型 {raw_type}",
False,
request_id,
) from exc
return AdminTask(
task_id=task_id,
task_type=task_type,
version=version,
priority=priority,
payload=dict(task_payload),
created_at=created_at,
updated_at=updated_at,
)
@staticmethod
def _decode_json(body: bytes, request_id: str) -> dict:
try:
@@ -156,7 +304,12 @@ class HttpAdminGateway(ClientRegistrationGateway):
return data
@classmethod
def _raise_http_error(cls, error: HTTPError, request_id: str) -> None:
def _raise_http_error(
cls,
error: HTTPError,
request_id: str,
operation: str = "登记",
) -> None:
try:
body = error.read()
except OSError:
@@ -168,7 +321,7 @@ class HttpAdminGateway(ClientRegistrationGateway):
if not isinstance(details, dict):
raise ValueError
code = str(details.get("code") or "ADMIN_HTTP_ERROR")
message = str(details.get("message") or "Admin 拒绝了登记请求")
message = str(details.get("message") or f"Admin 拒绝了{operation}请求")
retryable = bool(details.get("retryable", error.code >= 500))
response_request_id = str(details.get("request_id") or request_id)
extra = details.get("details")
@@ -176,7 +329,7 @@ class HttpAdminGateway(ClientRegistrationGateway):
extra = {}
except (AdminGatewayError, ValueError):
code = "ADMIN_HTTP_ERROR"
message = f"Admin 登记失败,状态码 {error.code}"
message = f"Admin {operation}失败,状态码 {error.code}"
retryable = error.code >= 500
response_request_id = request_id
extra = {}
+311 -3
View File
@@ -22,11 +22,30 @@
from typing import Dict, Optional
from PyQt5.QtCore import QObject
from PyQt5.QtCore import QCoreApplication, QObject, QThread, pyqtSignal, pyqtSlot
from qfluentwidgets import InfoBar, InfoBarPosition
from .admin_gateway import (
AdminGatewayError,
AdminTask,
AndroidDeviceInfo,
ClaimCapabilities,
ClientInfo,
TaskClaimGateway,
)
from .current_client_service import CurrentClientService
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
from .pdd_ui import PDDTaskPage, TaskRow
from .task_models import TaskFilters, TaskStatus, TaskSummary, TaskType
from .task_repository import TaskRepository
from .selected_android_device_service import SelectedAndroidDeviceService
from .settings_repository import SettingsRepository
from .task_models import (
NewClaimedTask,
TaskFilters,
TaskStatus,
TaskSummary,
TaskType,
)
from .task_repository import DuplicateTaskError, TaskRepository
TASK_TYPE_BY_TEXT = {
@@ -62,6 +81,132 @@ TASK_STATUS_TEXT = {
}
def admin_task_to_new_claimed_task(task: AdminTask) -> NewClaimedTask:
"""把 Admin 任务显式映射为本地任务,避免字段名自动展开出错。"""
if task.task_type is not TaskType.COLLECT:
raise ValueError(f"任务 {task.task_id} 不是采集任务")
payload = dict(task.payload)
goods_url = payload.get("goods_url")
goods_id = payload.get("goods_id")
if not isinstance(goods_url, str) or not goods_url.strip():
raise ValueError("payload.goods_url 不能为空")
if goods_id is not None and not isinstance(goods_id, str):
raise ValueError("payload.goods_id 必须是文本")
original_task = {
"id": task.task_id,
"type": task.task_type.value,
"version": task.version,
"priority": task.priority,
"payload": payload,
"created_at": task.created_at,
"updated_at": task.updated_at,
}
return NewClaimedTask(
remote_task_id=task.task_id,
task_type=task.task_type,
goods_url=goods_url,
goods_id=goods_id.strip() if isinstance(goods_id, str) else None,
priority=task.priority,
version=task.version,
admin_payload=original_task,
)
class ClaimTaskWorker(QObject):
"""在后台领取至多一个采集任务,并先写入本地数据库。"""
noTask = pyqtSignal()
taskSaved = pyqtSignal(str)
duplicateTask = pyqtSignal(str)
localSaveFailed = pyqtSignal(str, str)
failed = pyqtSignal(str)
completed = pyqtSignal()
def __init__(
self,
gateway: TaskClaimGateway,
task_repository: TaskRepository,
client_service: CurrentClientService,
android_device_service: SelectedAndroidDeviceService,
) -> None:
super().__init__()
self._gateway = gateway
self._task_repository = task_repository
self._client_service = client_service
self._android_device_service = android_device_service
self._cancelled = False
def cancel(self) -> None:
"""阻止尚未开始的领取;已领取的任务仍必须保存到本地。"""
self._cancelled = True
@pyqtSlot()
def run(self) -> None:
claimed_task: Optional[AdminTask] = None
try:
if self._cancelled:
return
client_settings = self._client_service.load()
if not client_settings.client_id:
self.failed.emit("请先在设置页保存当前设备号和设备名")
return
android_serial = self._android_device_service.load()
if not android_serial:
self.failed.emit("请先在设置页选择并保存 Android 设备")
return
capabilities = ClaimCapabilities(
device=AndroidDeviceInfo(android_serial),
supported_types=(TaskType.COLLECT,),
purchase_mode="dry_run",
schema_versions=(1,),
)
claimed_task = self._gateway.claim_next(
ClientInfo(
client_settings.client_id,
client_settings.client_name,
),
capabilities,
)
if claimed_task is None:
if not self._cancelled:
self.noTask.emit()
return
local_task = admin_task_to_new_claimed_task(claimed_task)
try:
self._task_repository.add_claimed_task(local_task)
except DuplicateTaskError:
if not self._cancelled:
self.duplicateTask.emit(claimed_task.task_id)
return
except Exception as exc:
if not self._cancelled:
self.localSaveFailed.emit(claimed_task.task_id, str(exc))
return
if not self._cancelled:
self.taskSaved.emit(claimed_task.task_id)
except AdminGatewayError as exc:
if not self._cancelled:
request_hint = (
f",请求编号:{exc.request_id}" if exc.request_id else ""
)
self.failed.emit(f"{exc}{request_hint}")
except Exception as exc:
if not self._cancelled:
if claimed_task is not None:
self.localSaveFailed.emit(claimed_task.task_id, str(exc))
else:
self.failed.emit(f"领取任务失败:{exc}")
finally:
self.completed.emit()
class PDDTaskPageEvent(QObject):
"""把 PDD 页面只读操作连接到本地任务 Repository。"""
@@ -70,15 +215,46 @@ class PDDTaskPageEvent(QObject):
page: PDDTaskPage,
repository: Optional[TaskRepository] = None,
parent=None,
claim_gateway: Optional[TaskClaimGateway] = None,
settings_repository: Optional[SettingsRepository] = None,
):
super().__init__(parent or page)
self._page = page
self._repository = repository or TaskRepository()
self._filters = TaskFilters()
self._closing = False
self._claim_busy = False
self._claim_thread: Optional[QThread] = None
self._claim_worker: Optional[ClaimTaskWorker] = None
settings = settings_repository or SettingsRepository()
self._client_service = CurrentClientService(settings)
self._selected_android_device_service = SelectedAndroidDeviceService(
settings
)
self._claim_gateway = claim_gateway
self._claim_gateway_error = ""
if self._claim_gateway is None:
base_url = settings.get("admin.base_url", DEFAULT_ADMIN_BASE_URL)
timeout_value = settings.get("admin.request_timeout_seconds", 3.0)
try:
timeout_seconds = float(timeout_value)
self._claim_gateway = HttpAdminGateway(
base_url if isinstance(base_url, str) else "",
timeout_seconds=timeout_seconds,
)
except (TypeError, ValueError) as exc:
self._claim_gateway_error = str(exc)
page.searchRequested.connect(self.search_tasks)
page.refreshRequested.connect(self.refresh_tasks)
page.autoFetchRequested.connect(self._request_claim_task)
page.taskModel.loadMoreRequested.connect(self._load_page)
page.autoFetchButton.setText("获取任务")
page.destroyed.connect(self.shutdown)
application = QCoreApplication.instance()
if application is not None:
application.aboutToQuit.connect(self.shutdown)
def load_initial_tasks(self) -> None:
"""应用启动后读取第一页本地任务。"""
@@ -100,6 +276,103 @@ class PDDTaskPageEvent(QObject):
self._reload()
@pyqtSlot()
def _request_claim_task(self) -> None:
"""启动一次后台领取;重复点击和关闭期间直接忽略。"""
if self._closing or self._claim_busy:
return
if self._claim_gateway_error or self._claim_gateway is None:
message = self._claim_gateway_error or "Admin 领取服务未初始化"
self._page.set_engine_status(f"领取失败:{message}")
self._show_claim_error("领取任务失败", message)
return
self._claim_busy = True
self._page.autoFetchButton.setEnabled(False)
self._page.set_engine_status("正在领取一个采集任务,请稍候…")
thread = QThread(self)
worker = ClaimTaskWorker(
self._claim_gateway,
self._repository,
self._client_service,
self._selected_android_device_service,
)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.noTask.connect(self._on_no_claimed_task)
worker.taskSaved.connect(self._on_claimed_task_saved)
worker.duplicateTask.connect(self._on_duplicate_claimed_task)
worker.localSaveFailed.connect(self._on_claimed_task_save_failed)
worker.failed.connect(self._on_claim_failed)
worker.completed.connect(thread.quit)
worker.completed.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._on_claim_thread_finished)
self._claim_thread = thread
self._claim_worker = worker
thread.start()
@pyqtSlot()
def _on_no_claimed_task(self) -> None:
if not self._closing:
self._page.set_engine_status("暂无可领取的采集任务")
@pyqtSlot(str)
def _on_claimed_task_saved(self, task_id: str) -> None:
if self._closing:
return
self._page.set_engine_status(
f"已领取任务 {task_id},已保存到本地任务列表"
)
self._reload()
@pyqtSlot(str)
def _on_duplicate_claimed_task(self, task_id: str) -> None:
if not self._closing:
self._page.set_engine_status(
f"任务 {task_id} 本地已有,未重复保存"
)
@pyqtSlot(str, str)
def _on_claimed_task_save_failed(self, task_id: str, message: str) -> None:
if not self._closing:
content = (
f"任务 {task_id} 已在服务端领取,但本地保存失败:{message}。"
"请记下这个任务号联系维护者。"
)
self._page.set_engine_status(content)
self._show_claim_error("本地任务保存失败", content)
@pyqtSlot(str)
def _on_claim_failed(self, message: str) -> None:
if not self._closing:
content = message or "领取任务失败,请稍后重试"
self._page.set_engine_status(content)
self._show_claim_error("领取任务失败", content)
def _show_claim_error(self, title: str, content: str) -> None:
"""显示不会自动消失的可恢复错误,同时保留底部状态文字。"""
InfoBar.error(
title=title,
content=content,
isClosable=True,
duration=-1,
position=InfoBarPosition.TOP_RIGHT,
parent=self._page,
)
@pyqtSlot()
def _on_claim_thread_finished(self) -> None:
self._claim_worker = None
self._claim_thread = None
self._claim_busy = False
if not self._closing:
self._page.autoFetchButton.setText("获取任务")
self._page.autoFetchButton.setEnabled(True)
def _reload(self) -> None:
self._page.begin_task_reload()
self._page.taskModel.fetchMore()
@@ -118,6 +391,41 @@ class PDDTaskPageEvent(QObject):
rows = [summary_to_row(summary) for summary in summaries]
self._page.append_task_page(rows, has_more=len(rows) == limit)
@pyqtSlot()
def shutdown(self) -> None:
"""窗口关闭时停止新的领取,并等待已领取任务完成本地保存。"""
if self._closing:
return
self._closing = True
worker = self._claim_worker
thread = self._claim_thread
if worker is not None:
try:
worker.cancel()
signal_slots = (
(worker.noTask, self._on_no_claimed_task),
(worker.taskSaved, self._on_claimed_task_saved),
(worker.duplicateTask, self._on_duplicate_claimed_task),
(
worker.localSaveFailed,
self._on_claimed_task_save_failed,
),
(worker.failed, self._on_claim_failed),
)
except RuntimeError:
signal_slots = ()
for signal, slot in signal_slots:
try:
signal.disconnect(slot)
except (TypeError, RuntimeError):
pass
if thread is not None and thread.isRunning():
thread.quit()
thread.wait(10_000)
def summary_to_row(summary: TaskSummary) -> TaskRow:
"""把领域摘要转换成只供表格显示的轻量行。"""