"""使用 Python 标准库调用 Admin 登记、领取和提交接口。""" import hashlib import json import socket from http.client import RemoteDisconnected 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, RegistrationReceipt, SubmissionReceipt, ) from .task_models import TaskType DEFAULT_ADMIN_BASE_URL = "http://127.0.0.1:8080" class HttpAdminGateway(AdminGateway): """通过 HTTP 登记、领取和提交任务;令牌只保存在内存。""" def __init__( self, base_url: str = DEFAULT_ADMIN_BASE_URL, token: str = "", timeout_seconds: float = 3.0, opener: Optional[Callable] = None, client_id: str = "", ): 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 self._client_id = client_id.strip() # 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()) self._client_id = client.client_id.strip() 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"], ) def claim_next( self, client: ClientInfo, capabilities: ClaimCapabilities, ) -> Optional[AdminTask]: """按调用方已安全确认的能力领取一个任务。""" request_id = str(uuid4()) self._client_id = client.client_id.strip() 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/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) 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") legacy_failure = ( endpoint == "failure" and accepted is True and isinstance(accepted_at, str) and bool(accepted_at.strip()) and isinstance(data.get("task_status"), str) and bool(data["task_status"].strip()) and not (isinstance(result_id, str) and result_id.strip()) ) if legacy_failure: digest = hashlib.sha256( idempotency_key.encode("utf-8") ).hexdigest()[:16] return SubmissionReceipt( True, f"legacy-failure-{digest}", 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 返回成功状态,但响应格式异常,数据可能已接收,将安全重试", True, request_id, ) return SubmissionReceipt(True, result_id, accepted_at) @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 cls._validate_claim_payload(task_type, task_payload, request_id) 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 _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: 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, operation: 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 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") if not isinstance(extra, dict): extra = {} except (AdminGatewayError, ValueError): code = "ADMIN_HTTP_ERROR" message = f"Admin {operation}失败,状态码 {error.code}" retryable = error.code >= 500 response_request_id = request_id extra = {} raise AdminGatewayError( code, message, retryable, response_request_id, extra, ) from error