329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""任务领取、续租和单张证据上传的不可变值对象。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
import hashlib
|
||
from pathlib import Path
|
||
|
||
from .errors import ValidationError
|
||
from .validation import (
|
||
MAX_SKU_TEXT_CODE_POINTS,
|
||
MAX_TITLE_CODE_POINTS,
|
||
canonical_product_url,
|
||
require_exact_fields,
|
||
require_goods_id,
|
||
require_lower_hex_64,
|
||
require_money,
|
||
require_persisted_text,
|
||
require_positive_int,
|
||
require_rfc3339_z,
|
||
rfc3339_z_nanoseconds,
|
||
require_string,
|
||
require_uuid4,
|
||
)
|
||
|
||
|
||
EVIDENCE_KIND = "SKU_PANEL_GATE_1"
|
||
PRIVACY_TIER = "INTERNAL_RAW"
|
||
|
||
|
||
@dataclass(frozen=True, repr=False)
|
||
class SecretToken:
|
||
"""64 位小写 token;repr 永不暴露明文。"""
|
||
|
||
value: str
|
||
|
||
def __post_init__(self) -> None:
|
||
require_lower_hex_64(self.value, "invalid_token")
|
||
|
||
def __repr__(self) -> str:
|
||
return "SecretToken([已隐藏])"
|
||
|
||
def __str__(self) -> str:
|
||
return "[已隐藏]"
|
||
|
||
|
||
@dataclass(frozen=True, repr=False)
|
||
class DeviceCredentials:
|
||
device_id: str
|
||
token: SecretToken
|
||
|
||
def __post_init__(self) -> None:
|
||
require_uuid4(self.device_id, "invalid_device_id")
|
||
if not isinstance(self.token, SecretToken):
|
||
raise ValidationError("invalid_device_token")
|
||
|
||
def __repr__(self) -> str:
|
||
return f"DeviceCredentials(device_id={self.device_id!r}, token=[已隐藏])"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ClaimRequest:
|
||
session_id: str
|
||
claim_request_id: str
|
||
|
||
def __post_init__(self) -> None:
|
||
require_uuid4(self.session_id, "invalid_session_id")
|
||
require_uuid4(self.claim_request_id, "invalid_claim_request_id")
|
||
|
||
def to_wire(self) -> dict[str, object]:
|
||
return {"session_id": self.session_id, "claim_request_id": self.claim_request_id}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PurchaseTask:
|
||
id: str
|
||
version: int
|
||
title: str
|
||
product_url: str
|
||
goods_id: str
|
||
sku_color: str
|
||
sku_size: str
|
||
quantity: int
|
||
max_total_price: str
|
||
|
||
def __post_init__(self) -> None:
|
||
require_uuid4(self.id, "invalid_task_id")
|
||
require_positive_int(self.version, "invalid_task_version")
|
||
require_persisted_text(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS)
|
||
require_goods_id(self.goods_id)
|
||
if self.product_url != canonical_product_url(self.goods_id):
|
||
raise ValidationError("invalid_product_url")
|
||
require_persisted_text(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS)
|
||
require_persisted_text(self.sku_size, "invalid_sku_size", maximum=MAX_SKU_TEXT_CODE_POINTS)
|
||
require_positive_int(self.quantity, "invalid_quantity")
|
||
require_money(self.max_total_price, "invalid_max_total_price")
|
||
|
||
@classmethod
|
||
def from_wire(cls, value: object) -> "PurchaseTask":
|
||
data = require_exact_fields(
|
||
value,
|
||
("id", "version", "title", "product_url", "goods_id", "sku_color", "sku_size", "quantity", "max_total_price"),
|
||
)
|
||
return cls(**data) # type: ignore[arg-type]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AuthorizationSnapshot:
|
||
id: str
|
||
task_version: int
|
||
expires_at: str
|
||
|
||
def __post_init__(self) -> None:
|
||
require_uuid4(self.id, "invalid_authorization_id")
|
||
require_positive_int(self.task_version, "invalid_authorization_task_version")
|
||
require_rfc3339_z(self.expires_at, "invalid_authorization_expiry")
|
||
|
||
@classmethod
|
||
def from_wire(cls, value: object) -> "AuthorizationSnapshot":
|
||
data = require_exact_fields(value, ("id", "task_version", "expires_at"))
|
||
return cls(**data) # type: ignore[arg-type]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AttemptSnapshot:
|
||
id: str
|
||
claim_token: SecretToken
|
||
claim_generation: int
|
||
lease_expires_at: str
|
||
|
||
def __post_init__(self) -> None:
|
||
require_uuid4(self.id, "invalid_attempt_id")
|
||
if not isinstance(self.claim_token, SecretToken):
|
||
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
|
||
require_positive_int(self.claim_generation, "invalid_claim_generation")
|
||
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
|
||
|
||
@classmethod
|
||
def from_wire(cls, value: object) -> "AttemptSnapshot":
|
||
data = require_exact_fields(value, ("id", "claim_token", "claim_generation", "lease_expires_at"))
|
||
return cls(
|
||
id=data["id"], # type: ignore[arg-type]
|
||
claim_token=SecretToken(data["claim_token"]), # type: ignore[arg-type]
|
||
claim_generation=data["claim_generation"], # type: ignore[arg-type]
|
||
lease_expires_at=data["lease_expires_at"], # type: ignore[arg-type]
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ClaimedTask:
|
||
task: PurchaseTask
|
||
authorization: AuthorizationSnapshot
|
||
attempt: AttemptSnapshot = field(repr=False)
|
||
|
||
def __post_init__(self) -> None:
|
||
if self.task.version != self.authorization.task_version + 1:
|
||
raise ValidationError("task_authorization_version_mismatch")
|
||
if rfc3339_z_nanoseconds(self.attempt.lease_expires_at) > rfc3339_z_nanoseconds(self.authorization.expires_at):
|
||
raise ValidationError("claim_lease_exceeds_authorization")
|
||
|
||
@classmethod
|
||
def from_wire(cls, value: object) -> "ClaimedTask":
|
||
data = require_exact_fields(value, ("task", "authorization", "attempt"))
|
||
return cls(
|
||
task=PurchaseTask.from_wire(data["task"]),
|
||
authorization=AuthorizationSnapshot.from_wire(data["authorization"]),
|
||
attempt=AttemptSnapshot.from_wire(data["attempt"]),
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True, repr=False)
|
||
class RenewRequest:
|
||
task_id: str
|
||
renew_request_id: str
|
||
session_id: str
|
||
attempt_id: str
|
||
claim_generation: int
|
||
claim_token: SecretToken
|
||
expected_lease_expires_at: str
|
||
authorization_expires_at: str
|
||
|
||
def __post_init__(self) -> None:
|
||
require_uuid4(self.task_id, "invalid_task_id")
|
||
require_uuid4(self.renew_request_id, "invalid_renew_request_id")
|
||
require_uuid4(self.session_id, "invalid_session_id")
|
||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||
require_positive_int(self.claim_generation, "invalid_claim_generation")
|
||
if not isinstance(self.claim_token, SecretToken):
|
||
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
|
||
require_rfc3339_z(self.expected_lease_expires_at, "invalid_expected_lease_expiry")
|
||
require_rfc3339_z(self.authorization_expires_at, "invalid_authorization_expiry")
|
||
|
||
def __repr__(self) -> str:
|
||
return (
|
||
f"RenewRequest(task_id={self.task_id!r}, renew_request_id={self.renew_request_id!r}, "
|
||
"claim_token=[已隐藏])"
|
||
)
|
||
|
||
def to_wire(self) -> dict[str, object]:
|
||
return {
|
||
"renew_request_id": self.renew_request_id,
|
||
"session_id": self.session_id,
|
||
"attempt_id": self.attempt_id,
|
||
"claim_generation": self.claim_generation,
|
||
"claim_token": self.claim_token.value,
|
||
"expected_lease_expires_at": self.expected_lease_expires_at,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RenewResult:
|
||
task_id: str
|
||
attempt_id: str
|
||
claim_generation: int
|
||
lease_expires_at: str
|
||
|
||
def __post_init__(self) -> None:
|
||
require_uuid4(self.task_id, "invalid_task_id")
|
||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||
require_positive_int(self.claim_generation, "invalid_claim_generation")
|
||
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
|
||
|
||
@classmethod
|
||
def from_wire(cls, value: object) -> "RenewResult":
|
||
data = require_exact_fields(value, ("task_id", "attempt_id", "claim_generation", "lease_expires_at"))
|
||
return cls(**data) # type: ignore[arg-type]
|
||
|
||
|
||
@dataclass(frozen=True, repr=False)
|
||
class EvidenceUpload:
|
||
task_id: str
|
||
upload_key: str
|
||
attempt_id: str
|
||
sha256: str
|
||
captured_at: str
|
||
content: bytes = field(repr=False)
|
||
kind: str = EVIDENCE_KIND
|
||
privacy_tier: str = PRIVACY_TIER
|
||
width_px: int = field(init=False)
|
||
height_px: int = field(init=False)
|
||
|
||
def __post_init__(self) -> None:
|
||
require_uuid4(self.task_id, "invalid_task_id")
|
||
require_uuid4(self.upload_key, "invalid_upload_key")
|
||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||
require_lower_hex_64(self.sha256, "invalid_evidence_sha256")
|
||
require_rfc3339_z(self.captured_at, "invalid_captured_at")
|
||
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
|
||
raise ValidationError("invalid_evidence_metadata")
|
||
if not isinstance(self.content, bytes) or not self.content or len(self.content) > 10 * 1024 * 1024:
|
||
raise ValidationError("invalid_evidence_size")
|
||
if len(self.content) < 24 or not self.content.startswith(b"\x89PNG\r\n\x1a\n") or self.content[12:16] != b"IHDR":
|
||
raise ValidationError("invalid_evidence_png")
|
||
width = int.from_bytes(self.content[16:20], "big")
|
||
height = int.from_bytes(self.content[20:24], "big")
|
||
if width <= 0 or height <= 0 or width > 8192 or height > 8192 or width * height > 16_777_216:
|
||
raise ValidationError("invalid_evidence_dimensions")
|
||
object.__setattr__(self, "width_px", width)
|
||
object.__setattr__(self, "height_px", height)
|
||
if hashlib.sha256(self.content).hexdigest() != self.sha256:
|
||
raise ValidationError("evidence_hash_mismatch")
|
||
|
||
def __repr__(self) -> str:
|
||
return (
|
||
f"EvidenceUpload(task_id={self.task_id!r}, upload_key={self.upload_key!r}, "
|
||
f"attempt_id={self.attempt_id!r}, byte_size={len(self.content)})"
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AssetReceipt:
|
||
asset_id: str
|
||
task_id: str
|
||
attempt_id: str
|
||
kind: str
|
||
privacy_tier: str
|
||
sha256: str
|
||
byte_size: int
|
||
content_type: str
|
||
width_px: int
|
||
height_px: int
|
||
captured_at: str
|
||
|
||
def __post_init__(self) -> None:
|
||
require_uuid4(self.asset_id, "invalid_asset_id")
|
||
require_uuid4(self.task_id, "invalid_task_id")
|
||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
|
||
raise ValidationError("invalid_asset_metadata")
|
||
require_lower_hex_64(self.sha256, "invalid_asset_sha256")
|
||
require_positive_int(self.byte_size, "invalid_asset_byte_size")
|
||
if self.byte_size > 10 * 1024 * 1024 or self.content_type != "image/png":
|
||
raise ValidationError("invalid_asset_content")
|
||
width = require_positive_int(self.width_px, "invalid_asset_width")
|
||
height = require_positive_int(self.height_px, "invalid_asset_height")
|
||
if width > 8192 or height > 8192 or width * height > 16_777_216:
|
||
raise ValidationError("invalid_asset_dimensions")
|
||
require_rfc3339_z(self.captured_at, "invalid_captured_at")
|
||
|
||
@classmethod
|
||
def from_wire(cls, value: object) -> "AssetReceipt":
|
||
data = require_exact_fields(
|
||
value,
|
||
("asset_id", "task_id", "attempt_id", "kind", "privacy_tier", "sha256", "byte_size", "content_type", "width_px", "height_px", "captured_at"),
|
||
)
|
||
return cls(**data) # type: ignore[arg-type]
|
||
|
||
|
||
@dataclass(frozen=True, repr=False)
|
||
class ScreenshotAsset:
|
||
"""调用方显式选择的唯一 PNG;路径不会进入 repr 或 HTTP。"""
|
||
|
||
path: Path = field(repr=False)
|
||
task_id: str
|
||
attempt_id: str
|
||
captured_at: str
|
||
kind: str = EVIDENCE_KIND
|
||
privacy_tier: str = PRIVACY_TIER
|
||
|
||
def __post_init__(self) -> None:
|
||
require_uuid4(self.task_id, "invalid_task_id")
|
||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||
require_rfc3339_z(self.captured_at, "invalid_captured_at")
|
||
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
|
||
raise ValidationError("invalid_evidence_metadata")
|
||
|
||
def __repr__(self) -> str:
|
||
return f"ScreenshotAsset(task_id={self.task_id!r}, attempt_id={self.attempt_id!r}, path=[已隐藏])"
|