feat: 安全领取并分派采购任务 (#72)

This commit is contained in:
chengma
2026-08-10 00:05:40 +08:00
parent 460619392f
commit f201bcc4bf
12 changed files with 789 additions and 76 deletions
+1 -4
View File
@@ -47,10 +47,7 @@ class ClaimCapabilities:
"""Client 领取任务时声明的设备与执行能力。"""
device: Optional[AndroidDeviceInfo] = None
supported_types: Tuple[TaskType, ...] = (
TaskType.COLLECT,
TaskType.PURCHASE,
)
supported_types: Tuple[TaskType, ...] = (TaskType.COLLECT,)
purchase_mode: str = "dry_run"
schema_versions: Tuple[int, ...] = (1,)
+62 -4
View File
@@ -147,16 +147,17 @@ class HttpAdminGateway(AdminGateway):
client: ClientInfo,
capabilities: ClaimCapabilities,
) -> Optional[AdminTask]:
"""领取一个采集任务;Admin 返回 204 时返回 ``None``。"""
"""按调用方已安全确认的能力领取一个任务。"""
request_id = str(uuid4())
self._client_id = client.client_id.strip()
payload = {
"client": {"name": client.name.strip()},
# #30 只允许领取采集任务。采购能力必须由安全门禁工单开启。
"supported_types": [TaskType.COLLECT.value],
"supported_types": [
task_type.value for task_type in capabilities.supported_types
],
"capabilities": {
"purchase_mode": "dry_run",
"purchase_mode": capabilities.purchase_mode,
"schema_versions": list(capabilities.schema_versions),
},
}
@@ -394,6 +395,8 @@ class HttpAdminGateway(AdminGateway):
request_id,
) from exc
cls._validate_claim_payload(task_type, task_payload, request_id)
return AdminTask(
task_id=task_id,
task_type=task_type,
@@ -404,6 +407,61 @@ class HttpAdminGateway(AdminGateway):
updated_at=updated_at,
)
@staticmethod
def _validate_claim_payload(
task_type: TaskType,
payload: Mapping[str, object],
request_id: str,
) -> None:
"""在采购任务进入本地执行前校验全部安全字段。"""
goods_url = payload.get("goods_url")
if not isinstance(goods_url, str) or not goods_url.strip():
raise AdminGatewayError(
"ADMIN_INVALID_RESPONSE",
"Admin 任务 payload.goods_url 不能为空",
False,
request_id,
)
if task_type is not TaskType.PURCHASE:
return
goods_id = payload.get("goods_id")
options = payload.get("options")
quantity = payload.get("quantity")
max_price_cent = payload.get("max_price_cent")
valid_options = (
isinstance(options, Mapping)
and bool(options)
and all(
isinstance(key, str)
and bool(key.strip())
and isinstance(value, str)
and bool(value.strip())
for key, value in options.items()
)
)
valid = (
isinstance(goods_id, str)
and bool(goods_id.strip())
and valid_options
and isinstance(quantity, int)
and not isinstance(quantity, bool)
and quantity > 0
and isinstance(max_price_cent, int)
and not isinstance(max_price_cent, bool)
and max_price_cent > 0
)
if not valid:
raise AdminGatewayError(
"ADMIN_INVALID_RESPONSE",
(
"Admin 采购任务缺少有效的 goods_id、动态 options、"
"quantity 或 max_price_cent"
),
False,
request_id,
)
@staticmethod
def _decode_json(body: bytes, request_id: str) -> dict:
try:
+48 -61
View File
@@ -34,7 +34,6 @@ from qfluentwidgets import InfoBar, InfoBarPosition, MessageBox
from .admin_gateway import (
AdminGatewayError,
AdminTask,
ClientInfo,
AdminGateway,
)
@@ -42,16 +41,20 @@ from .collect_task_service import CollectServiceFactory, CollectTaskService
from .current_client_service import CurrentClientService
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
from .pdd_ui import PDDTaskPage, TaskRow
from .purchase_task_service import PurchaseAdapterFactory
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 CollectRerunError, TaskRepository
from .task_dispatcher import (
TaskDispatcher,
admin_task_to_new_claimed_task,
)
from .task_detail_view import TaskDetailWindow
@@ -88,42 +91,8 @@ 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)
@@ -141,6 +110,7 @@ class ClaimTaskWorker(QObject):
client_service: CurrentClientService,
android_device_service: SelectedAndroidDeviceService,
collect_service_factory: Optional[CollectServiceFactory] = None,
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
selected_task_id: str = "",
) -> None:
super().__init__()
@@ -150,6 +120,7 @@ class ClaimTaskWorker(QObject):
self._android_device_service = android_device_service
self._cancelled = False
self._collect_service_factory = collect_service_factory
self._purchase_adapter_factory = purchase_adapter_factory
self._selected_task_id = selected_task_id
def cancel(self) -> None:
@@ -168,22 +139,31 @@ class ClaimTaskWorker(QObject):
return
android_serial = self._android_device_service.load()
service = CollectTaskService(
self._gateway,
self._task_repository,
ClientInfo(
client_settings.client_id,
client_settings.client_name,
),
android_serial or "",
cancelled=lambda: self._cancelled,
collect_service_factory=self._collect_service_factory,
)
result = (
service.execute_selected(self._selected_task_id)
if self._selected_task_id
else service.execute_one()
client = ClientInfo(
client_settings.client_id,
client_settings.client_name,
)
if self._selected_task_id:
service = CollectTaskService(
self._gateway,
self._task_repository,
client,
android_serial or "",
cancelled=lambda: self._cancelled,
collect_service_factory=self._collect_service_factory,
)
result = service.execute_selected(self._selected_task_id)
else:
dispatcher = TaskDispatcher(
self._gateway,
self._task_repository,
client,
android_serial or "",
collect_service_factory=self._collect_service_factory,
purchase_adapter_factory=self._purchase_adapter_factory,
cancelled=lambda: self._cancelled,
)
result = dispatcher.execute_one()
if not self._cancelled or result.kind == "cancelled":
self.outcome.emit(result.kind, result.message, result.task_id)
except AdminGatewayError as exc:
@@ -198,7 +178,7 @@ class ClaimTaskWorker(QObject):
self.failed.emit(message)
except Exception as exc:
if not self._cancelled:
self.failed.emit(f"执行采集任务失败:{exc}")
self.failed.emit(f"执行任务失败:{exc}")
finally:
self.completed.emit()
@@ -214,6 +194,7 @@ class PDDTaskPageEvent(QObject):
claim_gateway: Optional[AdminGateway] = None,
settings_repository: Optional[SettingsRepository] = None,
collect_service_factory: Optional[CollectServiceFactory] = None,
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
next_task_delay_ms: int = 500,
no_task_delay_ms: int = 5_000,
retry_delays_ms: tuple[int, ...] = (5_000, 10_000, 20_000, 30_000),
@@ -233,6 +214,7 @@ class PDDTaskPageEvent(QObject):
self._retry_count = 0
self._detail_windows: Dict[str, TaskDetailWindow] = {}
self._collect_service_factory = collect_service_factory
self._purchase_adapter_factory = purchase_adapter_factory
if next_task_delay_ms < 0 or no_task_delay_ms <= 0:
raise ValueError("自动获取等待时间配置无效")
if not retry_delays_ms or any(value <= 0 for value in retry_delays_ms):
@@ -476,15 +458,16 @@ class PDDTaskPageEvent(QObject):
self._claim_busy = True
self._cycle_next_delay_ms = None
self._page.set_auto_fetch_state("running")
self._page.set_engine_status("自动获取:运行中 · 正在处理一条采集任务…")
self._page.set_engine_status("自动获取:运行中 · 正在处理一条任务…")
thread = QThread(self)
worker = ClaimTaskWorker(
self._claim_gateway,
self._repository,
self._client_service,
self._selected_android_device_service,
self._collect_service_factory,
gateway=self._claim_gateway,
task_repository=self._repository,
client_service=self._client_service,
android_device_service=self._selected_android_device_service,
collect_service_factory=self._collect_service_factory,
purchase_adapter_factory=self._purchase_adapter_factory,
)
worker.moveToThread(thread)
thread.started.connect(worker.run)
@@ -630,7 +613,7 @@ class PDDTaskPageEvent(QObject):
self._show_retry_paused(content)
self._stop_after_current(content)
elif kind in {"manual_review", "failed"}:
self._show_claim_error("采集任务需要处理", message)
self._show_claim_error("任务需要处理", message)
self._stop_after_current(message)
elif kind == "cancelled":
self._stop_after_current(message or "自动获取已停止")
@@ -648,7 +631,11 @@ class PDDTaskPageEvent(QObject):
task = self._repository.get_task(task_id)
except Exception:
return False
return task is not None and task.status is TaskStatus.RETRY_WAIT
return (
task is not None
and task.task_type is TaskType.COLLECT
and task.status is TaskStatus.RETRY_WAIT
)
def _show_retry_paused(self, content: str) -> None:
"""用持久警告说明任务不会自行倒计时重试。"""
+272
View File
@@ -0,0 +1,272 @@
"""串行领取、落库并按任务类型分派一条任务。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Mapping, Optional
from .admin_gateway import (
AdminGateway,
AdminGatewayError,
AdminTask,
AndroidDeviceInfo,
ClaimCapabilities,
ClientInfo,
)
from .collect_task_service import CollectServiceFactory, CollectTaskService
from .purchase_task_service import (
PurchaseAdapterFactory,
PurchaseTaskService,
)
from .task_models import (
NewClaimedTask,
OutboxEventRecord,
OutboxEventType,
TaskType,
)
from .task_repository import DuplicateTaskError, TaskRepository
@dataclass(frozen=True)
class TaskDispatchOutcome:
"""一轮串行任务处理的结果。"""
kind: str
message: str
task_id: str = ""
def admin_task_to_new_claimed_task(task: AdminTask) -> NewClaimedTask:
"""严格校验 Admin 任务并映射为本地已领取任务。"""
payload = dict(task.payload)
goods_url = payload.get("goods_url")
if not isinstance(goods_url, str) or not goods_url.strip():
raise ValueError("payload.goods_url 不能为空")
goods_id = payload.get("goods_id")
if goods_id is not None and not isinstance(goods_id, str):
raise ValueError("payload.goods_id 必须是文本")
target_color = None
target_size = None
price_cent = None
quantity = None
if task.task_type is TaskType.PURCHASE:
if not isinstance(goods_id, str) or not goods_id.strip():
raise ValueError("采购任务 payload.goods_id 不能为空")
options = payload.get("options")
if not isinstance(options, Mapping) or not options:
raise ValueError("采购任务 payload.options 必须是非空对象")
normalized_options: dict[str, str] = {}
for key, value in options.items():
if not isinstance(key, str) or not key.strip():
raise ValueError("采购任务 options 的名称不能为空")
if not isinstance(value, str) or not value.strip():
raise ValueError("采购任务 options 的值必须是非空文本")
normalized_options[key.strip()] = value.strip()
payload["options"] = normalized_options
quantity = payload.get("quantity")
price_cent = payload.get("max_price_cent")
if (
isinstance(quantity, bool)
or not isinstance(quantity, int)
or quantity <= 0
):
raise ValueError("采购任务 payload.quantity 必须是大于 0 的整数")
if (
isinstance(price_cent, bool)
or not isinstance(price_cent, int)
or price_cent <= 0
):
raise ValueError(
"采购任务 payload.max_price_cent 必须是大于 0 的整数分"
)
target_color = normalized_options.get("color")
target_size = normalized_options.get("size")
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.strip(),
goods_id=goods_id.strip() if isinstance(goods_id, str) else None,
target_color=target_color,
target_size=target_size,
price_cent=price_cent,
quantity=quantity,
priority=task.priority,
version=task.version,
admin_payload=original_task,
)
class TaskDispatcher:
"""一次只补交、执行或领取并执行一条任务。"""
def __init__(
self,
gateway: AdminGateway,
repository: TaskRepository,
client: ClientInfo,
device_address: str,
*,
collect_service_factory: Optional[CollectServiceFactory] = None,
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
cancelled: Callable[[], bool] = lambda: False,
) -> None:
self._gateway = gateway
self._repository = repository
self._client = client
self._device_address = str(device_address or "").strip()
self._collect_factory = collect_service_factory
self._purchase_factory = purchase_adapter_factory
self._cancelled = cancelled
@property
def purchase_ready(self) -> bool:
"""只有显式提供采购演练适配器时才声明采购能力。"""
return self._purchase_factory is not None and bool(
self._device_address
)
def claim_capabilities(self) -> ClaimCapabilities:
"""集中构造不会意外开放 live 的领取能力。"""
supported = [TaskType.COLLECT]
if self.purchase_ready:
supported.append(TaskType.PURCHASE)
device = (
AndroidDeviceInfo(self._device_address)
if self._device_address
else None
)
return ClaimCapabilities(
device=device,
supported_types=tuple(supported),
purchase_mode="dry_run",
schema_versions=(1,),
)
def execute_one(self) -> TaskDispatchOutcome:
"""严格按 Outbox、本地任务、Admin 新任务的顺序处理。"""
pending = self._repository.next_pending_outbox()
if pending is not None:
return self._submit_pending(pending)
if not self._device_address:
raise ValueError("请先在设置页选择并保存 Android 设备")
if not self.purchase_ready:
pending_purchase = self._repository.next_purchase_task()
if pending_purchase is not None:
raise RuntimeError(
f"本地采购任务 {pending_purchase.remote_task_id} 等待执行,"
"但采购演练执行器未就绪;已停止领取新任务"
)
task = self._repository.next_runnable_task(
include_purchase=self.purchase_ready
)
if task is None:
remote = self._gateway.claim_next(
self._client, self.claim_capabilities()
)
if remote is None:
return TaskDispatchOutcome("no_task", "暂无可领取的任务")
try:
self._repository.add_claimed_task(
admin_task_to_new_claimed_task(remote)
)
except DuplicateTaskError:
pass
except Exception as exc:
raise RuntimeError(
f"任务 {remote.task_id} 已领取,但本地保存失败:{exc}"
) from exc
task = self._repository.get_task(remote.task_id)
if task is None:
raise RuntimeError(
f"任务 {remote.task_id} 已领取,但未能保存到本地"
)
if task.task_type is TaskType.COLLECT:
service = CollectTaskService(
self._gateway,
self._repository,
self._client,
self._device_address,
cancelled=self._cancelled,
collect_service_factory=self._collect_factory,
)
outcome = service.execute_selected(task.remote_task_id)
else:
if self._purchase_factory is None:
raise RuntimeError(
"采购演练执行器未就绪,已停止领取以避免任务卡住"
)
purchase_service = PurchaseTaskService(
self._gateway,
self._repository,
self._client,
self._device_address,
self._purchase_factory,
cancelled=self._cancelled,
)
outcome = purchase_service.execute_selected(task.remote_task_id)
return TaskDispatchOutcome(
outcome.kind, outcome.message, outcome.task_id
)
def _submit_pending(
self, event: OutboxEventRecord
) -> TaskDispatchOutcome:
"""只补交已落库事件,不重新访问 PDD 页面。"""
task_id = self._repository.outbox_task_id(event.id)
self._repository.mark_outbox_sending(event.id)
try:
if event.event_type is OutboxEventType.TASK_FAILURE:
receipt = self._gateway.submit_failure(
task_id, event.idempotency_key, event.payload_json
)
else:
receipt = self._gateway.submit_result(
task_id, event.idempotency_key, event.payload_json
)
if not receipt.accepted:
raise AdminGatewayError(
"ADMIN_RESULT_NOT_ACCEPTED",
"Admin 未确认接收任务结果",
False,
)
except AdminGatewayError as exc:
if exc.retryable:
self._repository.mark_outbox_retry(event.id, str(exc))
return TaskDispatchOutcome(
"result_pending",
f"任务 {task_id} 结果已保存在本地,等待提交 Admin:{exc}",
task_id,
)
self._repository.mark_outbox_failed(event.id, str(exc))
return TaskDispatchOutcome(
"manual_review",
f"任务 {task_id} 结果被 Admin 拒绝:{exc}",
task_id,
)
self._repository.mark_outbox_sent(event.id)
if event.event_type is OutboxEventType.TASK_FAILURE:
return TaskDispatchOutcome(
"failed", f"任务 {task_id} 失败信息已提交 Admin", task_id
)
return TaskDispatchOutcome(
"succeeded", f"任务 {task_id} 结果已提交 Admin", task_id
)
+26
View File
@@ -224,6 +224,32 @@ class TaskRepository:
connection.close()
return self._to_detail(row) if row is not None else None
def next_runnable_task(
self, *, include_purchase: bool
) -> Optional[TaskDetail]:
"""按领取顺序返回当前执行器能够处理的最早本地任务。"""
if include_purchase:
where = (
"((task_type = 'collect'"
" AND status IN ('claimed', 'retry_wait'))"
" OR (task_type = 'purchase' AND status = 'claimed'))"
)
else:
where = (
"task_type = 'collect'"
" AND status IN ('claimed', 'retry_wait')"
)
connection = open_database(self._db_path)
try:
row = connection.execute(
f"SELECT * FROM pdd_tasks WHERE {where}"
" ORDER BY received_at ASC, id ASC LIMIT 1"
).fetchone()
finally:
connection.close()
return self._to_detail(row) if row is not None else None
def validate_collect_rerun(self, remote_task_id: str) -> TaskDetail:
"""校验任务能否重新采集,成功时返回任务详情。"""