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
+249 -6
View File
@@ -6,15 +6,26 @@
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import json
from dataclasses import dataclass, replace
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 (
PddLivePurchaseAdapter,
PddPurchaseAdapter,
PddPurchaseError,
PddPurchaseSpecResolutionRequired,
PurchaseSpecCandidate,
PurchaseSpecCandidateSnapshot,
PurchasePageState,
)
from .task_models import OutboxEventRecord, OutboxEventType, TaskStatus
@@ -38,6 +49,8 @@ class PurchaseTarget:
options: Mapping[str, str]
quantity: int
max_price_cent: int # 人民币订单总价上限,单位分
original_options: Optional[Mapping[str, str]] = None
spec_resolution: Optional[Mapping[str, object]] = None
@dataclass(frozen=True)
@@ -121,6 +134,7 @@ class PurchaseTaskService:
)
self._started(remote_task_id)
adapter: PddPurchaseAdapter | None = None
target: PurchaseTarget | None = None
step = "purchase_prepare"
failure_message = ""
failure_outcome_kind = ""
@@ -149,7 +163,19 @@ class PurchaseTaskService:
step = "purchase_select_options"
self._enter_step(remote_task_id, started.attempt_id, step)
self._validate_identity(adapter.read_state(), target, "goods")
adapter.select_options(target.options)
try:
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"
self._enter_step(remote_task_id, started.attempt_id, step)
@@ -216,6 +242,9 @@ class PurchaseTaskService:
failure_outcome_kind = "global_failed"
else:
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(
remote_task_id,
started.attempt_id,
@@ -223,7 +252,7 @@ class PurchaseTaskService:
exc.message,
exc.retryable,
step,
exc.diagnostics,
diagnostics,
)
except Exception as exc:
failure_message = f"采购在“{step}”发生未知错误:{exc}"
@@ -277,6 +306,9 @@ class PurchaseTaskService:
"quantity": state.quantity,
"unit_price_cent": state.price_cent,
"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
@@ -314,6 +346,214 @@ class PurchaseTaskService:
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(
self, remote_task_id: str, attempt_id: str, step: str
) -> None:
@@ -619,7 +859,7 @@ class PurchaseTaskService:
"purchase": {
"mode": "dry_run",
"requested": {
"options": dict(target.options),
"options": dict(target.original_options or target.options),
"quantity": target.quantity,
"max_price_cent": target.max_price_cent,
},
@@ -636,6 +876,9 @@ class PurchaseTaskService:
"ordered_at": None,
"ordered_at_raw": None,
"match_status": "not_submitted",
"spec_resolution": dict(target.spec_resolution)
if target.spec_resolution is not None
else None,
},
"captured_at": utc_now_iso(),
"source": {