feat: 安全应用采购规格解析结果 (#257)
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
from http.client import RemoteDisconnected
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
@@ -17,6 +18,8 @@ from .admin_gateway import (
|
||||
ClaimCapabilities,
|
||||
ClientInfo,
|
||||
RegistrationReceipt,
|
||||
SpecResolutionMatch,
|
||||
SpecResolutionReceipt,
|
||||
SubmissionReceipt,
|
||||
)
|
||||
from .task_models import TaskType
|
||||
@@ -254,6 +257,148 @@ class HttpAdminGateway(AdminGateway):
|
||||
|
||||
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=self._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,
|
||||
|
||||
Reference in New Issue
Block a user