Files
cmautobuy/client/src/http_admin_gateway.py
T

694 lines
24 KiB
Python
Raw Normal View History

"""使用 Python 标准库调用 Admin 登记、领取和提交接口。"""
2026-08-06 17:57:49 +08:00
import hashlib
2026-08-06 17:57:49 +08:00
import json
import re
2026-08-06 17:57:49 +08:00
import socket
from http.client import RemoteDisconnected
from typing import Any, Callable, Mapping, Optional
2026-08-06 17:57:49 +08:00
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,
2026-08-06 17:57:49 +08:00
AdminGatewayError,
ClaimCapabilities,
ClientInfo,
RegistrationReceipt,
SpecResolutionMatch,
SpecResolutionReceipt,
SubmissionReceipt,
2026-08-06 17:57:49 +08:00
)
from .task_models import TaskType
2026-08-06 17:57:49 +08:00
DEFAULT_ADMIN_BASE_URL = "https://buy.833729.com"
PURCHASE_SPEC_RESOLUTION_TIMEOUT_SECONDS = 132.0
2026-08-06 17:57:49 +08:00
class HttpAdminGateway(AdminGateway):
"""通过 HTTP 登记、领取和提交任务;令牌只保存在内存。"""
2026-08-06 17:57:49 +08:00
def __init__(
self,
base_url: str = DEFAULT_ADMIN_BASE_URL,
token: str = "",
timeout_seconds: float = 3.0,
opener: Optional[Callable] = None,
client_id: str = "",
2026-08-06 17:57:49 +08:00
):
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 (
parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError(
"Admin 服务地址不能包含账号、密码、查询参数或片段"
)
2026-08-06 17:57:49 +08:00
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()
2026-08-06 17:57:49 +08:00
# 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()
2026-08-06 17:57:49 +08:00
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 resolve_purchase_spec(
self,
task_id: str,
idempotency_key: str,
observation: Mapping[str, Any],
) -> SpecResolutionReceipt:
"""发送一次已持久化的规格解析命令并严格校验响应。"""
if not task_id.strip() or not idempotency_key.strip():
raise ValueError("规格解析的 task_id 和 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()}/spec-resolution",
data=json.dumps(observation, ensure_ascii=False).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with self._opener(
request,
timeout=PURCHASE_SPEC_RESOLUTION_TIMEOUT_SECONDS,
) as response:
status = getattr(response, "status", None) or 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"
)
raise AdminGatewayError(
code,
"Admin 规格解析超时" if code == "ADMIN_TIMEOUT" else "无法连接 Admin 规格解析服务",
True,
request_id,
) from exc
if status != 200:
raise AdminGatewayError(
"ADMIN_UNEXPECTED_RESPONSE",
f"Admin 规格解析返回了未预期的状态码 {status}",
status >= 500,
request_id,
)
return self._parse_spec_resolution_response(body, request_id)
@classmethod
def _parse_spec_resolution_response(
cls, body: bytes, request_id: str
) -> SpecResolutionReceipt:
data = cls._decode_json(body, request_id)
schema_version = data.get("schema_version")
resolution_id = data.get("resolution_id")
outcome = data.get("outcome")
source = data.get("source")
snapshot_hash = data.get("candidate_snapshot_hash")
confidence = data.get("confidence_bps")
reason = data.get("reason")
resolved_at = data.get("resolved_at")
match_data = data.get("match")
valid = (
schema_version == 1
and isinstance(resolution_id, str)
and bool(resolution_id.strip())
and outcome in {"matched", "uncertain", "rejected", "failed"}
and (source is None or source in {"rule", "ai", "reused"})
and isinstance(snapshot_hash, str)
and re.fullmatch(r"[0-9a-f]{64}", snapshot_hash) is not None
and (confidence is None or (
isinstance(confidence, int)
and not isinstance(confidence, bool)
and 0 <= confidence <= 10000
))
and isinstance(reason, str)
and len(reason) <= 500
and isinstance(resolved_at, str)
and bool(resolved_at.strip())
)
match = None
if outcome == "matched" and isinstance(match_data, Mapping):
candidate_id = match_data.get("candidate_id")
raw_text = match_data.get("raw_text")
options = match_data.get("options")
if (
isinstance(candidate_id, str)
and bool(candidate_id.strip())
and isinstance(raw_text, str)
and bool(raw_text)
and isinstance(options, Mapping)
and set(options) == {"color", "size"}
and all(
isinstance(value, str) and bool(value)
for value in options.values()
)
and options.get("size") == raw_text
):
match = SpecResolutionMatch(
candidate_id,
raw_text,
{str(key): str(value) for key, value in options.items()},
)
if (
not valid
or (outcome == "matched") != (match is not None)
or (outcome != "matched" and match_data is not None)
):
raise AdminGatewayError(
"ADMIN_INVALID_RESPONSE",
"Admin 规格解析响应字段不完整或不合法",
False,
request_id,
)
return SpecResolutionReceipt(
1,
resolution_id,
outcome,
source,
snapshot_hash,
match,
confidence,
reason,
resolved_at,
)
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")
execution_mode = task.get("execution_mode", "dry_run")
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(execution_mode, str)
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
if execution_mode not in {"dry_run", "live"} or (
task_type is not TaskType.PURCHASE and execution_mode != "dry_run"
):
raise AdminGatewayError(
"ADMIN_INVALID_RESPONSE",
"Admin 返回了不支持的任务执行模式",
False,
request_id,
)
cls._validate_claim_payload(task_type, task_payload, request_id)
return AdminTask(
task_id=task_id,
task_type=task_type,
version=version,
priority=priority,
execution_mode=execution_mode,
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,
)
2026-08-06 17:57:49 +08:00
@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:
2026-08-06 17:57:49 +08:00
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}请求")
2026-08-06 17:57:49 +08:00
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}"
2026-08-06 17:57:49 +08:00
retryable = error.code >= 500
response_request_id = request_id
extra = {}
raise AdminGatewayError(
code,
message,
retryable,
response_request_id,
extra,
) from error