fix: 对齐规格解析 Mock 契约 (#257)
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -195,8 +197,12 @@ class MockAdminGateway(AdminGateway):
|
|||||||
) -> SpecResolutionReceipt:
|
) -> SpecResolutionReceipt:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._raise_forced_error()
|
self._raise_forced_error()
|
||||||
task = self._validate_submission_target(task_id, idempotency_key)
|
self._validate_spec_observation(
|
||||||
self._validate_spec_observation(task, idempotency_key, observation)
|
task_id, idempotency_key, observation
|
||||||
|
)
|
||||||
|
task = self._validate_spec_resolution_target(
|
||||||
|
task_id, observation
|
||||||
|
)
|
||||||
fingerprint = self._fingerprint(
|
fingerprint = self._fingerprint(
|
||||||
"spec_resolution", task_id, observation
|
"spec_resolution", task_id, observation
|
||||||
)
|
)
|
||||||
@@ -233,42 +239,59 @@ class MockAdminGateway(AdminGateway):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_spec_observation(
|
def _validate_spec_observation(
|
||||||
task: AdminTask,
|
task_id: str,
|
||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
observation: Mapping[str, Any],
|
observation: Mapping[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
if task.task_type.value != "purchase":
|
try:
|
||||||
|
encoded = json.dumps(
|
||||||
|
observation,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
raise AdminGatewayError(
|
raise AdminGatewayError(
|
||||||
"TASK_NOT_PURCHASE", "当前任务不是采购任务", False
|
"INVALID_BODY", "规格解析请求不是有效 JSON", False
|
||||||
|
) from exc
|
||||||
|
if len(encoded) > 64 * 1024:
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"INVALID_BODY", "规格解析请求超过 64 KiB", False
|
||||||
)
|
)
|
||||||
if observation.get("schema_version") != 1:
|
if observation.get("schema_version") != 1:
|
||||||
raise AdminGatewayError(
|
raise AdminGatewayError(
|
||||||
"INVALID_SPEC_RESOLUTION_SCHEMA", "规格解析版本无效", False
|
"INVALID_SPEC_RESOLUTION_SCHEMA", "规格解析版本无效", False
|
||||||
)
|
)
|
||||||
if observation.get("task_version") != task.version:
|
task_version = observation.get("task_version")
|
||||||
raise AdminGatewayError(
|
original_options = observation.get("original_options")
|
||||||
"TASK_VERSION_CONFLICT", "规格解析任务版本不一致", False
|
|
||||||
)
|
|
||||||
payload = task.payload
|
|
||||||
if observation.get("pdd_goods_id") != payload.get("goods_id"):
|
|
||||||
raise AdminGatewayError(
|
|
||||||
"PDD_GOODS_MISMATCH", "规格解析商品编号不一致", False
|
|
||||||
)
|
|
||||||
if dict(observation.get("original_options") or {}) != dict(
|
|
||||||
payload.get("options") or {}
|
|
||||||
):
|
|
||||||
raise AdminGatewayError(
|
|
||||||
"INVALID_SPEC_RESOLUTION_REQUEST", "原始规格不一致", False
|
|
||||||
)
|
|
||||||
candidates = observation.get("candidates")
|
candidates = observation.get("candidates")
|
||||||
snapshot_hash = observation.get("candidate_snapshot_hash")
|
snapshot_hash = observation.get("candidate_snapshot_hash")
|
||||||
selected_color = observation.get("selected_color")
|
selected_color = observation.get("selected_color")
|
||||||
|
target_size = observation.get("target_size")
|
||||||
|
observed_at = observation.get("observed_at")
|
||||||
if (
|
if (
|
||||||
not isinstance(candidates, list)
|
not isinstance(task_version, int)
|
||||||
|
or isinstance(task_version, bool)
|
||||||
|
or task_version <= 0
|
||||||
|
or not MockAdminGateway._valid_spec_text(
|
||||||
|
observation.get("attempt_id")
|
||||||
|
)
|
||||||
|
or not MockAdminGateway._valid_spec_text(
|
||||||
|
observation.get("pdd_goods_id")
|
||||||
|
)
|
||||||
|
or not MockAdminGateway._valid_spec_text(selected_color)
|
||||||
|
or not MockAdminGateway._valid_spec_text(target_size)
|
||||||
|
or not isinstance(original_options, Mapping)
|
||||||
|
or not 1 <= len(original_options) <= 16
|
||||||
|
or any(
|
||||||
|
not MockAdminGateway._valid_spec_text(key)
|
||||||
|
or not MockAdminGateway._valid_spec_text(value)
|
||||||
|
for key, value in original_options.items()
|
||||||
|
)
|
||||||
|
or not isinstance(candidates, list)
|
||||||
or not 1 <= len(candidates) <= 100
|
or not 1 <= len(candidates) <= 100
|
||||||
or not isinstance(snapshot_hash, str)
|
or not isinstance(snapshot_hash, str)
|
||||||
or len(snapshot_hash) != 64
|
or re.fullmatch(r"[0-9a-f]{64}", snapshot_hash) is None
|
||||||
or not str(observation.get("attempt_id") or "").strip()
|
or not MockAdminGateway._valid_observed_at(observed_at)
|
||||||
):
|
):
|
||||||
raise AdminGatewayError(
|
raise AdminGatewayError(
|
||||||
"INVALID_SPEC_RESOLUTION_REQUEST", "规格候选结构无效", False
|
"INVALID_SPEC_RESOLUTION_REQUEST", "规格候选结构无效", False
|
||||||
@@ -276,8 +299,9 @@ class MockAdminGateway(AdminGateway):
|
|||||||
if any(
|
if any(
|
||||||
not isinstance(candidate, Mapping)
|
not isinstance(candidate, Mapping)
|
||||||
or candidate.get("candidate_id") != f"c{index}"
|
or candidate.get("candidate_id") != f"c{index}"
|
||||||
or not isinstance(candidate.get("raw_text"), str)
|
or not MockAdminGateway._valid_spec_text(
|
||||||
or not candidate["raw_text"]
|
candidate.get("raw_text")
|
||||||
|
)
|
||||||
or dict(candidate.get("options") or {})
|
or dict(candidate.get("options") or {})
|
||||||
!= {
|
!= {
|
||||||
"color": selected_color,
|
"color": selected_color,
|
||||||
@@ -319,7 +343,7 @@ class MockAdminGateway(AdminGateway):
|
|||||||
identity = "".join(
|
identity = "".join(
|
||||||
MockAdminGateway._frame(str(value))
|
MockAdminGateway._frame(str(value))
|
||||||
for value in (
|
for value in (
|
||||||
task.task_id,
|
task_id,
|
||||||
observation["attempt_id"],
|
observation["attempt_id"],
|
||||||
snapshot_hash,
|
snapshot_hash,
|
||||||
"spec-resolution-v1",
|
"spec-resolution-v1",
|
||||||
@@ -333,6 +357,88 @@ class MockAdminGateway(AdminGateway):
|
|||||||
"SPEC_RESOLUTION_HASH_MISMATCH", "规格解析幂等键不一致", False
|
"SPEC_RESOLUTION_HASH_MISMATCH", "规格解析幂等键不一致", False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _validate_spec_resolution_target(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
observation: Mapping[str, Any],
|
||||||
|
) -> AdminTask:
|
||||||
|
item = self._find_task(task_id)
|
||||||
|
if item is None:
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"TASK_NOT_FOUND", "规格解析任务不存在", False
|
||||||
|
)
|
||||||
|
task = item.task
|
||||||
|
if task.task_type.value != "purchase":
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"TASK_NOT_PURCHASE", "当前任务不是采购任务", False
|
||||||
|
)
|
||||||
|
if observation.get("task_version") != task.version:
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"TASK_VERSION_CONFLICT", "规格解析任务版本不一致", False
|
||||||
|
)
|
||||||
|
payload = task.payload
|
||||||
|
if observation.get("pdd_goods_id") != payload.get("goods_id"):
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"PDD_GOODS_MISMATCH", "规格解析商品编号不一致", False
|
||||||
|
)
|
||||||
|
original_options = dict(observation.get("original_options") or {})
|
||||||
|
if (
|
||||||
|
original_options != dict(payload.get("options") or {})
|
||||||
|
or not MockAdminGateway._target_option_was_claimed(
|
||||||
|
original_options,
|
||||||
|
"color",
|
||||||
|
str(observation.get("selected_color") or ""),
|
||||||
|
)
|
||||||
|
or not MockAdminGateway._target_option_was_claimed(
|
||||||
|
original_options,
|
||||||
|
"size",
|
||||||
|
str(observation.get("target_size") or ""),
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"INVALID_SPEC_RESOLUTION_REQUEST", "原始规格不一致", False
|
||||||
|
)
|
||||||
|
if task_id not in self._claimed_task_ids:
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"TASK_NOT_CLAIMED_BY_CLIENT",
|
||||||
|
"该 Client 从未领取过此任务",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _target_option_was_claimed(
|
||||||
|
options: Mapping[str, Any], preferred_key: str, target: str
|
||||||
|
) -> bool:
|
||||||
|
if preferred_key in options:
|
||||||
|
return options[preferred_key] == target
|
||||||
|
return target in options.values()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _valid_spec_text(value: object) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(value, str)
|
||||||
|
and 1 <= len(value) <= 191
|
||||||
|
and not any(
|
||||||
|
unicodedata.category(character) == "Cc"
|
||||||
|
for character in value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _valid_observed_at(value: object) -> bool:
|
||||||
|
if not isinstance(value, str) or re.fullmatch(
|
||||||
|
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"
|
||||||
|
r"(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})",
|
||||||
|
value,
|
||||||
|
) is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return parsed.tzinfo is not None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _frame(value: str) -> str:
|
def _frame(value: str) -> str:
|
||||||
return f"{len(value.encode('utf-8'))}:{value}"
|
return f"{len(value.encode('utf-8'))}:{value}"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import unittest
|
import unittest
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from src.admin_gateway import (
|
from src.admin_gateway import (
|
||||||
AdminGateway,
|
AdminGateway,
|
||||||
@@ -17,6 +18,63 @@ from src.mock_admin_gateway import MockAdminGateway
|
|||||||
from src.task_models import TaskType
|
from src.task_models import TaskType
|
||||||
|
|
||||||
|
|
||||||
|
def _frame(value: str) -> str:
|
||||||
|
return f"{len(value.encode('utf-8'))}:{value}"
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_spec_observation(task_id: str, observation: dict) -> str:
|
||||||
|
candidates = observation["candidates"]
|
||||||
|
snapshot_values = [
|
||||||
|
"spec-resolution-v1",
|
||||||
|
observation["pdd_goods_id"],
|
||||||
|
observation["selected_color"],
|
||||||
|
str(len(candidates)),
|
||||||
|
]
|
||||||
|
for candidate in candidates:
|
||||||
|
snapshot_values.extend(
|
||||||
|
(
|
||||||
|
candidate["candidate_id"],
|
||||||
|
candidate["raw_text"],
|
||||||
|
candidate["options"]["color"],
|
||||||
|
candidate["options"]["size"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
observation["candidate_snapshot_hash"] = hashlib.sha256(
|
||||||
|
"".join(_frame(value) for value in snapshot_values).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
identity_values = (
|
||||||
|
task_id,
|
||||||
|
observation["attempt_id"],
|
||||||
|
observation["candidate_snapshot_hash"],
|
||||||
|
"spec-resolution-v1",
|
||||||
|
)
|
||||||
|
return "spec-resolution-v1:" + hashlib.sha256(
|
||||||
|
"".join(_frame(value) for value in identity_values).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_observation(task_id: str = "PUR-SPEC") -> tuple[dict, str]:
|
||||||
|
observation = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"task_version": 3,
|
||||||
|
"attempt_id": "attempt-001",
|
||||||
|
"pdd_goods_id": "737116531267",
|
||||||
|
"original_options": {"color": "黑色", "size": "60公斤"},
|
||||||
|
"selected_color": "黑色",
|
||||||
|
"target_size": "60公斤",
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"candidate_id": "c1",
|
||||||
|
"raw_text": "120斤",
|
||||||
|
"options": {"color": "黑色", "size": "120斤"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"candidate_snapshot_hash": "",
|
||||||
|
"observed_at": "2026-08-17T08:00:00Z",
|
||||||
|
}
|
||||||
|
return observation, _finalize_spec_observation(task_id, observation)
|
||||||
|
|
||||||
|
|
||||||
class MockAdminGatewayContractTest(unittest.TestCase):
|
class MockAdminGatewayContractTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.gateway = MockAdminGateway()
|
self.gateway = MockAdminGateway()
|
||||||
@@ -252,6 +310,115 @@ class MockAdminGatewayContractTest(unittest.TestCase):
|
|||||||
self.assertEqual(second, configured)
|
self.assertEqual(second, configured)
|
||||||
self.assertEqual(self.gateway.spec_resolution_count, 1)
|
self.assertEqual(self.gateway.spec_resolution_count, 1)
|
||||||
|
|
||||||
|
def test_spec_resolution_validation_matches_admin_contract(self):
|
||||||
|
task_id = "PUR-SPEC-VALIDATION"
|
||||||
|
task = AdminTask(
|
||||||
|
task_id=task_id,
|
||||||
|
task_type=TaskType.PURCHASE,
|
||||||
|
version=3,
|
||||||
|
priority=1,
|
||||||
|
payload={
|
||||||
|
"goods_id": "737116531267",
|
||||||
|
"options": {"color": "黑色", "size": "60公斤"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.gateway.enqueue_task(task, self.client.client_id)
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
original, _key = _spec_observation(task_id)
|
||||||
|
|
||||||
|
cases = []
|
||||||
|
|
||||||
|
wrong_color = deepcopy(original)
|
||||||
|
wrong_color["selected_color"] = "白色"
|
||||||
|
wrong_color["candidates"][0]["options"]["color"] = "白色"
|
||||||
|
cases.append((
|
||||||
|
"已选颜色不是领取规格",
|
||||||
|
wrong_color,
|
||||||
|
_finalize_spec_observation(task_id, wrong_color),
|
||||||
|
"INVALID_SPEC_RESOLUTION_REQUEST",
|
||||||
|
))
|
||||||
|
|
||||||
|
wrong_size = deepcopy(original)
|
||||||
|
wrong_size["target_size"] = "70公斤"
|
||||||
|
cases.append((
|
||||||
|
"目标尺码不是领取规格",
|
||||||
|
wrong_size,
|
||||||
|
_finalize_spec_observation(task_id, wrong_size),
|
||||||
|
"INVALID_SPEC_RESOLUTION_REQUEST",
|
||||||
|
))
|
||||||
|
|
||||||
|
missing_timezone = deepcopy(original)
|
||||||
|
missing_timezone["observed_at"] = "2026-08-17T08:00:00"
|
||||||
|
cases.append((
|
||||||
|
"观测时间没有时区",
|
||||||
|
missing_timezone,
|
||||||
|
_finalize_spec_observation(task_id, missing_timezone),
|
||||||
|
"INVALID_SPEC_RESOLUTION_REQUEST",
|
||||||
|
))
|
||||||
|
|
||||||
|
oversized_attempt = deepcopy(original)
|
||||||
|
oversized_attempt["attempt_id"] = "a" * 192
|
||||||
|
cases.append((
|
||||||
|
"执行尝试编号超长",
|
||||||
|
oversized_attempt,
|
||||||
|
_finalize_spec_observation(task_id, oversized_attempt),
|
||||||
|
"INVALID_SPEC_RESOLUTION_REQUEST",
|
||||||
|
))
|
||||||
|
|
||||||
|
control_character = deepcopy(original)
|
||||||
|
control_character["target_size"] = "60\n公斤"
|
||||||
|
cases.append((
|
||||||
|
"字段包含控制字符",
|
||||||
|
control_character,
|
||||||
|
_finalize_spec_observation(task_id, control_character),
|
||||||
|
"INVALID_SPEC_RESOLUTION_REQUEST",
|
||||||
|
))
|
||||||
|
|
||||||
|
oversized_body = deepcopy(original)
|
||||||
|
oversized_body["future_padding"] = "x" * (64 * 1024)
|
||||||
|
cases.append((
|
||||||
|
"请求体超过上限",
|
||||||
|
oversized_body,
|
||||||
|
_finalize_spec_observation(task_id, oversized_body),
|
||||||
|
"INVALID_BODY",
|
||||||
|
))
|
||||||
|
|
||||||
|
for name, request, key, expected_code in cases:
|
||||||
|
with self.subTest(name=name):
|
||||||
|
with self.assertRaises(AdminGatewayError) as raised:
|
||||||
|
self.gateway.resolve_purchase_spec(task_id, key, request)
|
||||||
|
self.assertEqual(raised.exception.code, expected_code)
|
||||||
|
self.assertFalse(raised.exception.retryable)
|
||||||
|
|
||||||
|
def test_spec_resolution_uses_admin_task_and_claim_error_codes(self):
|
||||||
|
observation, key = _spec_observation("PUR-MISSING")
|
||||||
|
with self.assertRaises(AdminGatewayError) as missing:
|
||||||
|
self.gateway.resolve_purchase_spec(
|
||||||
|
"PUR-MISSING", key, observation
|
||||||
|
)
|
||||||
|
self.assertEqual(missing.exception.code, "TASK_NOT_FOUND")
|
||||||
|
|
||||||
|
task_id = "PUR-UNCLAIMED"
|
||||||
|
self.gateway.enqueue_task(
|
||||||
|
AdminTask(
|
||||||
|
task_id=task_id,
|
||||||
|
task_type=TaskType.PURCHASE,
|
||||||
|
version=3,
|
||||||
|
priority=1,
|
||||||
|
payload={
|
||||||
|
"goods_id": "737116531267",
|
||||||
|
"options": {"color": "黑色", "size": "60公斤"},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
self.client.client_id,
|
||||||
|
)
|
||||||
|
observation, key = _spec_observation(task_id)
|
||||||
|
with self.assertRaises(AdminGatewayError) as unclaimed:
|
||||||
|
self.gateway.resolve_purchase_spec(task_id, key, observation)
|
||||||
|
self.assertEqual(
|
||||||
|
unclaimed.exception.code, "TASK_NOT_CLAIMED_BY_CLIENT"
|
||||||
|
)
|
||||||
|
|
||||||
def test_same_idempotency_key_and_content_reuses_receipt(self):
|
def test_same_idempotency_key_and_content_reuses_receipt(self):
|
||||||
self.gateway.enqueue_task(
|
self.gateway.enqueue_task(
|
||||||
self._task("TASK-001", TaskType.COLLECT), "client-001"
|
self._task("TASK-001", TaskType.COLLECT), "client-001"
|
||||||
|
|||||||
Reference in New Issue
Block a user