feat: 安全应用采购规格解析结果 (#257)

This commit is contained in:
chengma
2026-08-17 17:43:25 +08:00
parent 484ce4e8ec
commit afc1cdf719
19 changed files with 1669 additions and 24 deletions
+34 -1
View File
@@ -1,6 +1,6 @@
"""Client 访问 Admin 的稳定边界和简单数据对象。 """Client 访问 Admin 的稳定边界和简单数据对象。
AdminGateway 只有登记、领取任务、提交成功结果、提交失败结果四个业务方法。 AdminGateway 提供登记、领取、结果提交和一次性采购规格解析命令。
业务层不应直接依赖 HTTP 请求或 Mock 的内部实现。 业务层不应直接依赖 HTTP 请求或 Mock 的内部实现。
""" """
@@ -113,6 +113,30 @@ class RegistrationReceipt:
registered_at: str registered_at: str
@dataclass(frozen=True)
class SpecResolutionMatch:
"""Admin 从本次候选白名单中选中的原始规格。"""
candidate_id: str
raw_text: str
options: Mapping[str, str]
@dataclass(frozen=True)
class SpecResolutionReceipt:
"""一次采购规格解析的完整业务响应。"""
schema_version: int
resolution_id: str
outcome: str
source: Optional[str]
candidate_snapshot_hash: str
match: Optional[SpecResolutionMatch]
confidence_bps: Optional[int]
reason: str
resolved_at: str
class AdminGatewayError(RuntimeError): class AdminGatewayError(RuntimeError):
"""带稳定错误代码和可重试标志的 Admin 边界错误。""" """带稳定错误代码和可重试标志的 Admin 边界错误。"""
@@ -171,3 +195,12 @@ class AdminGateway(ClientRegistrationGateway, TaskClaimGateway):
failure: Mapping[str, Any], failure: Mapping[str, Any],
) -> SubmissionReceipt: ) -> SubmissionReceipt:
"""幂等提交失败、取消或人工处理结果。""" """幂等提交失败、取消或人工处理结果。"""
@abstractmethod
def resolve_purchase_spec(
self,
task_id: str,
idempotency_key: str,
observation: Mapping[str, Any],
) -> SpecResolutionReceipt:
"""一次性解析当前真机尺码候选;不得用于查询任务状态。"""
+43 -1
View File
@@ -4,7 +4,7 @@
``MIGRATIONS`` 末尾增加版本,不能修改已经发布的迁移。 ``MIGRATIONS`` 末尾增加版本,不能修改已经发布的迁移。
""" """
SCHEMA_VERSION = 5 SCHEMA_VERSION = 6
MIGRATION_1 = ( MIGRATION_1 = (
@@ -163,6 +163,47 @@ MIGRATION_5 = (
""", """,
) )
MIGRATION_6 = (
"""
CREATE TABLE purchase_spec_resolutions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
attempt_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE,
request_json TEXT NOT NULL,
request_hash TEXT NOT NULL,
candidate_snapshot_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'resolved')),
resolution_id TEXT,
outcome TEXT CHECK (outcome IS NULL OR outcome IN (
'matched', 'uncertain', 'rejected', 'failed'
)),
source TEXT CHECK (source IS NULL OR source IN (
'rule', 'ai', 'reused'
)),
confidence_bps INTEGER CHECK (
confidence_bps IS NULL OR
confidence_bps BETWEEN 0 AND 10000
),
match_candidate_id TEXT,
match_raw_text TEXT,
match_options_json TEXT,
reason TEXT,
resolved_at TEXT,
received_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES pdd_tasks(id) ON DELETE CASCADE,
UNIQUE (task_id, attempt_id, candidate_snapshot_hash)
)
""",
"""
CREATE INDEX idx_purchase_spec_resolutions_attempt
ON purchase_spec_resolutions(attempt_id, id DESC)
""",
)
MIGRATIONS = { MIGRATIONS = {
1: MIGRATION_1, 1: MIGRATION_1,
@@ -170,4 +211,5 @@ MIGRATIONS = {
3: MIGRATION_3, 3: MIGRATION_3,
4: MIGRATION_4, 4: MIGRATION_4,
5: MIGRATION_5, 5: MIGRATION_5,
6: MIGRATION_6,
} }
+145
View File
@@ -2,6 +2,7 @@
import hashlib import hashlib
import json import json
import re
import socket import socket
from http.client import RemoteDisconnected from http.client import RemoteDisconnected
from typing import Any, Callable, Mapping, Optional from typing import Any, Callable, Mapping, Optional
@@ -17,6 +18,8 @@ from .admin_gateway import (
ClaimCapabilities, ClaimCapabilities,
ClientInfo, ClientInfo,
RegistrationReceipt, RegistrationReceipt,
SpecResolutionMatch,
SpecResolutionReceipt,
SubmissionReceipt, SubmissionReceipt,
) )
from .task_models import TaskType from .task_models import TaskType
@@ -254,6 +257,148 @@ class HttpAdminGateway(AdminGateway):
return self._submit(task_id, idempotency_key, failure, "failure") 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( def _submit(
self, self,
task_id: str, task_id: str,
+167
View File
@@ -16,6 +16,8 @@ from .admin_gateway import (
ClaimCapabilities, ClaimCapabilities,
ClientInfo, ClientInfo,
RegistrationReceipt, RegistrationReceipt,
SpecResolutionMatch,
SpecResolutionReceipt,
SubmissionReceipt, SubmissionReceipt,
) )
@@ -48,6 +50,10 @@ class MockAdminGateway(AdminGateway):
self._submissions: Dict[ self._submissions: Dict[
str, Tuple[str, SubmissionReceipt] str, Tuple[str, SubmissionReceipt]
] = {} ] = {}
self._spec_resolutions: Dict[
str, Tuple[str, SpecResolutionReceipt]
] = {}
self._next_spec_resolution: Optional[SpecResolutionReceipt] = None
self._next_error: Optional[AdminGatewayError] = None self._next_error: Optional[AdminGatewayError] = None
self._reject_next_submission = False self._reject_next_submission = False
self._registrations: Dict[str, Tuple[ClientInfo, ClaimCapabilities]] = {} self._registrations: Dict[str, Tuple[ClientInfo, ClaimCapabilities]] = {}
@@ -170,6 +176,167 @@ class MockAdminGateway(AdminGateway):
) -> SubmissionReceipt: ) -> SubmissionReceipt:
return self._submit("failure", task_id, idempotency_key, failure) return self._submit("failure", task_id, idempotency_key, failure)
def set_next_spec_resolution(
self, receipt: SpecResolutionReceipt
) -> None:
"""测试辅助:设置下一次新规格解析请求的业务响应。"""
self._next_spec_resolution = deepcopy(receipt)
@property
def spec_resolution_count(self) -> int:
return len(self._spec_resolutions)
def resolve_purchase_spec(
self,
task_id: str,
idempotency_key: str,
observation: Mapping[str, Any],
) -> SpecResolutionReceipt:
with self._lock:
self._raise_forced_error()
task = self._validate_submission_target(task_id, idempotency_key)
self._validate_spec_observation(task, idempotency_key, observation)
fingerprint = self._fingerprint(
"spec_resolution", task_id, observation
)
previous = self._spec_resolutions.get(idempotency_key)
if previous is not None:
old_fingerprint, receipt = previous
if old_fingerprint != fingerprint:
raise AdminGatewayError(
"IDEMPOTENCY_CONFLICT",
"相同规格解析幂等键携带了不同内容",
False,
)
return deepcopy(receipt)
receipt = self._next_spec_resolution or SpecResolutionReceipt(
schema_version=1,
resolution_id=str(uuid4()),
outcome="uncertain",
source=None,
candidate_snapshot_hash=str(
observation["candidate_snapshot_hash"]
),
match=None,
confidence_bps=None,
reason="Mock 未配置唯一匹配",
resolved_at=utc_now_iso(),
)
self._next_spec_resolution = None
self._spec_resolutions[idempotency_key] = (
fingerprint,
deepcopy(receipt),
)
return deepcopy(receipt)
@staticmethod
def _validate_spec_observation(
task: AdminTask,
idempotency_key: str,
observation: Mapping[str, Any],
) -> None:
if task.task_type.value != "purchase":
raise AdminGatewayError(
"TASK_NOT_PURCHASE", "当前任务不是采购任务", False
)
if observation.get("schema_version") != 1:
raise AdminGatewayError(
"INVALID_SPEC_RESOLUTION_SCHEMA", "规格解析版本无效", 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
)
if dict(observation.get("original_options") or {}) != dict(
payload.get("options") or {}
):
raise AdminGatewayError(
"INVALID_SPEC_RESOLUTION_REQUEST", "原始规格不一致", False
)
candidates = observation.get("candidates")
snapshot_hash = observation.get("candidate_snapshot_hash")
selected_color = observation.get("selected_color")
if (
not isinstance(candidates, list)
or not 1 <= len(candidates) <= 100
or not isinstance(snapshot_hash, str)
or len(snapshot_hash) != 64
or not str(observation.get("attempt_id") or "").strip()
):
raise AdminGatewayError(
"INVALID_SPEC_RESOLUTION_REQUEST", "规格候选结构无效", False
)
if any(
not isinstance(candidate, Mapping)
or candidate.get("candidate_id") != f"c{index}"
or not isinstance(candidate.get("raw_text"), str)
or not candidate["raw_text"]
or dict(candidate.get("options") or {})
!= {
"color": selected_color,
"size": candidate.get("raw_text"),
}
for index, candidate in enumerate(candidates, start=1)
):
raise AdminGatewayError(
"INVALID_SPEC_RESOLUTION_REQUEST", "规格候选字段无效", False
)
candidate_material = "".join(
(
MockAdminGateway._frame("spec-resolution-v1"),
MockAdminGateway._frame(str(observation["pdd_goods_id"])),
MockAdminGateway._frame(str(selected_color)),
MockAdminGateway._frame(str(len(candidates))),
*(
"".join(
MockAdminGateway._frame(str(value))
for value in (
candidate.get("candidate_id"),
candidate.get("raw_text"),
(candidate.get("options") or {}).get("color"),
(candidate.get("options") or {}).get("size"),
)
)
for candidate in candidates
if isinstance(candidate, Mapping)
),
)
)
expected_snapshot_hash = hashlib.sha256(
candidate_material.encode("utf-8")
).hexdigest()
if snapshot_hash != expected_snapshot_hash:
raise AdminGatewayError(
"SPEC_RESOLUTION_HASH_MISMATCH", "候选快照哈希不一致", False
)
identity = "".join(
MockAdminGateway._frame(str(value))
for value in (
task.task_id,
observation["attempt_id"],
snapshot_hash,
"spec-resolution-v1",
)
)
expected_key = "spec-resolution-v1:" + hashlib.sha256(
identity.encode("utf-8")
).hexdigest()
if idempotency_key != expected_key:
raise AdminGatewayError(
"SPEC_RESOLUTION_HASH_MISMATCH", "规格解析幂等键不一致", False
)
@staticmethod
def _frame(value: str) -> str:
return f"{len(value.encode('utf-8'))}:{value}"
def _submit( def _submit(
self, self,
submission_type: str, submission_type: str,
+13
View File
@@ -198,6 +198,19 @@ class PddPurchaseAdapter(ABC):
def select_options(self, options: Mapping[str, str]) -> None: def select_options(self, options: Mapping[str, str]) -> None:
"""按完整动态规格对象精确选择,不做相似匹配。""" """按完整动态规格对象精确选择,不做相似匹配。"""
def apply_resolved_size(
self,
expected_snapshot: PurchaseSpecCandidateSnapshot,
candidate: PurchaseSpecCandidate,
) -> None:
"""重新核对真机候选后应用 Admin 返回的原始尺码。"""
raise PddPurchaseError(
"PURCHASE_SPEC_RESOLUTION_UNSUPPORTED",
"当前采购 Adapter 不支持运行时规格解析",
step="purchase_resolve_options",
)
@abstractmethod @abstractmethod
def set_quantity(self, quantity: int) -> None: def set_quantity(self, quantity: int) -> None:
"""设置采购数量。""" """设置采购数量。"""
@@ -20,6 +20,8 @@ class PurchaseReconcileQuery:
irreversible_action_at: str irreversible_action_at: str
order_submitted_at: str order_submitted_at: str
reconcile_started_at: str reconcile_started_at: str
original_options: Mapping[str, str] = field(default_factory=dict)
spec_resolution: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True) @dataclass(frozen=True)
+92
View File
@@ -54,6 +54,7 @@ from .pdd_purchase_adapter import (
PddPurchaseError, PddPurchaseError,
PddPurchaseSpecResolutionRequired, PddPurchaseSpecResolutionRequired,
PurchaseSizeObservation, PurchaseSizeObservation,
PurchaseSpecCandidate,
PurchaseSpecCandidateSnapshot, PurchaseSpecCandidateSnapshot,
PurchasePageState, PurchasePageState,
) )
@@ -1262,6 +1263,97 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
) )
return service.collect_second_dimension_candidates(device) return service.collect_second_dimension_candidates(device)
def apply_resolved_size(
self,
expected_snapshot: PurchaseSpecCandidateSnapshot,
candidate: PurchaseSpecCandidate,
) -> None:
"""重读并复算候选快照,只按响应中的页面原文选择一次尺码。"""
self._check_cancelled("purchase_resolve_options")
if (
expected_snapshot.goods_id != self._goods_id
or candidate not in expected_snapshot.candidates
or candidate.options.get("color") != expected_snapshot.selected_color
or candidate.options.get("size") != candidate.raw_text
):
raise PddPurchaseError(
"PURCHASE_SPEC_RESOLUTION_INVALID",
"规格解析结果不属于本次商品和候选快照",
step="purchase_resolve_options",
)
current_xml = self._dump_hierarchy()
root = _parse_xml(current_xml)
if _page_kind(root, "") != "order_confirmation" or not _selected(
root, expected_snapshot.selected_color
):
raise PddPurchaseError(
"PURCHASE_SPEC_CONTEXT_CHANGED",
"等待规格解析期间页面或已选颜色发生变化",
step="purchase_resolve_options",
)
current_xml = self._restore_purchase_panel_color_region(current_xml)
try:
dimension = self._size_candidate_collector(self._require_device())
except PddCollectError as exc:
raise PddPurchaseError(
"PURCHASE_SPEC_CANDIDATES_CHANGED",
f"无法重新完整读取尺码候选:{exc.message}",
step="purchase_resolve_options",
diagnostics={"candidate_scan_error": exc.code},
) from exc
if dimension is None:
raise PddPurchaseError(
"PURCHASE_SPEC_CANDIDATES_CHANGED",
"等待规格解析期间尺码候选已经消失",
step="purchase_resolve_options",
)
self._validate_size_candidate_dimension(dimension)
current_snapshot = PurchaseSpecCandidateSnapshot.build(
goods_id=self._goods_id,
selected_color=expected_snapshot.selected_color,
target_size=expected_snapshot.target_size,
dimension_name=dimension.name,
observations=tuple(
PurchaseSizeObservation(item.text, item.available)
for item in dimension.values
),
observed_at=expected_snapshot.observed_at,
)
if (
current_snapshot.dimension_name != expected_snapshot.dimension_name
or current_snapshot.candidate_snapshot_hash
!= expected_snapshot.candidate_snapshot_hash
or current_snapshot.candidates != expected_snapshot.candidates
):
raise PddPurchaseError(
"PURCHASE_SPEC_CANDIDATES_CHANGED",
"等待规格解析期间真机尺码候选已经变化",
step="purchase_resolve_options",
)
latest_xml = self._restore_purchase_panel_color_region(
self._dump_hierarchy()
)
if not self._select_size(
self._require_device(), latest_xml, candidate.raw_text, action_delay=0.2
):
raise PddPurchaseError(
"PURCHASE_RESOLVED_SIZE_NOT_SELECTED",
"解析得到的原始尺码没有可靠选中",
step="purchase_resolve_options",
)
selected_xml = self._dump_hierarchy()
if not _selected(_parse_xml(selected_xml), candidate.raw_text):
raise PddPurchaseError(
"PURCHASE_RESOLVED_SIZE_NOT_SELECTED",
"点击解析尺码后页面没有确认选中",
step="purchase_resolve_options",
)
self._requested_options["size"] = candidate.raw_text
def set_quantity(self, quantity: int) -> None: def set_quantity(self, quantity: int) -> None:
device = self._require_device() device = self._require_device()
if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0: if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0:
+15 -2
View File
@@ -255,7 +255,15 @@ class PurchaseReconcileService:
normalized_confirmed_options = { normalized_confirmed_options = {
str(key): str(value) for key, value in confirmed_options.items() str(key): str(value) for key, value in confirmed_options.items()
} }
if normalized_confirmed_options != requested_options: resolution = snapshot.get("spec_resolution")
resolution = resolution if isinstance(resolution, Mapping) else {}
applied_options = resolution.get("applied_options")
effective_options = (
{str(key): str(value) for key, value in applied_options.items()}
if isinstance(applied_options, Mapping)
else requested_options
)
if normalized_confirmed_options != effective_options:
raise ValueError("采购运行确认规格与任务不一致") raise ValueError("采购运行确认规格与任务不一致")
if ( if (
isinstance(confirmed_quantity, bool) isinstance(confirmed_quantity, bool)
@@ -291,6 +299,8 @@ class PurchaseReconcileService:
irreversible_action_at=str(run.irreversible_action_at), irreversible_action_at=str(run.irreversible_action_at),
order_submitted_at=order_submitted_at, order_submitted_at=order_submitted_at,
reconcile_started_at=_utc_now_iso(), reconcile_started_at=_utc_now_iso(),
original_options=requested_options,
spec_resolution=dict(resolution),
) )
def _result_data( def _result_data(
@@ -306,7 +316,7 @@ class PurchaseReconcileService:
"purchase": { "purchase": {
"mode": "live", "mode": "live",
"requested": { "requested": {
"options": dict(query.options), "options": dict(query.original_options or query.options),
"quantity": query.quantity, "quantity": query.quantity,
"max_price_cent": task.price_cent, "max_price_cent": task.price_cent,
}, },
@@ -324,6 +334,9 @@ class PurchaseReconcileService:
"ordered_at": candidate.ordered_at, "ordered_at": candidate.ordered_at,
"ordered_at_raw": candidate.ordered_at_raw, "ordered_at_raw": candidate.ordered_at_raw,
"match_status": "matched", "match_status": "matched",
"spec_resolution": dict(query.spec_resolution)
if query.spec_resolution
else None,
}, },
"captured_at": _utc_now_iso(), "captured_at": _utc_now_iso(),
"source": { "source": {
+248 -5
View File
@@ -6,15 +6,26 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass import hashlib
import json
from dataclasses import dataclass, replace
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Callable, Mapping from typing import Callable, Mapping, Optional
from .admin_gateway import AdminGateway, AdminGatewayError, ClientInfo from .admin_gateway import (
AdminGateway,
AdminGatewayError,
ClientInfo,
SpecResolutionMatch,
SpecResolutionReceipt,
)
from .pdd_purchase_adapter import ( from .pdd_purchase_adapter import (
PddLivePurchaseAdapter, PddLivePurchaseAdapter,
PddPurchaseAdapter, PddPurchaseAdapter,
PddPurchaseError, PddPurchaseError,
PddPurchaseSpecResolutionRequired,
PurchaseSpecCandidate,
PurchaseSpecCandidateSnapshot,
PurchasePageState, PurchasePageState,
) )
from .task_models import OutboxEventRecord, OutboxEventType, TaskStatus from .task_models import OutboxEventRecord, OutboxEventType, TaskStatus
@@ -38,6 +49,8 @@ class PurchaseTarget:
options: Mapping[str, str] options: Mapping[str, str]
quantity: int quantity: int
max_price_cent: int # 人民币订单总价上限,单位分 max_price_cent: int # 人民币订单总价上限,单位分
original_options: Optional[Mapping[str, str]] = None
spec_resolution: Optional[Mapping[str, object]] = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -121,6 +134,7 @@ class PurchaseTaskService:
) )
self._started(remote_task_id) self._started(remote_task_id)
adapter: PddPurchaseAdapter | None = None adapter: PddPurchaseAdapter | None = None
target: PurchaseTarget | None = None
step = "purchase_prepare" step = "purchase_prepare"
failure_message = "" failure_message = ""
failure_outcome_kind = "" failure_outcome_kind = ""
@@ -149,7 +163,19 @@ class PurchaseTaskService:
step = "purchase_select_options" step = "purchase_select_options"
self._enter_step(remote_task_id, started.attempt_id, step) self._enter_step(remote_task_id, started.attempt_id, step)
self._validate_identity(adapter.read_state(), target, "goods") self._validate_identity(adapter.read_state(), target, "goods")
try:
adapter.select_options(target.options) adapter.select_options(target.options)
except PddPurchaseSpecResolutionRequired as required:
step = "purchase_resolve_options"
self._enter_step(remote_task_id, started.attempt_id, step)
target = self._resolve_and_apply_spec(
remote_task_id,
started.attempt_id,
started.task.version,
target,
required.snapshot,
adapter,
)
step = "purchase_verify_options" step = "purchase_verify_options"
self._enter_step(remote_task_id, started.attempt_id, step) self._enter_step(remote_task_id, started.attempt_id, step)
@@ -216,6 +242,9 @@ class PurchaseTaskService:
failure_outcome_kind = "global_failed" failure_outcome_kind = "global_failed"
else: else:
failure_outcome_kind = "task_failed" failure_outcome_kind = "task_failed"
diagnostics = dict(exc.diagnostics)
if target is not None and target.spec_resolution is not None:
diagnostics["spec_resolution"] = dict(target.spec_resolution)
event = self._save_failure( event = self._save_failure(
remote_task_id, remote_task_id,
started.attempt_id, started.attempt_id,
@@ -223,7 +252,7 @@ class PurchaseTaskService:
exc.message, exc.message,
exc.retryable, exc.retryable,
step, step,
exc.diagnostics, diagnostics,
) )
except Exception as exc: except Exception as exc:
failure_message = f"采购在“{step}”发生未知错误:{exc}" failure_message = f"采购在“{step}”发生未知错误:{exc}"
@@ -277,6 +306,9 @@ class PurchaseTaskService:
"quantity": state.quantity, "quantity": state.quantity,
"unit_price_cent": state.price_cent, "unit_price_cent": state.price_cent,
"total_price_cent": state.price_cent * state.quantity, "total_price_cent": state.price_cent * state.quantity,
"spec_resolution": dict(target.spec_resolution)
if target.spec_resolution is not None
else None,
}, },
) )
submitted_at = None submitted_at = None
@@ -314,6 +346,214 @@ class PurchaseTaskService:
remote_task_id, remote_task_id,
) )
def _resolve_and_apply_spec(
self,
remote_task_id: str,
attempt_id: str,
task_version: int,
target: PurchaseTarget,
snapshot: PurchaseSpecCandidateSnapshot,
adapter: PddPurchaseAdapter,
) -> PurchaseTarget:
"""请求一次规格解析,先落库,再重读真机并精确应用结果。"""
if (
snapshot.goods_id != target.goods_id
or snapshot.selected_color != target.options.get("color")
or snapshot.target_size != target.options.get("size")
):
raise PddPurchaseError(
"PURCHASE_SPEC_CONTEXT_CHANGED",
"规格候选的商品、颜色或目标尺码与采购任务不一致",
step="purchase_resolve_options",
)
request = {
"schema_version": 1,
"task_version": task_version,
"attempt_id": attempt_id,
"pdd_goods_id": snapshot.goods_id,
"original_options": dict(target.options),
"selected_color": snapshot.selected_color,
"target_size": snapshot.target_size,
"candidates": [item.to_dict() for item in snapshot.candidates],
"candidate_snapshot_hash": snapshot.candidate_snapshot_hash,
"observed_at": snapshot.observed_at,
}
encoded = json.dumps(
request, ensure_ascii=False, separators=(",", ":")
).encode("utf-8")
if len(encoded) > 64 * 1024:
raise PddPurchaseError(
"PURCHASE_SPEC_RESOLUTION_REQUEST_TOO_LARGE",
"规格解析请求超过 64 KiB,已停止采购",
step="purchase_resolve_options",
)
idempotency_key = self._spec_resolution_idempotency_key(
remote_task_id, attempt_id, snapshot.candidate_snapshot_hash
)
record = self._repository.prepare_purchase_spec_resolution(
remote_task_id,
attempt_id,
idempotency_key,
request,
)
if record.status == "resolved":
receipt = self._receipt_from_record(record)
else:
try:
receipt = self._gateway.resolve_purchase_spec(
remote_task_id, idempotency_key, record.request_json
)
except AdminGatewayError as exc:
raise PddPurchaseError(
"PURCHASE_SPEC_RESOLUTION_ADMIN_FAILED",
f"Admin 规格解析失败:{exc}",
step="purchase_resolve_options",
diagnostics={
"admin_error_code": exc.code,
"request_id": exc.request_id,
},
) from exc
record = self._repository.save_purchase_spec_resolution(
record.id, self._receipt_to_dict(receipt)
)
candidate = self._validated_resolution_candidate(snapshot, receipt)
effective_options = dict(target.options)
effective_options["size"] = candidate.raw_text
audit = {
"resolution_id": receipt.resolution_id,
"candidate_snapshot_hash": receipt.candidate_snapshot_hash,
"source": receipt.source,
"confidence_bps": receipt.confidence_bps,
"candidate_id": candidate.candidate_id,
"raw_text": candidate.raw_text,
"applied_options": dict(effective_options),
}
try:
adapter.apply_resolved_size(snapshot, candidate)
except PddPurchaseError as exc:
diagnostics = dict(exc.diagnostics)
diagnostics["spec_resolution"] = dict(audit)
raise PddPurchaseError(
exc.code,
exc.message,
step=exc.step,
retryable=False,
diagnostics=diagnostics,
) from exc
return replace(
target,
options=effective_options,
original_options=dict(target.options),
spec_resolution=audit,
)
@staticmethod
def _validated_resolution_candidate(
snapshot: PurchaseSpecCandidateSnapshot,
receipt: SpecResolutionReceipt,
) -> PurchaseSpecCandidate:
if receipt.candidate_snapshot_hash != snapshot.candidate_snapshot_hash:
raise PddPurchaseError(
"PURCHASE_SPEC_RESOLUTION_INVALID",
"Admin 返回的候选快照哈希与请求不一致",
step="purchase_resolve_options",
)
if receipt.outcome != "matched" or receipt.match is None:
code = {
"uncertain": "PURCHASE_SPEC_RESOLUTION_UNCERTAIN",
"rejected": "PURCHASE_SPEC_RESOLUTION_REJECTED",
"failed": "PURCHASE_SPEC_RESOLUTION_FAILED",
}.get(receipt.outcome, "PURCHASE_SPEC_RESOLUTION_INVALID")
raise PddPurchaseError(
code,
f"Admin 未返回唯一可用尺码:{receipt.reason}",
step="purchase_resolve_options",
diagnostics={"resolution_id": receipt.resolution_id},
)
match = receipt.match
candidates = tuple(
candidate
for candidate in snapshot.candidates
if candidate.candidate_id == match.candidate_id
and candidate.raw_text == match.raw_text
and dict(candidate.options) == dict(match.options)
)
if len(candidates) != 1:
raise PddPurchaseError(
"PURCHASE_SPEC_RESOLUTION_INVALID",
"Admin 返回的规格不是本次候选的逐字副本",
step="purchase_resolve_options",
diagnostics={"resolution_id": receipt.resolution_id},
)
return candidates[0]
@staticmethod
def _spec_resolution_idempotency_key(
remote_task_id: str,
attempt_id: str,
snapshot_hash: str,
) -> str:
def frame(value: str) -> str:
return f"{len(value.encode('utf-8'))}:{value}"
material = "".join(
frame(value)
for value in (
remote_task_id,
attempt_id,
snapshot_hash,
"spec-resolution-v1",
)
)
return "spec-resolution-v1:" + hashlib.sha256(
material.encode("utf-8")
).hexdigest()
@staticmethod
def _receipt_to_dict(receipt: SpecResolutionReceipt) -> dict[str, object]:
return {
"schema_version": receipt.schema_version,
"resolution_id": receipt.resolution_id,
"outcome": receipt.outcome,
"source": receipt.source,
"candidate_snapshot_hash": receipt.candidate_snapshot_hash,
"match": (
{
"candidate_id": receipt.match.candidate_id,
"raw_text": receipt.match.raw_text,
"options": dict(receipt.match.options),
}
if receipt.match is not None
else None
),
"confidence_bps": receipt.confidence_bps,
"reason": receipt.reason,
"resolved_at": receipt.resolved_at,
}
@staticmethod
def _receipt_from_record(record: object) -> SpecResolutionReceipt:
match = None
if getattr(record, "match_candidate_id", None):
match = SpecResolutionMatch(
record.match_candidate_id,
record.match_raw_text,
record.match_options or {},
)
return SpecResolutionReceipt(
1,
record.resolution_id or "",
record.outcome or "failed",
record.source,
record.candidate_snapshot_hash,
match,
record.confidence_bps,
record.reason or "",
record.resolved_at or "",
)
def _enter_step( def _enter_step(
self, remote_task_id: str, attempt_id: str, step: str self, remote_task_id: str, attempt_id: str, step: str
) -> None: ) -> None:
@@ -619,7 +859,7 @@ class PurchaseTaskService:
"purchase": { "purchase": {
"mode": "dry_run", "mode": "dry_run",
"requested": { "requested": {
"options": dict(target.options), "options": dict(target.original_options or target.options),
"quantity": target.quantity, "quantity": target.quantity,
"max_price_cent": target.max_price_cent, "max_price_cent": target.max_price_cent,
}, },
@@ -636,6 +876,9 @@ class PurchaseTaskService:
"ordered_at": None, "ordered_at": None,
"ordered_at_raw": None, "ordered_at_raw": None,
"match_status": "not_submitted", "match_status": "not_submitted",
"spec_resolution": dict(target.spec_resolution)
if target.spec_resolution is not None
else None,
}, },
"captured_at": utc_now_iso(), "captured_at": utc_now_iso(),
"source": { "source": {
+26
View File
@@ -226,6 +226,32 @@ class StartedTaskRun:
attempt_no: int attempt_no: int
@dataclass(frozen=True)
class PurchaseSpecResolutionRecord:
"""一次采购执行中的规格解析请求和已持久化响应。"""
id: int
task_id: int
attempt_id: str
idempotency_key: str
request_json: Dict[str, Any]
request_hash: str
candidate_snapshot_hash: str
status: str
resolution_id: Optional[str]
outcome: Optional[str]
source: Optional[str]
confidence_bps: Optional[int]
match_candidate_id: Optional[str]
match_raw_text: Optional[str]
match_options: Optional[Dict[str, str]]
reason: Optional[str]
resolved_at: Optional[str]
received_at: Optional[str]
created_at: str
updated_at: str
@dataclass(frozen=True) @dataclass(frozen=True)
class AppSettingRecord: class AppSettingRecord:
"""一条非敏感应用设置。""" """一条非敏感应用设置。"""
+190
View File
@@ -3,6 +3,7 @@
Repository 是数据库访问入口。界面和自动化代码不应自行拼接任务 SQL。 Repository 是数据库访问入口。界面和自动化代码不应自行拼接任务 SQL。
""" """
import hashlib
import json import json
import sqlite3 import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -16,6 +17,7 @@ from .task_models import (
OutboxEventRecord, OutboxEventRecord,
OutboxEventType, OutboxEventType,
OutboxStatus, OutboxStatus,
PurchaseSpecResolutionRecord,
RunStatus, RunStatus,
StartedTaskRun, StartedTaskRun,
TaskDetail, TaskDetail,
@@ -820,6 +822,159 @@ class TaskRepository:
finally: finally:
connection.close() connection.close()
def prepare_purchase_spec_resolution(
self,
remote_task_id: str,
attempt_id: str,
idempotency_key: str,
request: Dict[str, object],
) -> PurchaseSpecResolutionRecord:
"""发送前保存完整请求;相同身份只能复用完全相同的内容。"""
canonical = json.dumps(
request,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
request_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
snapshot_hash = str(request.get("candidate_snapshot_hash") or "")
if not idempotency_key.strip() or len(snapshot_hash) != 64:
raise ValueError("规格解析幂等键或候选快照哈希无效")
now = utc_now_iso()
connection = open_database(self._db_path)
try:
with connection:
task = connection.execute(
"SELECT id, task_type, status FROM pdd_tasks"
" WHERE remote_task_id = ?",
(remote_task_id,),
).fetchone()
if task is None or task["task_type"] != TaskType.PURCHASE.value:
raise ValueError("采购任务不存在或类型错误")
run = connection.execute(
"SELECT run_status, irreversible_action_at FROM task_runs"
" WHERE task_id = ? AND attempt_id = ?",
(task["id"], attempt_id),
).fetchone()
if (
task["status"] != TaskStatus.RUNNING.value
or run is None
or run["run_status"] != RunStatus.RUNNING.value
or run["irreversible_action_at"] is not None
):
raise ValueError("采购执行已结束或已进入不可逆阶段")
existing = connection.execute(
"SELECT * FROM purchase_spec_resolutions"
" WHERE task_id = ? AND attempt_id = ?"
" AND candidate_snapshot_hash = ?",
(task["id"], attempt_id, snapshot_hash),
).fetchone()
if existing is not None:
if (
existing["idempotency_key"] != idempotency_key
or existing["request_hash"] != request_hash
or existing["request_json"] != canonical
):
raise ValueError("同一规格解析身份的请求内容发生冲突")
return self._to_spec_resolution(existing)
cursor = connection.execute(
"INSERT INTO purchase_spec_resolutions (task_id, attempt_id,"
" idempotency_key, request_json, request_hash,"
" candidate_snapshot_hash, created_at, updated_at)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
task["id"], attempt_id, idempotency_key, canonical,
request_hash, snapshot_hash, now, now,
),
)
row = connection.execute(
"SELECT * FROM purchase_spec_resolutions WHERE id = ?",
(cursor.lastrowid,),
).fetchone()
assert row is not None
return self._to_spec_resolution(row)
finally:
connection.close()
def save_purchase_spec_resolution(
self,
record_id: int,
response: Dict[str, object],
) -> PurchaseSpecResolutionRecord:
"""先保存 Admin 业务响应,再允许自动化继续选择尺码。"""
match = response.get("match")
match_data = dict(match) if isinstance(match, dict) else {}
options = match_data.get("options")
now = utc_now_iso()
connection = open_database(self._db_path)
try:
with connection:
current = connection.execute(
"SELECT r.*, tr.run_status, tr.irreversible_action_at"
" FROM purchase_spec_resolutions r"
" JOIN task_runs tr ON tr.attempt_id = r.attempt_id"
" AND tr.task_id = r.task_id"
" WHERE r.id = ?",
(record_id,),
).fetchone()
if current is None:
raise ValueError("规格解析请求记录不存在")
if current["status"] == "resolved":
saved = self._to_spec_resolution(current)
incoming_match = (
{
str(key): str(value)
for key, value in options.items()
}
if isinstance(options, dict)
else None
)
if (
saved.resolution_id != response.get("resolution_id")
or saved.outcome != response.get("outcome")
or saved.source != response.get("source")
or saved.confidence_bps != response.get("confidence_bps")
or saved.match_candidate_id
!= match_data.get("candidate_id")
or saved.match_raw_text != match_data.get("raw_text")
or saved.match_options != incoming_match
or saved.reason != response.get("reason")
or saved.resolved_at != response.get("resolved_at")
):
raise ValueError("同一规格解析记录收到了不同响应")
return saved
if (
current["run_status"] != RunStatus.RUNNING.value
or current["irreversible_action_at"] is not None
):
raise ValueError("采购执行已结束或已进入不可逆阶段")
connection.execute(
"UPDATE purchase_spec_resolutions SET status = 'resolved',"
" resolution_id = ?, outcome = ?, source = ?,"
" confidence_bps = ?, match_candidate_id = ?,"
" match_raw_text = ?, match_options_json = ?, reason = ?,"
" resolved_at = ?, received_at = ?, updated_at = ? WHERE id = ?",
(
response.get("resolution_id"), response.get("outcome"),
response.get("source"), response.get("confidence_bps"),
match_data.get("candidate_id"), match_data.get("raw_text"),
json.dumps(options, ensure_ascii=False)
if isinstance(options, dict) else None,
response.get("reason"), response.get("resolved_at"),
now, now, record_id,
),
)
row = connection.execute(
"SELECT * FROM purchase_spec_resolutions WHERE id = ?",
(record_id,),
).fetchone()
assert row is not None
return self._to_spec_resolution(row)
finally:
connection.close()
def mark_purchase_irreversible( def mark_purchase_irreversible(
self, self,
remote_task_id: str, remote_task_id: str,
@@ -1775,6 +1930,41 @@ class TaskRepository:
sent_at=row["sent_at"], sent_at=row["sent_at"],
) )
@staticmethod
def _to_spec_resolution(
row: sqlite3.Row,
) -> PurchaseSpecResolutionRecord:
match_options = None
if row["match_options_json"]:
loaded = TaskRepository._load_json_object(
row["match_options_json"]
)
match_options = {
str(key): str(value) for key, value in loaded.items()
}
return PurchaseSpecResolutionRecord(
id=row["id"],
task_id=row["task_id"],
attempt_id=row["attempt_id"],
idempotency_key=row["idempotency_key"],
request_json=TaskRepository._load_json_object(row["request_json"]),
request_hash=row["request_hash"],
candidate_snapshot_hash=row["candidate_snapshot_hash"],
status=row["status"],
resolution_id=row["resolution_id"],
outcome=row["outcome"],
source=row["source"],
confidence_bps=row["confidence_bps"],
match_candidate_id=row["match_candidate_id"],
match_raw_text=row["match_raw_text"],
match_options=match_options,
reason=row["reason"],
resolved_at=row["resolved_at"],
received_at=row["received_at"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
@staticmethod @staticmethod
def _to_task_run(row: sqlite3.Row) -> TaskRunRecord: def _to_task_run(row: sqlite3.Row) -> TaskRunRecord:
diagnostics = ( diagnostics = (
+91 -1
View File
@@ -1,5 +1,6 @@
"""AdminGateway 边界和 Mock 契约测试。""" """AdminGateway 边界和 Mock 契约测试。"""
import hashlib
import unittest import unittest
from src.admin_gateway import ( from src.admin_gateway import (
@@ -9,6 +10,8 @@ from src.admin_gateway import (
AndroidDeviceInfo, AndroidDeviceInfo,
ClaimCapabilities, ClaimCapabilities,
ClientInfo, ClientInfo,
SpecResolutionMatch,
SpecResolutionReceipt,
) )
from src.mock_admin_gateway import MockAdminGateway from src.mock_admin_gateway import MockAdminGateway
from src.task_models import TaskType from src.task_models import TaskType
@@ -61,7 +64,7 @@ class MockAdminGatewayContractTest(unittest.TestCase):
"reported_at": "2026-08-06T08:03:00Z", "reported_at": "2026-08-06T08:03:00Z",
} }
def test_gateway_has_only_four_business_methods(self): def test_gateway_has_only_expected_business_methods(self):
self.assertEqual( self.assertEqual(
AdminGateway.__abstractmethods__, AdminGateway.__abstractmethods__,
{ {
@@ -69,6 +72,7 @@ class MockAdminGatewayContractTest(unittest.TestCase):
"claim_next", "claim_next",
"submit_result", "submit_result",
"submit_failure", "submit_failure",
"resolve_purchase_spec",
}, },
) )
for forbidden in ("get_status", "heartbeat", "renew_lease"): for forbidden in ("get_status", "heartbeat", "renew_lease"):
@@ -162,6 +166,92 @@ class MockAdminGatewayContractTest(unittest.TestCase):
) )
self.assertTrue(unavailable_context.exception.retryable) self.assertTrue(unavailable_context.exception.retryable)
def test_spec_resolution_is_idempotent_and_returns_configured_match(self):
task = AdminTask(
task_id="PUR-SPEC",
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)
frame = lambda value: f"{len(value.encode('utf-8'))}:{value}"
snapshot_material = "".join(
frame(value)
for value in (
"spec-resolution-v1",
"737116531267",
"黑色",
"1",
"c1",
"120斤",
"黑色",
"120斤",
)
)
snapshot_hash = hashlib.sha256(
snapshot_material.encode("utf-8")
).hexdigest()
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": snapshot_hash,
"observed_at": "2026-08-17T08:00:00Z",
}
material = "".join(
frame(value)
for value in (
"PUR-SPEC",
"attempt-001",
snapshot_hash,
"spec-resolution-v1",
)
)
key = "spec-resolution-v1:" + hashlib.sha256(
material.encode("utf-8")
).hexdigest()
configured = SpecResolutionReceipt(
1,
"psr-001",
"matched",
"rule",
snapshot_hash,
SpecResolutionMatch(
"c1", "120斤", {"color": "黑色", "size": "120斤"}
),
10000,
"唯一重量等价",
"2026-08-17T08:00:01Z",
)
self.gateway.set_next_spec_resolution(configured)
first = self.gateway.resolve_purchase_spec(
"PUR-SPEC", key, observation
)
second = self.gateway.resolve_purchase_spec(
"PUR-SPEC", key, observation
)
self.assertEqual(first, configured)
self.assertEqual(second, configured)
self.assertEqual(self.gateway.spec_resolution_count, 1)
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"
+47 -6
View File
@@ -6,7 +6,13 @@ import unittest
from pathlib import Path from pathlib import Path
from src.db import DatabaseVersionError, initialize_database, open_database from src.db import DatabaseVersionError, initialize_database, open_database
from src.db_schema import MIGRATION_1, MIGRATION_2, MIGRATION_3, MIGRATION_4 from src.db_schema import (
MIGRATION_1,
MIGRATION_2,
MIGRATION_3,
MIGRATION_4,
MIGRATION_5,
)
EXPECTED_TABLES = { EXPECTED_TABLES = {
@@ -14,6 +20,7 @@ EXPECTED_TABLES = {
"task_runs", "task_runs",
"outbox_events", "outbox_events",
"app_settings", "app_settings",
"purchase_spec_resolutions",
} }
EXPECTED_INDEXES = { EXPECTED_INDEXES = {
@@ -23,6 +30,7 @@ EXPECTED_INDEXES = {
"idx_task_runs_task", "idx_task_runs_task",
"idx_outbox_pending", "idx_outbox_pending",
"idx_pdd_tasks_visible_list", "idx_pdd_tasks_visible_list",
"idx_purchase_spec_resolutions_attempt",
} }
@@ -64,7 +72,7 @@ class DatabaseInitializationTests(unittest.TestCase):
self.assertTrue(EXPECTED_TABLES.issubset(tables)) self.assertTrue(EXPECTED_TABLES.issubset(tables))
self.assertTrue(EXPECTED_INDEXES.issubset(indexes)) self.assertTrue(EXPECTED_INDEXES.issubset(indexes))
self.assertEqual(version, 5) self.assertEqual(version, 6)
def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None: def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None:
connection = open_database(self.db_path) connection = open_database(self.db_path)
@@ -107,7 +115,7 @@ class DatabaseInitializationTests(unittest.TestCase):
connection.close() connection.close()
self.assertIn("result_data", columns) self.assertIn("result_data", columns)
self.assertEqual(attempt_id, "ATTEMPT-OLD") self.assertEqual(attempt_id, "ATTEMPT-OLD")
self.assertEqual(version, 5) self.assertEqual(version, 6)
def test_initialize_can_run_twice_without_losing_data(self) -> None: def test_initialize_can_run_twice_without_losing_data(self) -> None:
initialize_database(self.db_path) initialize_database(self.db_path)
@@ -170,7 +178,7 @@ class DatabaseInitializationTests(unittest.TestCase):
finally: finally:
connection.close() connection.close()
self.assertEqual(row[0], "dry_run") self.assertEqual(row[0], "dry_run")
self.assertEqual(version, 5) self.assertEqual(version, 6)
def test_v3_database_adds_soft_remove_column_without_losing_tasks(self) -> None: def test_v3_database_adds_soft_remove_column_without_losing_tasks(self) -> None:
connection = open_database(self.db_path) connection = open_database(self.db_path)
@@ -212,7 +220,7 @@ class DatabaseInitializationTests(unittest.TestCase):
connection.close() connection.close()
self.assertIn("removed_at", columns) self.assertIn("removed_at", columns)
self.assertEqual(tuple(row), ("COL-V3", None)) self.assertEqual(tuple(row), ("COL-V3", None))
self.assertEqual(version, 5) self.assertEqual(version, 6)
def test_v4_database_backfills_shop_name_from_valid_pdd_data(self) -> None: def test_v4_database_backfills_shop_name_from_valid_pdd_data(self) -> None:
connection = open_database(self.db_path) connection = open_database(self.db_path)
@@ -245,7 +253,40 @@ class DatabaseInitializationTests(unittest.TestCase):
finally: finally:
connection.close() connection.close()
self.assertEqual(row[0], "测试店铺") self.assertEqual(row[0], "测试店铺")
self.assertEqual(version, 5) self.assertEqual(version, 6)
def test_v5_database_adds_purchase_spec_resolution_audit_table(self):
connection = open_database(self.db_path)
try:
with connection:
for statement in (
MIGRATION_1
+ MIGRATION_2
+ MIGRATION_3
+ MIGRATION_4
+ MIGRATION_5
):
connection.execute(statement)
connection.execute("PRAGMA user_version = 5")
finally:
connection.close()
initialize_database(self.db_path)
connection = open_database(self.db_path)
try:
columns = {
row[1]
for row in connection.execute(
"PRAGMA table_info(purchase_spec_resolutions)"
)
}
version = connection.execute("PRAGMA user_version").fetchone()[0]
finally:
connection.close()
self.assertIn("candidate_snapshot_hash", columns)
self.assertIn("idempotency_key", columns)
self.assertEqual(version, 6)
def test_new_connection_uses_required_pragmas(self) -> None: def test_new_connection_uses_required_pragmas(self) -> None:
initialize_database(self.db_path) initialize_database(self.db_path)
+101
View File
@@ -206,6 +206,107 @@ class HttpAdminGatewayTest(unittest.TestCase):
self.assertTrue(receipt.accepted) self.assertTrue(receipt.accepted)
self.assertTrue(receipt.result_id.startswith("legacy-failure-")) self.assertTrue(receipt.result_id.startswith("legacy-failure-"))
def test_resolve_purchase_spec_posts_once_and_parses_whitelisted_match(self):
response = {
"schema_version": 1,
"resolution_id": "psr-001",
"outcome": "matched",
"source": "rule",
"candidate_snapshot_hash": "a" * 64,
"match": {
"candidate_id": "c1",
"raw_text": "120斤",
"options": {"color": "黑色", "size": "120斤"},
},
"confidence_bps": 10000,
"reason": "唯一等价",
"resolved_at": "2026-08-17T08:00:01Z",
"future_field": "ignored",
}
opener = RecordingOpener(FakeResponse(200, response))
gateway = HttpAdminGateway(
opener=opener, client_id="CLIENT-001", timeout_seconds=5
)
receipt = gateway.resolve_purchase_spec(
"PUR-001", "spec-resolution-v1:key", {"schema_version": 1}
)
self.assertEqual(receipt.resolution_id, "psr-001")
self.assertEqual(receipt.match.raw_text, "120斤")
self.assertTrue(opener.request.full_url.endswith(
"/api/v1/client/tasks/PUR-001/spec-resolution"
))
headers = {
key.lower(): value for key, value in opener.request.header_items()
}
self.assertEqual(
headers["idempotency-key"], "spec-resolution-v1:key"
)
self.assertEqual(opener.timeout, 5)
def test_resolve_purchase_spec_accepts_all_nonmatched_business_outcomes(self):
for outcome in ("uncertain", "rejected", "failed"):
with self.subTest(outcome=outcome):
gateway = HttpAdminGateway(
opener=RecordingOpener(
FakeResponse(
200,
{
"schema_version": 1,
"resolution_id": f"psr-{outcome}",
"outcome": outcome,
"source": None,
"candidate_snapshot_hash": "b" * 64,
"match": None,
"confidence_bps": None,
"reason": "没有唯一安全候选",
"resolved_at": "2026-08-17T08:00:01Z",
},
)
),
client_id="CLIENT-001",
)
receipt = gateway.resolve_purchase_spec(
"PUR-001", "spec-resolution-v1:key", {}
)
self.assertEqual(receipt.outcome, outcome)
self.assertIsNone(receipt.match)
def test_resolve_purchase_spec_rejects_match_on_nonmatched_outcome(self):
gateway = HttpAdminGateway(
opener=RecordingOpener(
FakeResponse(
200,
{
"schema_version": 1,
"resolution_id": "psr-invalid",
"outcome": "uncertain",
"source": "ai",
"candidate_snapshot_hash": "c" * 64,
"match": {
"candidate_id": "c1",
"raw_text": "120斤",
"options": {"color": "黑色", "size": "120斤"},
},
"confidence_bps": 5000,
"reason": "响应自相矛盾",
"resolved_at": "2026-08-17T08:00:01Z",
},
)
),
client_id="CLIENT-001",
)
with self.assertRaises(AdminGatewayError) as raised:
gateway.resolve_purchase_spec(
"PUR-001", "spec-resolution-v1:key", {}
)
self.assertEqual(raised.exception.code, "ADMIN_INVALID_RESPONSE")
def test_malformed_2xx_submission_is_retryable_and_not_called_rejected(self): def test_malformed_2xx_submission_is_retryable_and_not_called_rejected(self):
gateway = HttpAdminGateway( gateway = HttpAdminGateway(
opener=RecordingOpener(FakeResponse(200, {"accepted": True})), opener=RecordingOpener(FakeResponse(200, {"accepted": True})),
@@ -11,6 +11,8 @@ from src.pdd_collect_service import DimensionValue, PddCollectError, SpecDimensi
from src.pdd_purchase_adapter import ( from src.pdd_purchase_adapter import (
PddPurchaseError, PddPurchaseError,
PddPurchaseSpecResolutionRequired, PddPurchaseSpecResolutionRequired,
PurchaseSizeObservation,
PurchaseSpecCandidateSnapshot,
) )
from src.performance_timing import TaskPerformanceTrace from src.performance_timing import TaskPerformanceTrace
from src.pdd_u2_purchase_adapter import ( from src.pdd_u2_purchase_adapter import (
@@ -991,6 +993,76 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
) )
adapter.close() adapter.close()
def test_apply_resolved_size_rechecks_same_snapshot_before_exact_selection(self):
device = FakeDevice()
selected = []
snapshot = PurchaseSpecCandidateSnapshot.build(
goods_id="753136429979",
selected_color="黑色",
target_size="XXL",
dimension_name="尺码",
observations=(
PurchaseSizeObservation("3XL【140-165斤】", True),
),
observed_at="2026-08-17T08:00:00Z",
)
adapter = U2PddPurchaseAdapter(
"USB-001",
device_service=PddDeviceService(connector=lambda _serial: device),
sleeper=lambda _seconds: None,
select_size_fn=lambda _device, _xml, target, **_kwargs: (
selected.append(target) or True
),
size_candidate_collector=lambda _device: SpecDimension(
"size",
"尺码",
(DimensionValue("3XL【140-165斤】", True),),
),
)
adapter.open_goods(GOODS_URL)
device.click(790, 2214)
adapter.apply_resolved_size(snapshot, snapshot.candidates[0])
self.assertEqual(selected, ["3XL【140-165斤】"])
self.assertEqual(
adapter.read_state().selected_options,
{"size": "3XL【140-165斤】"},
)
adapter.close()
def test_apply_resolved_size_stops_when_candidate_snapshot_changed(self):
device = FakeDevice()
selections = []
snapshot = PurchaseSpecCandidateSnapshot.build(
goods_id="753136429979",
selected_color="黑色",
target_size="XXL",
dimension_name="尺码",
observations=(PurchaseSizeObservation("M", True),),
observed_at="2026-08-17T08:00:00Z",
)
adapter = U2PddPurchaseAdapter(
"USB-001",
device_service=PddDeviceService(connector=lambda _serial: device),
sleeper=lambda _seconds: None,
select_size_fn=lambda *_args, **_kwargs: selections.append(True),
size_candidate_collector=lambda _device: SpecDimension(
"size", "尺码", (DimensionValue("L", True),)
),
)
adapter.open_goods(GOODS_URL)
device.click(790, 2214)
with self.assertRaises(PddPurchaseError) as raised:
adapter.apply_resolved_size(snapshot, snapshot.candidates[0])
self.assertEqual(
raised.exception.code, "PURCHASE_SPEC_CANDIDATES_CHANGED"
)
self.assertEqual(selections, [])
adapter.close()
def test_candidate_snapshot_is_not_used_when_target_exists_but_click_fails(self): def test_candidate_snapshot_is_not_used_when_target_exists_but_click_fails(self):
device = FakeDevice() device = FakeDevice()
adapter = U2PddPurchaseAdapter( adapter = U2PddPurchaseAdapter(
+288 -3
View File
@@ -3,21 +3,30 @@
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from src.admin_gateway import ( from src.admin_gateway import (
AdminTask, AdminTask,
AndroidDeviceInfo, AndroidDeviceInfo,
ClaimCapabilities, ClaimCapabilities,
ClientInfo, ClientInfo,
SpecResolutionMatch,
SpecResolutionReceipt,
) )
from src.mock_admin_gateway import MockAdminGateway from src.mock_admin_gateway import MockAdminGateway
from src.db import open_database
from src.pdd_purchase_adapter import ( from src.pdd_purchase_adapter import (
PddLivePurchaseAdapter, PddLivePurchaseAdapter,
PddPurchaseAdapter, PddPurchaseAdapter,
PddPurchaseError, PddPurchaseError,
PddPurchaseSpecResolutionRequired,
PurchaseSizeObservation,
PurchaseSpecCandidateSnapshot,
PurchasePageState, PurchasePageState,
) )
from src.pdd_purchase_reconcile_adapter import PurchaseOrderCandidate
from src.purchase_task_service import PurchaseTaskService from src.purchase_task_service import PurchaseTaskService
from src.purchase_reconcile_service import PurchaseReconcileService
from src.task_models import NewClaimedTask, TaskStatus, TaskType from src.task_models import NewClaimedTask, TaskStatus, TaskType
from src.task_repository import TaskRepository from src.task_repository import TaskRepository
@@ -135,12 +144,65 @@ class UnavailableGoodsAdapter(RecordingDryRunAdapter):
) )
class ResolvingDryRunAdapter(RecordingDryRunAdapter):
"""首次选择报告尺码不存在,随后记录审计候选的精确应用。"""
def __init__(self) -> None:
super().__init__()
self.snapshot = PurchaseSpecCandidateSnapshot.build(
goods_id=self.goods_id,
selected_color="黑色",
target_size="L",
dimension_name="尺码",
observations=(
PurchaseSizeObservation("120斤", True),
PurchaseSizeObservation("130斤", True),
),
observed_at="2026-08-17T08:00:00Z",
)
def select_options(self, options) -> None:
self.calls.append(("select_options", dict(options)))
self.options = {
key: value for key, value in options.items() if key != "size"
}
raise PddPurchaseSpecResolutionRequired(self.snapshot)
def apply_resolved_size(self, expected_snapshot, candidate) -> None:
self.calls.append(
("apply_resolved_size", expected_snapshot.candidate_snapshot_hash,
candidate.candidate_id, candidate.raw_text)
)
self.options["size"] = candidate.raw_text
class ResolvingLiveAdapter(ResolvingDryRunAdapter, PddLivePurchaseAdapter):
def __init__(self) -> None:
super().__init__()
self.submit_count = 0
def update_shipping_address(self, purchase_number: str) -> None:
self.calls.append(("update_shipping_address", purchase_number))
def submit_order_once(self) -> None:
self.submit_count += 1
self.calls.append(("submit_order_once",))
class ChangedAfterResolutionAdapter(ResolvingDryRunAdapter):
def apply_resolved_size(self, expected_snapshot, candidate) -> None:
raise PddPurchaseError(
"PURCHASE_SPEC_CANDIDATES_CHANGED",
"等待期间候选变化",
step="purchase_resolve_options",
)
class PurchaseTaskServiceTest(unittest.TestCase): class PurchaseTaskServiceTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory() self.temp_dir = tempfile.TemporaryDirectory()
self.repository = TaskRepository( self.db_path = Path(self.temp_dir.name) / "client.db"
Path(self.temp_dir.name) / "client.db" self.repository = TaskRepository(self.db_path)
)
self.gateway = MockAdminGateway() self.gateway = MockAdminGateway()
self.client = ClientInfo("CLIENT-001", "测试客户端") self.client = ClientInfo("CLIENT-001", "测试客户端")
@@ -233,6 +295,229 @@ class PurchaseTaskServiceTest(unittest.TestCase):
self.assertFalse(purchase["order_submitted"]) self.assertFalse(purchase["order_submitted"])
self.assertFalse(purchase["payment_attempted"]) self.assertFalse(purchase["payment_attempted"])
def test_matched_spec_resolution_is_persisted_and_existing_flow_continues(self):
self._prepare_task(task_id="PUR-RESOLVE")
adapter = ResolvingDryRunAdapter()
candidate = adapter.snapshot.candidates[0]
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
"psr-001",
"matched",
"rule",
adapter.snapshot.candidate_snapshot_hash,
SpecResolutionMatch(
candidate.candidate_id,
candidate.raw_text,
candidate.options,
),
10000,
"唯一重量等价",
"2026-08-17T08:00:01Z",
)
)
outcome = self._service(adapter).execute_selected("PUR-RESOLVE")
self.assertEqual(outcome.kind, "succeeded")
self.assertTrue(any(call[0] == "apply_resolved_size" for call in adapter.calls))
detail = self.repository.get_task("PUR-RESOLVE")
purchase = detail.pdd_data["purchase"]
self.assertEqual(purchase["requested"]["options"], OPTIONS)
self.assertEqual(purchase["confirmed"]["options"]["size"], "120斤")
self.assertEqual(
purchase["spec_resolution"]["resolution_id"], "psr-001"
)
connection = open_database(self.db_path)
try:
row = connection.execute(
"SELECT status, resolution_id, outcome"
" FROM purchase_spec_resolutions"
).fetchone()
finally:
connection.close()
self.assertEqual(tuple(row), ("resolved", "psr-001", "matched"))
def test_uncertain_spec_resolution_stops_before_quantity_and_submission(self):
self._prepare_task(task_id="PUR-UNCERTAIN")
adapter = ResolvingDryRunAdapter()
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
"psr-uncertain",
"uncertain",
"ai",
adapter.snapshot.candidate_snapshot_hash,
None,
7000,
"无法唯一判断",
"2026-08-17T08:00:01Z",
)
)
outcome = self._service(adapter).execute_selected("PUR-UNCERTAIN")
self.assertEqual(outcome.kind, "task_failed")
self.assertFalse(any(call[0] == "apply_resolved_size" for call in adapter.calls))
self.assertNotIn(("set_quantity", 2), adapter.calls)
detail = self.repository.get_task("PUR-UNCERTAIN")
self.assertEqual(
detail.last_error_code, "PURCHASE_SPEC_RESOLUTION_UNCERTAIN"
)
def test_rejected_and_failed_spec_resolution_use_stable_failure_codes(self):
for outcome, expected_code in (
("rejected", "PURCHASE_SPEC_RESOLUTION_REJECTED"),
("failed", "PURCHASE_SPEC_RESOLUTION_FAILED"),
):
with self.subTest(outcome=outcome):
task_id = f"PUR-{outcome.upper()}"
self._prepare_task(task_id=task_id)
adapter = ResolvingDryRunAdapter()
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
f"psr-{outcome}",
outcome,
None,
adapter.snapshot.candidate_snapshot_hash,
None,
None,
"没有可安全采用的候选",
"2026-08-17T08:00:01Z",
)
)
result = self._service(adapter).execute_selected(task_id)
self.assertEqual(result.kind, "task_failed")
self.assertEqual(
self.repository.get_task(task_id).last_error_code,
expected_code,
)
self.assertFalse(
any(call[0] == "apply_resolved_size" for call in adapter.calls)
)
def test_live_resolution_keeps_original_request_and_existing_safety_gates(self):
self._prepare_task(task_id="PUR-RESOLVE-LIVE", execution_mode="live")
adapter = ResolvingLiveAdapter()
candidate = adapter.snapshot.candidates[0]
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
"psr-live",
"matched",
"ai",
adapter.snapshot.candidate_snapshot_hash,
SpecResolutionMatch(
candidate.candidate_id,
candidate.raw_text,
candidate.options,
),
9300,
"唯一候选",
"2026-08-17T08:00:01Z",
)
)
outcome = self._service(adapter).execute_selected("PUR-RESOLVE-LIVE")
self.assertEqual(outcome.kind, "reconcile_pending")
self.assertEqual(adapter.submit_count, 1)
task = self.repository.get_task("PUR-RESOLVE-LIVE")
run = self.repository.latest_task_run("PUR-RESOLVE-LIVE")
self.assertIsNotNone(run.irreversible_action_at)
self.assertEqual(
run.diagnostics_json["final_confirmation"]["options"]["size"],
"120斤",
)
self.assertEqual(
run.diagnostics_json["final_confirmation"]["spec_resolution"]
["resolution_id"],
"psr-live",
)
query = PurchaseReconcileService._query(task, run)
self.assertEqual(query.original_options, OPTIONS)
self.assertEqual(query.options["size"], "120斤")
self.assertEqual(query.spec_resolution["resolution_id"], "psr-live")
result = PurchaseReconcileService._result_data(
SimpleNamespace(_device_address="127.0.0.1:5555"),
task,
query,
PurchaseOrderCandidate(
order_no="ORDER-001",
ordered_at="2026-08-17T08:00:03Z",
ordered_at_raw="2026-08-17 16:00:03",
payment_status="unpaid",
),
)
self.assertEqual(result["purchase"]["requested"]["options"], OPTIONS)
self.assertEqual(
result["purchase"]["confirmed"]["options"]["size"], "120斤"
)
self.assertEqual(
result["purchase"]["spec_resolution"]["resolution_id"],
"psr-live",
)
def test_spec_resolution_timeout_keeps_persisted_request_and_does_not_retry(self):
self._prepare_task(task_id="PUR-RESOLVE-TIMEOUT")
adapter = ResolvingDryRunAdapter()
self.gateway.timeout_next_call()
outcome = self._service(adapter).execute_selected(
"PUR-RESOLVE-TIMEOUT"
)
self.assertEqual(outcome.kind, "task_failed")
self.assertEqual(self.gateway.spec_resolution_count, 0)
connection = open_database(self.db_path)
try:
row = connection.execute(
"SELECT status, idempotency_key, request_json"
" FROM purchase_spec_resolutions"
).fetchone()
finally:
connection.close()
self.assertEqual(row["status"], "pending")
self.assertTrue(row["idempotency_key"].startswith("spec-resolution-v1:"))
self.assertIn('"candidate_snapshot_hash"', row["request_json"])
def test_matched_resolution_page_change_failure_keeps_resolution_audit(self):
self._prepare_task(task_id="PUR-RESOLVE-CHANGED")
adapter = ChangedAfterResolutionAdapter()
candidate = adapter.snapshot.candidates[0]
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
"psr-changed",
"matched",
"ai",
adapter.snapshot.candidate_snapshot_hash,
SpecResolutionMatch(
candidate.candidate_id,
candidate.raw_text,
candidate.options,
),
9000,
"模型匹配",
"2026-08-17T08:00:01Z",
)
)
outcome = self._service(adapter).execute_selected(
"PUR-RESOLVE-CHANGED"
)
self.assertEqual(outcome.kind, "task_failed")
run = self.repository.latest_task_run("PUR-RESOLVE-CHANGED")
self.assertEqual(
run.diagnostics_json["spec_resolution"]["resolution_id"],
"psr-changed",
)
self.assertIsNone(run.irreversible_action_at)
def test_started_callback_runs_after_database_enters_running(self): def test_started_callback_runs_after_database_enters_running(self):
self._prepare_task(task_id="PUR-STARTED") self._prepare_task(task_id="PUR-STARTED")
observed = [] observed = []
+54
View File
@@ -114,6 +114,60 @@ class TaskRepositoryTests(unittest.TestCase):
self.assertEqual(detail.admin_payload["task_id"], "TASK-001") self.assertEqual(detail.admin_payload["task_id"], "TASK-001")
self.assertEqual(detail.pdd_data["schema_version"], 1) self.assertEqual(detail.pdd_data["schema_version"], 1)
def test_purchase_spec_resolution_request_is_persisted_before_response(self):
self.repository.add_claimed_task(
self._task("PUR-SPEC", TaskType.PURCHASE)
)
started = self.repository.start_purchase_run("PUR-SPEC", "USB-001")
request = {
"schema_version": 1,
"candidate_snapshot_hash": "a" * 64,
"candidates": [{"candidate_id": "c1", "raw_text": "L"}],
}
first = self.repository.prepare_purchase_spec_resolution(
"PUR-SPEC", started.attempt_id, "spec-resolution-v1:key", request
)
second = self.repository.prepare_purchase_spec_resolution(
"PUR-SPEC", started.attempt_id, "spec-resolution-v1:key", request
)
self.assertEqual(first.id, second.id)
self.assertEqual(first.status, "pending")
self.assertEqual(first.request_json, request)
saved = self.repository.save_purchase_spec_resolution(
first.id,
{
"resolution_id": "psr-001",
"outcome": "matched",
"source": "rule",
"confidence_bps": 10000,
"match": {
"candidate_id": "c1",
"raw_text": "L",
"options": {"color": "黑色", "size": "L"},
},
"reason": "唯一匹配",
"resolved_at": "2026-08-17T08:00:01Z",
},
)
self.assertEqual(saved.status, "resolved")
self.assertEqual(saved.resolution_id, "psr-001")
self.assertEqual(saved.match_options["size"], "L")
with self.assertRaisesRegex(ValueError, "不同响应"):
self.repository.save_purchase_spec_resolution(
first.id,
{
"resolution_id": "psr-other",
"outcome": "rejected",
"source": None,
"confidence_bps": None,
"match": None,
"reason": "内容变化",
"resolved_at": "2026-08-17T08:00:02Z",
},
)
def test_live_mode_and_irreversible_marker_are_persisted_once(self) -> None: def test_live_mode_and_irreversible_marker_are_persisted_once(self) -> None:
self.repository.add_claimed_task( self.repository.add_claimed_task(
self._task( self._task(
+34 -4
View File
@@ -278,8 +278,7 @@ CREATE INDEX idx_task_runs_task
[接口契约 §7.1](04-admin-api-contract.md) 定义了一个与 `task_runs.attempt_id` 绑定的一次性 [接口契约 §7.1](04-admin-api-contract.md) 定义了一个与 `task_runs.attempt_id` 绑定的一次性
规格解析命令。Admin 的 `purchase_spec_resolutions` 是服务端候选观察和最终决策的权威 规格解析命令。Admin 的 `purchase_spec_resolutions` 是服务端候选观察和最终决策的权威
审计;Client 本地仍必须在发送前保存以下最小信息,后续由工单 #257 增加 SQLite migration 审计;Client 本地仍必须在发送前保存以下最小信息:
和 Repository:
- `task_id`(本地外键)与 `attempt_id`; - `task_id`(本地外键)与 `attempt_id`;
- 完整且大小受限的请求 JSON、确定性 `Idempotency-Key` 和请求哈希; - 完整且大小受限的请求 JSON、确定性 `Idempotency-Key` 和请求哈希;
@@ -292,8 +291,39 @@ CREATE INDEX idx_task_runs_task
`task_runs` 的执行期有效规格。进入过 `irreversible_action_at` 的运行不得新建或重放解析 `task_runs` 的执行期有效规格。进入过 `irreversible_action_at` 的运行不得新建或重放解析
以继续采购,只能核对订单。 以继续采购,只能核对订单。
#254 只冻结接口和 Admin 审计模型,当前 Client SQLite schema 不在本工单改动。未实现 Client 使用 `purchase_spec_resolutions` 保存这些执行期记录:
#257 的旧 Client 继续按“规格不匹配即失败”运行,不调用新接口,也不会出现半持久化状态。
```sql
CREATE TABLE purchase_spec_resolutions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
attempt_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE,
request_json TEXT NOT NULL,
request_hash TEXT NOT NULL,
candidate_snapshot_hash TEXT NOT NULL,
status TEXT NOT NULL, -- pending / resolved
resolution_id TEXT,
outcome TEXT,
source TEXT,
confidence_bps INTEGER,
match_candidate_id TEXT,
match_raw_text TEXT,
match_options_json TEXT,
reason TEXT,
resolved_at TEXT,
received_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES pdd_tasks(id) ON DELETE CASCADE,
UNIQUE (task_id, attempt_id, candidate_snapshot_hash)
);
```
`pending` 表示请求已落库但尚未得到可验证的业务响应,不能据此继续采购;`resolved`
表示完整响应已经落库,仍须通过候选白名单和真机快照复核。网络调用不放在 SQLite
事务中。旧任务原始规格不覆盖,最终结果分别保存原始请求规格、实际采用规格和
`resolution_id`。
## 5. `outbox_events` ## 5. `outbox_events`
+6
View File
@@ -115,6 +115,12 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode`
当前前台应用,避免在部分真机上重复等待十秒以上。最终下单点击前仍必须单独 当前前台应用,避免在部分真机上重复等待十秒以上。最终下单点击前仍必须单独
查询前台应用。性能日志分别记录状态读取、颜色选择和尺码选择耗时。 查询前台应用。性能日志分别记录状态读取、颜色选择和尺码选择耗时。
- 规格选择后读取最新控件树并验证选中状态。 - 规格选择后读取最新控件树并验证选中状态。
- 运行时规格解析请求必须在网络发送前写入独立 SQLite 记录;只调用一次专用命令,
不轮询 Admin。只有 `matched` 响应的哈希、候选编号、原文和 options 都是本次候选的
逐字副本时才继续。响应返回后重新完整遍历真机候选并复算哈希,页面、颜色或候选
有任何变化都在可逆阶段停止。
- 解析记录不能覆盖任务原始规格。成功结果同时保留原始规格、实际采用规格和
`resolution_id`;解析失败不自动重新采购,已有不可逆标记时永远不调用规格解析。
- 设置采购数量时先读取当前值;数量相同不聚焦输入框,小差值优先使用加减按钮。 - 设置采购数量时先读取当前值;数量相同不聚焦输入框,小差值优先使用加减按钮。
只有输入框兜底路径确认输入法已经显示时才允许按一次返回键;输入法关闭后必须 只有输入框兜底路径确认输入法已经显示时才允许按一次返回键;输入法关闭后必须
重新核对规格、数量、价格和唯一提交目标,规格面板丢失时立即停止。 重新核对规格、数量、价格和唯一提交目标,规格面板丢失时立即停止。