Files
cmautobuy/client/src/http_admin_gateway.py
T

523 lines
18 KiB
Python

"""使用 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