feat(client): add Admin Gateway mock contract (#10)

This commit is contained in:
chengma
2026-08-06 16:38:28 +08:00
parent 799ee33045
commit c412e8afe2
3 changed files with 647 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
"""Client 访问 Admin 的稳定边界和简单数据对象。
AdminGateway 只有领取任务、提交成功结果、提交失败结果三个业务方法。
业务层不应直接依赖 HTTP 请求或 Mock 的内部实现。
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Mapping, Optional, Tuple
from .task_models import TaskType
@dataclass(frozen=True)
class ClientInfo:
"""发起领取请求的 Client 身份,不保存访问令牌。"""
client_id: str
def __post_init__(self) -> None:
if not self.client_id.strip():
raise ValueError("client_id 不能为空")
@dataclass(frozen=True)
class AndroidDeviceInfo:
"""领取任务时上报的 Android 设备信息。"""
address: str
platform: str = "android"
pdd_package: str = "com.xunmeng.pinduoduo"
def __post_init__(self) -> None:
if not self.address.strip():
raise ValueError("设备地址不能为空")
if self.platform != "android":
raise ValueError("当前只支持 android 平台")
if not self.pdd_package.strip():
raise ValueError("PDD 包名不能为空")
@dataclass(frozen=True)
class ClaimCapabilities:
"""Client 领取任务时声明的设备与执行能力。"""
device: AndroidDeviceInfo
supported_types: Tuple[TaskType, ...] = (
TaskType.COLLECT,
TaskType.PURCHASE,
)
purchase_mode: str = "dry_run"
schema_versions: Tuple[int, ...] = (1,)
def __post_init__(self) -> None:
if not self.supported_types:
raise ValueError("supported_types 不能为空")
if any(not isinstance(value, TaskType) for value in self.supported_types):
raise ValueError("supported_types 必须使用 TaskType")
if self.purchase_mode not in {"dry_run", "live"}:
raise ValueError("purchase_mode 只能是 dry_run 或 live")
if not self.schema_versions or any(
version <= 0 for version in self.schema_versions
):
raise ValueError("schema_versions 必须是正整数")
@dataclass(frozen=True)
class AdminTask:
"""Admin 派发给 Client 的一个任务。"""
task_id: str
task_type: TaskType
version: int
priority: int
payload: Mapping[str, Any] = field(default_factory=dict)
created_at: str = ""
updated_at: str = ""
def __post_init__(self) -> None:
if not self.task_id.strip():
raise ValueError("task_id 不能为空")
if not isinstance(self.task_type, TaskType):
raise ValueError("task_type 必须使用 TaskType")
if self.version <= 0:
raise ValueError("version 必须大于 0")
if not isinstance(self.payload, Mapping):
raise ValueError("payload 必须是对象")
@dataclass(frozen=True)
class SubmissionReceipt:
"""Admin 已接收并保存一次提交的确认。"""
accepted: bool
result_id: str
accepted_at: str
class AdminGatewayError(RuntimeError):
"""带稳定错误代码和可重试标志的 Admin 边界错误。"""
def __init__(
self,
code: str,
message: str,
retryable: bool,
request_id: str = "",
details: Optional[Mapping[str, Any]] = None,
):
super().__init__(message)
self.code = code
self.retryable = retryable
self.request_id = request_id
self.details = dict(details or {})
class AdminGateway(ABC):
"""Admin 边界;不得增加状态查询、心跳或租约方法。"""
@abstractmethod
def claim_next(
self, client: ClientInfo, capabilities: ClaimCapabilities
) -> Optional[AdminTask]:
"""领取至多一个分配给当前 Client 的任务。"""
@abstractmethod
def submit_result(
self,
task_id: str,
idempotency_key: str,
result: Mapping[str, Any],
) -> SubmissionReceipt:
"""幂等提交采集或采购成功结果。"""
@abstractmethod
def submit_failure(
self,
task_id: str,
idempotency_key: str,
failure: Mapping[str, Any],
) -> SubmissionReceipt:
"""幂等提交失败、取消或人工处理结果。"""
+285
View File
@@ -0,0 +1,285 @@
"""不访问网络的 AdminGateway 测试实现。"""
import hashlib
import json
from copy import deepcopy
from dataclasses import dataclass
from datetime import datetime, timezone
from threading import Lock
from typing import Any, Dict, List, Mapping, Optional, Tuple
from uuid import uuid4
from .admin_gateway import (
AdminGateway,
AdminGatewayError,
AdminTask,
ClaimCapabilities,
ClientInfo,
SubmissionReceipt,
)
FAILURE_STATUSES = {"retry_wait", "manual_review", "failed", "cancelled"}
def utc_now_iso() -> str:
"""返回精确到秒的 UTC 时间。"""
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
"+00:00", "Z"
)
@dataclass
class _QueuedTask:
task: AdminTask
assigned_client_id: str
claimed: bool = False
cancelled: bool = False
class MockAdminGateway(AdminGateway):
"""支持任务派发、故障模拟和幂等提交的内存 Mock。"""
def __init__(self):
self._tasks: List[_QueuedTask] = []
self._claimed_task_ids = set()
self._submissions: Dict[
str, Tuple[str, SubmissionReceipt]
] = {}
self._next_error: Optional[AdminGatewayError] = None
self._reject_next_submission = False
self._lock = Lock()
def enqueue_task(self, task: AdminTask, assigned_client_id: str) -> None:
"""测试辅助:加入一条分配给指定 Client 的任务。"""
if not assigned_client_id.strip():
raise ValueError("assigned_client_id 不能为空")
with self._lock:
if any(item.task.task_id == task.task_id for item in self._tasks):
raise ValueError(f"任务 {task.task_id} 已存在")
self._tasks.append(
_QueuedTask(deepcopy(task), assigned_client_id.strip())
)
def cancel_task(self, task_id: str) -> None:
"""测试辅助:模拟 Admin 在任务派发后取消任务。"""
with self._lock:
item = self._find_task(task_id)
if item is None:
raise ValueError(f"任务 {task_id} 不存在")
item.cancelled = True
def timeout_next_call(self) -> None:
"""测试辅助:让下一次 Gateway 调用模拟网络超时。"""
self._next_error = AdminGatewayError(
"ADMIN_TIMEOUT", "Admin 请求超时", True
)
def fail_next_call_temporarily(self) -> None:
"""测试辅助:让下一次调用模拟 Admin 暂时故障。"""
self._next_error = AdminGatewayError(
"ADMIN_UNAVAILABLE", "Admin 暂时不可用", True
)
def reject_next_submission(self) -> None:
"""测试辅助:让下一次提交模拟结果校验失败。"""
self._reject_next_submission = True
@property
def submission_count(self) -> int:
"""返回已接受的不同幂等提交数量。"""
return len(self._submissions)
def claim_next(
self, client: ClientInfo, capabilities: ClaimCapabilities
) -> Optional[AdminTask]:
with self._lock:
self._raise_forced_error()
for item in self._tasks:
if item.claimed:
continue
if item.assigned_client_id != client.client_id:
continue
if item.task.task_type not in capabilities.supported_types:
continue
item.claimed = True
self._claimed_task_ids.add(item.task.task_id)
return deepcopy(item.task)
return None
def submit_result(
self,
task_id: str,
idempotency_key: str,
result: Mapping[str, Any],
) -> SubmissionReceipt:
return self._submit("result", task_id, idempotency_key, result)
def submit_failure(
self,
task_id: str,
idempotency_key: str,
failure: Mapping[str, Any],
) -> SubmissionReceipt:
return self._submit("failure", task_id, idempotency_key, failure)
def _submit(
self,
submission_type: str,
task_id: str,
idempotency_key: str,
payload: Mapping[str, Any],
) -> SubmissionReceipt:
with self._lock:
self._raise_forced_error()
if self._reject_next_submission:
self._reject_next_submission = False
raise AdminGatewayError(
"ADMIN_RESULT_INVALID", "Admin 拒绝了无效结果", False
)
task = self._validate_submission_target(task_id, idempotency_key)
if submission_type == "result":
self._validate_result(task, payload)
else:
self._validate_failure(task, payload)
fingerprint = self._fingerprint(
submission_type, task_id, payload
)
previous = self._submissions.get(idempotency_key)
if previous is not None:
old_fingerprint, receipt = previous
if old_fingerprint != fingerprint:
raise AdminGatewayError(
"IDEMPOTENCY_CONFLICT",
"相同幂等键提交了不同内容",
False,
)
return receipt
receipt = SubmissionReceipt(
accepted=True,
result_id=str(uuid4()),
accepted_at=utc_now_iso(),
)
self._submissions[idempotency_key] = (fingerprint, receipt)
return receipt
def _validate_submission_target(
self, task_id: str, idempotency_key: str
) -> AdminTask:
if not task_id.strip():
raise AdminGatewayError(
"ADMIN_TASK_INVALID", "task_id 不能为空", False
)
if not idempotency_key.strip():
raise AdminGatewayError(
"ADMIN_IDEMPOTENCY_KEY_INVALID", "幂等键不能为空", False
)
item = self._find_task(task_id)
if item is None or task_id not in self._claimed_task_ids:
raise AdminGatewayError(
"ADMIN_TASK_NOT_ASSIGNED",
"任务未派发给当前 Client",
False,
)
# item.cancelled 故意不参与判断:已派发任务即使取消也必须接收。
return item.task
@staticmethod
def _validate_result(task: AdminTask, result: Mapping[str, Any]) -> None:
MockAdminGateway._require_mapping(result, "result")
MockAdminGateway._validate_common_submission(task, result)
if result.get("result_type") != task.task_type.value:
raise AdminGatewayError(
"ADMIN_RESULT_INVALID", "result_type 与任务类型不一致", False
)
if not str(result.get("completed_at", "")).strip():
raise AdminGatewayError(
"ADMIN_RESULT_INVALID", "completed_at 不能为空", False
)
if not isinstance(result.get("pdd_data"), Mapping):
raise AdminGatewayError(
"ADMIN_RESULT_INVALID", "pdd_data 必须是对象", False
)
@staticmethod
def _validate_failure(task: AdminTask, failure: Mapping[str, Any]) -> None:
MockAdminGateway._require_mapping(failure, "failure")
MockAdminGateway._validate_common_submission(task, failure)
if failure.get("status") not in FAILURE_STATUSES:
raise AdminGatewayError(
"ADMIN_RESULT_INVALID", "失败状态无效", False
)
error = failure.get("error")
if not isinstance(error, Mapping) or not str(error.get("code", "")).strip():
raise AdminGatewayError(
"ADMIN_RESULT_INVALID", "error.code 不能为空", False
)
if not str(failure.get("reported_at", "")).strip():
raise AdminGatewayError(
"ADMIN_RESULT_INVALID", "reported_at 不能为空", False
)
@staticmethod
def _validate_common_submission(
task: AdminTask, payload: Mapping[str, Any]
) -> None:
version = payload.get("task_version")
if isinstance(version, bool) or version != task.version:
raise AdminGatewayError(
"ADMIN_RESULT_INVALID", "task_version 与任务不一致", False
)
if not str(payload.get("attempt_id", "")).strip():
raise AdminGatewayError(
"ADMIN_RESULT_INVALID", "attempt_id 不能为空", False
)
@staticmethod
def _require_mapping(value: Mapping[str, Any], name: str) -> None:
if not isinstance(value, Mapping):
raise AdminGatewayError(
"ADMIN_RESULT_INVALID", f"{name} 必须是对象", False
)
@staticmethod
def _fingerprint(
submission_type: str, task_id: str, payload: Mapping[str, Any]
) -> str:
try:
canonical = json.dumps(
{
"submission_type": submission_type,
"task_id": task_id,
"payload": payload,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
except (TypeError, ValueError) as exc:
raise AdminGatewayError(
"ADMIN_RESULT_INVALID", "提交内容不是有效 JSON", False
) from exc
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _raise_forced_error(self) -> None:
error = self._next_error
self._next_error = None
if error is not None:
raise error
def _find_task(self, task_id: str) -> Optional[_QueuedTask]:
for item in self._tasks:
if item.task.task_id == task_id:
return item
return None
+220
View File
@@ -0,0 +1,220 @@
"""AdminGateway 边界和 Mock 契约测试。"""
import unittest
from src.admin_gateway import (
AdminGateway,
AdminGatewayError,
AdminTask,
AndroidDeviceInfo,
ClaimCapabilities,
ClientInfo,
)
from src.mock_admin_gateway import MockAdminGateway
from src.task_models import TaskType
class MockAdminGatewayContractTest(unittest.TestCase):
def setUp(self):
self.gateway = MockAdminGateway()
self.client = ClientInfo("client-001")
self.all_capabilities = ClaimCapabilities(
device=AndroidDeviceInfo("192.168.0.173:5555")
)
@staticmethod
def _task(task_id: str, task_type: TaskType) -> AdminTask:
return AdminTask(
task_id=task_id,
task_type=task_type,
version=3,
priority=10,
payload={"goods_id": "737116531267"},
created_at="2026-08-06T07:00:00Z",
updated_at="2026-08-06T07:05:00Z",
)
@staticmethod
def _result(result_type: str = "collect"):
return {
"task_version": 3,
"attempt_id": "attempt-001",
"result_type": result_type,
"completed_at": "2026-08-06T08:03:00Z",
"pdd_data": {"title": "测试商品"},
}
@staticmethod
def _failure():
return {
"task_version": 3,
"attempt_id": "attempt-001",
"status": "manual_review",
"error": {
"code": "AMBIGUOUS_ORDER_MATCH",
"message": "发现多个候选订单",
"retryable": False,
"step": "reconcile_order",
},
"diagnostics": {"artifact_ids": ["artifact-001"]},
"reported_at": "2026-08-06T08:03:00Z",
}
def test_gateway_has_only_three_business_methods(self):
self.assertEqual(
AdminGateway.__abstractmethods__,
{"claim_next", "submit_result", "submit_failure"},
)
for forbidden in ("get_status", "heartbeat", "renew_lease"):
self.assertFalse(hasattr(AdminGateway, forbidden))
def test_claim_returns_none_when_no_task_exists(self):
self.assertIsNone(
self.gateway.claim_next(self.client, self.all_capabilities)
)
def test_claim_respects_client_and_supported_types(self):
self.gateway.enqueue_task(
self._task("COLLECT-001", TaskType.COLLECT), "client-001"
)
self.gateway.enqueue_task(
self._task("PURCHASE-001", TaskType.PURCHASE), "client-001"
)
self.gateway.enqueue_task(
self._task("COLLECT-OTHER", TaskType.COLLECT), "client-002"
)
collect_only = ClaimCapabilities(
device=AndroidDeviceInfo("emulator-5554"),
supported_types=(TaskType.COLLECT,),
)
first = self.gateway.claim_next(self.client, collect_only)
self.assertEqual(first.task_id, "COLLECT-001")
self.assertIsNone(self.gateway.claim_next(self.client, collect_only))
second = self.gateway.claim_next(self.client, self.all_capabilities)
self.assertEqual(second.task_id, "PURCHASE-001")
self.assertIsNone(
self.gateway.claim_next(self.client, self.all_capabilities)
)
def test_claimed_task_is_not_returned_twice(self):
self.gateway.enqueue_task(
self._task("TASK-001", TaskType.COLLECT), "client-001"
)
self.assertIsNotNone(
self.gateway.claim_next(self.client, self.all_capabilities)
)
self.assertIsNone(
self.gateway.claim_next(self.client, self.all_capabilities)
)
def test_can_simulate_timeout_and_temporary_failure_once(self):
self.gateway.timeout_next_call()
with self.assertRaises(AdminGatewayError) as timeout_context:
self.gateway.claim_next(self.client, self.all_capabilities)
self.assertEqual(timeout_context.exception.code, "ADMIN_TIMEOUT")
self.assertTrue(timeout_context.exception.retryable)
self.assertIsNone(
self.gateway.claim_next(self.client, self.all_capabilities)
)
self.gateway.fail_next_call_temporarily()
with self.assertRaises(AdminGatewayError) as unavailable_context:
self.gateway.claim_next(self.client, self.all_capabilities)
self.assertEqual(
unavailable_context.exception.code, "ADMIN_UNAVAILABLE"
)
self.assertTrue(unavailable_context.exception.retryable)
def test_same_idempotency_key_and_content_reuses_receipt(self):
self.gateway.enqueue_task(
self._task("TASK-001", TaskType.COLLECT), "client-001"
)
self.gateway.claim_next(self.client, self.all_capabilities)
result = self._result()
first = self.gateway.submit_result("TASK-001", "stable-key", result)
second = self.gateway.submit_result("TASK-001", "stable-key", result)
self.assertTrue(first.accepted)
self.assertEqual(first, second)
self.assertEqual(self.gateway.submission_count, 1)
def test_same_idempotency_key_with_different_content_conflicts(self):
self.gateway.enqueue_task(
self._task("TASK-001", TaskType.COLLECT), "client-001"
)
self.gateway.claim_next(self.client, self.all_capabilities)
self.gateway.submit_result("TASK-001", "stable-key", self._result())
changed = self._result()
changed["pdd_data"] = {"title": "另一个商品"}
with self.assertRaises(AdminGatewayError) as context:
self.gateway.submit_result("TASK-001", "stable-key", changed)
self.assertEqual(context.exception.code, "IDEMPOTENCY_CONFLICT")
self.assertFalse(context.exception.retryable)
self.assertEqual(self.gateway.submission_count, 1)
def test_cancelled_task_still_accepts_result_and_failure(self):
self.gateway.enqueue_task(
self._task("TASK-001", TaskType.COLLECT), "client-001"
)
self.gateway.claim_next(self.client, self.all_capabilities)
self.gateway.cancel_task("TASK-001")
result_receipt = self.gateway.submit_result(
"TASK-001", "result-key", self._result()
)
failure_receipt = self.gateway.submit_failure(
"TASK-001", "failure-key", self._failure()
)
self.assertTrue(result_receipt.accepted)
self.assertTrue(failure_receipt.accepted)
self.assertEqual(self.gateway.submission_count, 2)
def test_unclaimed_task_submission_is_rejected(self):
self.gateway.enqueue_task(
self._task("TASK-001", TaskType.COLLECT), "client-001"
)
with self.assertRaises(AdminGatewayError) as context:
self.gateway.submit_result("TASK-001", "result-key", self._result())
self.assertEqual(context.exception.code, "ADMIN_TASK_NOT_ASSIGNED")
self.assertFalse(context.exception.retryable)
def test_can_simulate_result_validation_failure(self):
self.gateway.enqueue_task(
self._task("TASK-001", TaskType.COLLECT), "client-001"
)
self.gateway.claim_next(self.client, self.all_capabilities)
self.gateway.reject_next_submission()
with self.assertRaises(AdminGatewayError) as context:
self.gateway.submit_result("TASK-001", "result-key", self._result())
self.assertEqual(context.exception.code, "ADMIN_RESULT_INVALID")
self.assertFalse(context.exception.retryable)
self.assertEqual(self.gateway.submission_count, 0)
def test_payload_validation_rejects_wrong_result_type(self):
self.gateway.enqueue_task(
self._task("TASK-001", TaskType.COLLECT), "client-001"
)
self.gateway.claim_next(self.client, self.all_capabilities)
with self.assertRaises(AdminGatewayError) as context:
self.gateway.submit_result(
"TASK-001", "result-key", self._result("purchase")
)
self.assertEqual(context.exception.code, "ADMIN_RESULT_INVALID")
if __name__ == "__main__":
unittest.main()