feat: 安全应用采购规格解析结果 (#257)
This commit is contained in:
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user