feat: 执行并提交 PDD 采集任务 (#32)

This commit is contained in:
chengma
2026-08-07 17:38:29 +08:00
parent 34b4b54b63
commit 2d1fdbea3f
17 changed files with 2156 additions and 108 deletions
+107 -5
View File
@@ -3,20 +3,20 @@
import json
import socket
from http.client import RemoteDisconnected
from typing import Callable, Mapping, Optional
from typing import Any, 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 (
AdminGateway,
AdminTask,
AdminGatewayError,
ClaimCapabilities,
ClientInfo,
ClientRegistrationGateway,
RegistrationReceipt,
TaskClaimGateway,
SubmissionReceipt,
)
from .task_models import TaskType
@@ -24,8 +24,8 @@ from .task_models import TaskType
DEFAULT_ADMIN_BASE_URL = "http://127.0.0.1:8080"
class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway):
"""通过 HTTP 登记 Client 和领取采集任务;令牌只保存在内存。"""
class HttpAdminGateway(AdminGateway):
"""通过 HTTP 登记、领取和提交任务;令牌只保存在内存。"""
def __init__(
self,
@@ -33,6 +33,7 @@ class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway):
token: str = "",
timeout_seconds: float = 3.0,
opener: Optional[Callable] = None,
client_id: str = "",
):
normalized_url = base_url.strip().rstrip("/")
parsed = urlparse(normalized_url)
@@ -44,6 +45,7 @@ class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway):
self._base_url = normalized_url
self._token = token.strip()
self._timeout_seconds = timeout_seconds
self._client_id = client_id.strip()
# Admin 通常运行在本机或局域网。明确禁用环境代理,避免
# HTTP_PROXY 把 127.0.0.1 请求错误转发到代理服务器。
self._opener = opener or build_opener(ProxyHandler({})).open
@@ -54,6 +56,7 @@ class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway):
"""调用独立登记接口,不领取或修改任务。"""
request_id = str(uuid4())
self._client_id = client.client_id.strip()
payload = {
"client": {"name": client.name.strip()},
"supported_types": [
@@ -146,6 +149,7 @@ class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway):
"""领取一个采集任务;Admin 返回 204 时返回 ``None``。"""
request_id = str(uuid4())
self._client_id = client.client_id.strip()
payload = {
"client": {"name": client.name.strip()},
# #30 只允许领取采集任务。采购能力必须由安全门禁工单开启。
@@ -219,6 +223,104 @@ class HttpAdminGateway(ClientRegistrationGateway, TaskClaimGateway):
)
return self._parse_claim_response(body, request_id)
def submit_result(
self,
task_id: str,
idempotency_key: str,
result: Mapping[str, Any],
) -> SubmissionReceipt:
"""幂等提交成功结果。"""
return self._submit(task_id, idempotency_key, result, "result")
def submit_failure(
self,
task_id: str,
idempotency_key: str,
failure: Mapping[str, Any],
) -> SubmissionReceipt:
"""幂等提交失败或人工处理结果。"""
return self._submit(task_id, idempotency_key, failure, "failure")
def _submit(
self,
task_id: str,
idempotency_key: str,
payload: Mapping[str, Any],
endpoint: str,
) -> SubmissionReceipt:
if not task_id.strip():
raise ValueError("task_id 不能为空")
if not idempotency_key.strip():
raise ValueError("Idempotency-Key 不能为空")
if not self._client_id:
raise AdminGatewayError(
"CLIENT_ID_MISSING",
"提交结果前必须先配置 Client 设备号",
False,
)
request_id = str(uuid4())
headers = {
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json",
"Idempotency-Key": idempotency_key.strip(),
"X-Request-Id": request_id,
"X-Client-Id": self._client_id,
}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
request = Request(
f"{self._base_url}/api/v1/client/tasks/{task_id.strip()}/{endpoint}",
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)
code = "ADMIN_TIMEOUT" if isinstance(reason, (socket.timeout, TimeoutError)) else "ADMIN_UNAVAILABLE"
message = "Admin 提交请求超时,请稍后重试" if code == "ADMIN_TIMEOUT" else "无法连接 Admin,结果已保存在本地"
raise AdminGatewayError(code, message, True, request_id) from exc
if status not in (200, 201):
raise AdminGatewayError(
"ADMIN_UNEXPECTED_RESPONSE",
f"Admin 返回了未预期的状态码 {status}",
status >= 500,
request_id,
)
data = self._decode_json(body, request_id)
accepted = data.get("accepted")
result_id = data.get("result_id")
accepted_at = data.get("accepted_at")
if (
accepted is not True
or not isinstance(result_id, str)
or not result_id.strip()
or not isinstance(accepted_at, str)
or not accepted_at.strip()
):
raise AdminGatewayError(
"ADMIN_INVALID_RESPONSE",
"Admin 提交响应字段不完整",
False,
request_id,
)
return SubmissionReceipt(True, result_id, accepted_at)
@classmethod
def _parse_claim_response(
cls,