"""任务领取、续租和单张证据上传的不可变值对象。""" 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=[已隐藏])"