Compare commits
5
Commits
8600c33391
...
cfb238d022
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfb238d022 | ||
|
|
31f07ac245 | ||
|
|
488005ac93 | ||
|
|
4186f1315a | ||
|
|
3a0a41db00 |
@@ -0,0 +1,44 @@
|
||||
"""与 UI、HTTP 和拼多多页面实现无关的客户端核心契约。"""
|
||||
|
||||
from .errors import (
|
||||
AmbiguousRemoteError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
StateError,
|
||||
ValidationError,
|
||||
)
|
||||
from .models import (
|
||||
AssetReceipt,
|
||||
AuthorizationSnapshot,
|
||||
ClaimRequest,
|
||||
ClaimedTask,
|
||||
DeviceCredentials,
|
||||
EvidenceUpload,
|
||||
PurchaseTask,
|
||||
RenewRequest,
|
||||
RenewResult,
|
||||
SecretToken,
|
||||
)
|
||||
from .ports import EvidenceSink, TaskSource
|
||||
|
||||
__all__ = [
|
||||
"AmbiguousRemoteError",
|
||||
"AssetReceipt",
|
||||
"AuthorizationSnapshot",
|
||||
"ClaimRequest",
|
||||
"ClaimedTask",
|
||||
"CredentialRemoteError",
|
||||
"DeviceCredentials",
|
||||
"EvidenceSink",
|
||||
"EvidenceUpload",
|
||||
"ManualRemoteError",
|
||||
"ProtocolRemoteError",
|
||||
"PurchaseTask",
|
||||
"RenewRequest",
|
||||
"RenewResult",
|
||||
"SecretToken",
|
||||
"StateError",
|
||||
"TaskSource",
|
||||
"ValidationError",
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""可安全呈现的客户端错误分类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ClientError(RuntimeError):
|
||||
"""错误文本只使用固定 reason code,不携带凭据、响应或本机路径。"""
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
self.reason = reason
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
class ValidationError(ClientError):
|
||||
"""本地输入或 wire schema 不满足固定契约。"""
|
||||
|
||||
|
||||
class StateError(ClientError):
|
||||
"""本地状态无法安全推进;调用方必须停止而不是绕过。"""
|
||||
|
||||
|
||||
class ProtectionError(ClientError):
|
||||
"""秘密保护失败。"""
|
||||
|
||||
|
||||
class SingleInstanceError(ClientError):
|
||||
"""同一配置已经由另一个采购工具进程持有。"""
|
||||
|
||||
|
||||
class RemoteError(ClientError):
|
||||
"""服务端调用的稳定错误分类。"""
|
||||
|
||||
|
||||
class AmbiguousRemoteError(RemoteError):
|
||||
"""请求结果不明;只允许以原幂等键、原载荷显式恢复。"""
|
||||
|
||||
|
||||
class CredentialRemoteError(RemoteError):
|
||||
"""设备凭据无效或已撤销。"""
|
||||
|
||||
|
||||
class ProtocolRemoteError(RemoteError):
|
||||
"""请求/响应与固定协议不兼容,不得自动重试。"""
|
||||
|
||||
|
||||
class ManualRemoteError(RemoteError):
|
||||
"""服务端要求人工处理的确定性冲突。"""
|
||||
@@ -0,0 +1,327 @@
|
||||
"""任务领取、续租和单张证据上传的不可变值对象。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import ValidationError
|
||||
from .validation import (
|
||||
canonical_product_url,
|
||||
require_exact_fields,
|
||||
require_goods_id,
|
||||
require_lower_hex_64,
|
||||
require_money,
|
||||
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_string(self.title, "invalid_task_title", maximum=32 * 1024)
|
||||
if not self.title.strip():
|
||||
raise ValidationError("invalid_task_title")
|
||||
require_goods_id(self.goods_id)
|
||||
if self.product_url != canonical_product_url(self.goods_id):
|
||||
raise ValidationError("invalid_product_url")
|
||||
require_string(self.sku_color, "invalid_sku_color", maximum=32 * 1024)
|
||||
require_string(self.sku_size, "invalid_sku_size", maximum=32 * 1024)
|
||||
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=[已隐藏])"
|
||||
@@ -0,0 +1,17 @@
|
||||
"""由 HTTP 适配器实现的窄端口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from .models import AssetReceipt, ClaimRequest, ClaimedTask, DeviceCredentials, EvidenceUpload, RenewRequest, RenewResult
|
||||
|
||||
|
||||
class TaskSource(Protocol):
|
||||
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
|
||||
|
||||
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
|
||||
|
||||
|
||||
class EvidenceSink(Protocol):
|
||||
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
|
||||
@@ -0,0 +1,175 @@
|
||||
"""客户端与服务端共享 wire 的严格值校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping
|
||||
from urllib.parse import quote
|
||||
|
||||
from .errors import ValidationError
|
||||
|
||||
|
||||
UUID4_RE = re.compile(
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||
)
|
||||
LOWER_HEX_64_RE = re.compile(r"[0-9a-f]{64}")
|
||||
RFC3339_Z_RE = re.compile(
|
||||
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z"
|
||||
)
|
||||
MONEY_RE = re.compile(r"(?:0|[1-9][0-9]*)\.[0-9]{2}")
|
||||
GOODS_ID_RE = re.compile(r"[0-9]+")
|
||||
|
||||
|
||||
def require_string(value: object, reason: str, *, maximum: int = 4096) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
||||
raise ValidationError(reason)
|
||||
if any(0xD800 <= ord(character) <= 0xDFFF for character in value):
|
||||
raise ValidationError(reason)
|
||||
return value
|
||||
|
||||
|
||||
def require_uuid4(value: object, reason: str = "invalid_uuid") -> str:
|
||||
text = require_string(value, reason, maximum=36)
|
||||
if UUID4_RE.fullmatch(text) is None:
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def require_lower_hex_64(value: object, reason: str = "invalid_hex") -> str:
|
||||
text = require_string(value, reason, maximum=64)
|
||||
if LOWER_HEX_64_RE.fullmatch(text) is None:
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def require_rfc3339_z(value: object, reason: str = "invalid_timestamp") -> str:
|
||||
text = require_string(value, reason, maximum=40)
|
||||
if RFC3339_Z_RE.fullmatch(text) is None:
|
||||
raise ValidationError(reason)
|
||||
parsed: datetime | None = None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text[:-1] + "+00:00")
|
||||
except ValueError:
|
||||
pass
|
||||
if parsed is None:
|
||||
raise ValidationError(reason)
|
||||
if parsed.utcoffset() is None or parsed.utcoffset().total_seconds() != 0:
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def rfc3339_z_nanoseconds(value: object, reason: str = "invalid_timestamp") -> int:
|
||||
"""无浮点、无微秒截断地把 UTC RFC3339Nano 转成纳秒时间轴。"""
|
||||
|
||||
text = require_rfc3339_z(value, reason)
|
||||
base: datetime | None = None
|
||||
try:
|
||||
base = datetime.strptime(text[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
pass
|
||||
if base is None:
|
||||
raise ValidationError(reason)
|
||||
fraction = "" if len(text) == 20 else text[20:-1]
|
||||
nanoseconds = int(fraction.ljust(9, "0")) if fraction else 0
|
||||
return calendar.timegm(base.utctimetuple()) * 1_000_000_000 + nanoseconds
|
||||
|
||||
|
||||
def datetime_nanoseconds(value: datetime, reason: str = "invalid_timestamp") -> int:
|
||||
if not isinstance(value, datetime) or value.utcoffset() is None:
|
||||
raise ValidationError(reason)
|
||||
utc = value.astimezone(timezone.utc)
|
||||
return calendar.timegm(utc.utctimetuple()) * 1_000_000_000 + utc.microsecond * 1_000
|
||||
|
||||
|
||||
def require_positive_int(value: object, reason: str = "invalid_integer") -> int:
|
||||
# bool 是 int 的子类;wire 中必须显式拒绝 true/false。
|
||||
if type(value) is not int or value <= 0 or value > 9_223_372_036_854_775_807:
|
||||
raise ValidationError(reason)
|
||||
return value
|
||||
|
||||
|
||||
def require_money(value: object, reason: str = "invalid_money") -> str:
|
||||
text = require_string(value, reason, maximum=32 * 1024)
|
||||
if MONEY_RE.fullmatch(text) is None or text == "0.00":
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def require_goods_id(value: object) -> str:
|
||||
text = require_string(value, "invalid_goods_id", maximum=32 * 1024)
|
||||
if GOODS_ID_RE.fullmatch(text) is None:
|
||||
raise ValidationError("invalid_goods_id")
|
||||
return text
|
||||
|
||||
|
||||
def canonical_product_url(goods_id: str) -> str:
|
||||
require_goods_id(goods_id)
|
||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + quote(goods_id, safe="")
|
||||
|
||||
|
||||
def require_exact_fields(
|
||||
value: object,
|
||||
required: Iterable[str],
|
||||
reason: str = "invalid_schema",
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError(reason)
|
||||
expected = frozenset(required)
|
||||
if frozenset(value) != expected:
|
||||
raise ValidationError(reason)
|
||||
return value
|
||||
|
||||
|
||||
def strict_json_loads(raw: bytes, *, maximum: int) -> object:
|
||||
if not isinstance(raw, bytes) or len(raw) == 0 or len(raw) > maximum:
|
||||
raise ValidationError("invalid_json_size")
|
||||
text: str | None = None
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
if text is None:
|
||||
raise ValidationError("invalid_json_utf8")
|
||||
if text.startswith("\ufeff"):
|
||||
raise ValidationError("invalid_json_bom")
|
||||
|
||||
def pairs_hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValidationError("duplicate_json_key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def reject_number(_: str) -> object:
|
||||
raise ValidationError("invalid_json_number")
|
||||
|
||||
def parse_integer(value: str) -> int:
|
||||
digits = value[1:] if value.startswith("-") else value
|
||||
if len(digits) > 19:
|
||||
raise ValidationError("invalid_json_integer")
|
||||
parsed = int(value)
|
||||
if parsed < -9_223_372_036_854_775_808 or parsed > 9_223_372_036_854_775_807:
|
||||
raise ValidationError("invalid_json_integer")
|
||||
return parsed
|
||||
|
||||
parsed_json: object | None = None
|
||||
failed = False
|
||||
try:
|
||||
parsed_json = json.loads(
|
||||
text,
|
||||
object_pairs_hook=pairs_hook,
|
||||
parse_int=parse_integer,
|
||||
parse_float=reject_number,
|
||||
parse_constant=reject_number,
|
||||
)
|
||||
except ValidationError:
|
||||
raise
|
||||
except (json.JSONDecodeError, UnicodeError, ValueError, RecursionError):
|
||||
failed = True
|
||||
if failed:
|
||||
raise ValidationError("invalid_json")
|
||||
return parsed_json
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Windows 本地恢复、凭据保护和单实例底座。"""
|
||||
|
||||
from .models import PollingSession, ProfileSettings, RecoverySnapshot
|
||||
from .facade import DurableClientGateway
|
||||
from .protection import DpapiProtector, SecretProtector
|
||||
from .single_instance import NamedMutex
|
||||
from .store import LocalStateStore
|
||||
|
||||
__all__ = [
|
||||
"DpapiProtector",
|
||||
"DurableClientGateway",
|
||||
"LocalStateStore",
|
||||
"NamedMutex",
|
||||
"PollingSession",
|
||||
"ProfileSettings",
|
||||
"RecoverySnapshot",
|
||||
"SecretProtector",
|
||||
]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""把“先持久化,再发一次 HTTP”固化成 T-304/T-306 的唯一集成入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cmbuyer_client.core.errors import (
|
||||
AmbiguousRemoteError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
)
|
||||
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, ScreenshotAsset
|
||||
from cmbuyer_client.core.ports import EvidenceSink, TaskSource
|
||||
|
||||
from .store import LocalStateStore
|
||||
|
||||
|
||||
class DurableClientGateway:
|
||||
"""不隐藏重试;每次方法调用最多发一次请求,结果不明保留原槽。"""
|
||||
|
||||
def __init__(self, store: LocalStateStore, task_source: TaskSource, evidence_sink: EvidenceSink) -> None:
|
||||
self._store = store
|
||||
self._task_source = task_source
|
||||
self._evidence_sink = evidence_sink
|
||||
|
||||
def claim_next(self, profile_id: str) -> ClaimedTask | None:
|
||||
request = self._store.prepare_claim(profile_id)
|
||||
credentials = self._store.load_profile(profile_id).credentials
|
||||
try:
|
||||
claimed = self._task_source.claim_next(credentials, request)
|
||||
except AmbiguousRemoteError:
|
||||
raise
|
||||
except CredentialRemoteError:
|
||||
raise
|
||||
except ProtocolRemoteError:
|
||||
self._store.mark_claim_terminal(profile_id, request, "PROTOCOL")
|
||||
raise
|
||||
except ManualRemoteError:
|
||||
self._store.mark_claim_terminal(profile_id, request, "MANUAL")
|
||||
raise
|
||||
if claimed is None:
|
||||
self._store.commit_claim_empty(profile_id, request)
|
||||
return None
|
||||
self._store.commit_claim_success(profile_id, request, claimed)
|
||||
return claimed
|
||||
|
||||
def renew(self, profile_id: str):
|
||||
request = self._store.prepare_renew(profile_id)
|
||||
credentials = self._store.load_profile(profile_id).credentials
|
||||
try:
|
||||
result = self._task_source.renew(credentials, request)
|
||||
except AmbiguousRemoteError:
|
||||
raise
|
||||
except CredentialRemoteError:
|
||||
raise
|
||||
except ProtocolRemoteError:
|
||||
self._store.mark_renew_terminal(profile_id, request, "PROTOCOL")
|
||||
raise
|
||||
except ManualRemoteError:
|
||||
self._store.mark_renew_terminal(profile_id, request, "MANUAL")
|
||||
raise
|
||||
self._store.commit_renew_success(profile_id, request, result)
|
||||
return result
|
||||
|
||||
def upload_evidence(self, profile_id: str, asset: ScreenshotAsset) -> AssetReceipt:
|
||||
prepared = self._store.prepare_or_resume_evidence(profile_id, asset)
|
||||
if isinstance(prepared, AssetReceipt):
|
||||
return prepared
|
||||
credentials = self._store.load_profile(profile_id).credentials
|
||||
try:
|
||||
receipt = self._evidence_sink.upload(credentials, prepared)
|
||||
except AmbiguousRemoteError:
|
||||
raise
|
||||
except CredentialRemoteError:
|
||||
raise
|
||||
except ProtocolRemoteError:
|
||||
self._store.mark_evidence_terminal(profile_id, prepared, "PROTOCOL")
|
||||
raise
|
||||
except ManualRemoteError:
|
||||
self._store.mark_evidence_terminal(profile_id, prepared, "MANUAL")
|
||||
raise
|
||||
self._store.commit_evidence_success(profile_id, prepared, receipt)
|
||||
return receipt
|
||||
@@ -0,0 +1,86 @@
|
||||
"""供 T-304 使用的稳定本地配置与恢复快照。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
|
||||
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest
|
||||
from cmbuyer_client.core.validation import require_string, require_uuid4
|
||||
|
||||
|
||||
LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080"
|
||||
PROFILE_ID_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProfileSettings:
|
||||
profile_id: str
|
||||
service_url: str
|
||||
device_id: str
|
||||
adb_path: str
|
||||
adb_serial: str
|
||||
transport: str
|
||||
poll_interval_seconds: int = 15
|
||||
failure_threshold: int = 3
|
||||
http_timeout_seconds: int = 10
|
||||
step_timeout_seconds: int = 45
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.profile_id, str) or PROFILE_ID_RE.fullmatch(self.profile_id) is None:
|
||||
raise ValueError("invalid_profile_id")
|
||||
if self.service_url != LOOPBACK_SERVICE_URL:
|
||||
raise ValueError("service_url_not_allowed")
|
||||
require_uuid4(self.device_id, "invalid_device_id")
|
||||
require_string(self.adb_path, "invalid_adb_path", maximum=1024)
|
||||
require_string(self.adb_serial, "invalid_adb_serial", maximum=200)
|
||||
if self.transport not in ("usb", "wifi"):
|
||||
raise ValueError("invalid_transport")
|
||||
_range(self.poll_interval_seconds, 5, 300, "invalid_poll_interval")
|
||||
_range(self.failure_threshold, 1, 10, "invalid_failure_threshold")
|
||||
_range(self.http_timeout_seconds, 1, 120, "invalid_http_timeout")
|
||||
_range(self.step_timeout_seconds, 5, 300, "invalid_step_timeout")
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class LoadedProfile:
|
||||
settings: ProfileSettings
|
||||
credentials: DeviceCredentials = field(repr=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"LoadedProfile(settings={self.settings!r}, credentials=[已隐藏])"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollingSession:
|
||||
profile_id: str
|
||||
session_id: str
|
||||
accept_new: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.session_id, "invalid_session_id")
|
||||
if type(self.accept_new) is not bool:
|
||||
raise ValueError("invalid_accept_new")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingEvidence:
|
||||
task_id: str
|
||||
attempt_id: str
|
||||
kind: str
|
||||
upload_key: str
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoverySnapshot:
|
||||
session: PollingSession | None
|
||||
pending_claim: ClaimRequest | None
|
||||
active_claim: ClaimedTask | None = field(repr=False)
|
||||
pending_renew: RenewRequest | None = field(repr=False)
|
||||
pending_evidence: tuple[PendingEvidence, ...]
|
||||
|
||||
|
||||
def _range(value: object, minimum: int, maximum: int, reason: str) -> None:
|
||||
if type(value) is not int or not minimum <= value <= maximum:
|
||||
raise ValueError(reason)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Windows 当前用户范围 DPAPI 封装;生产环境绝不降级为明文。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import os
|
||||
import re
|
||||
from typing import Protocol
|
||||
|
||||
from cmbuyer_client.core.errors import ProtectionError
|
||||
|
||||
|
||||
class SecretProtector(Protocol):
|
||||
def protect(self, plaintext: bytes, *, purpose: str) -> bytes: ...
|
||||
|
||||
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes: ...
|
||||
|
||||
|
||||
class _DataBlob(ctypes.Structure):
|
||||
_fields_ = (("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_ubyte)))
|
||||
|
||||
|
||||
def _blob(data: bytes) -> tuple[_DataBlob, object]:
|
||||
buffer = (ctypes.c_ubyte * len(data)).from_buffer_copy(data) if data else (ctypes.c_ubyte * 1)()
|
||||
return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))), buffer
|
||||
|
||||
|
||||
class DpapiProtector:
|
||||
"""使用 CryptProtectData/UI_FORBIDDEN;错误只暴露固定 reason code。"""
|
||||
|
||||
_UI_FORBIDDEN = 0x1
|
||||
_ENTROPY_PREFIX = b"cmbuyer-localstate-v1:"
|
||||
_PURPOSE_RE = re.compile(
|
||||
r"(?:device-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36}|"
|
||||
r"claim-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36})",
|
||||
flags=re.ASCII,
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
if os.name != "nt":
|
||||
raise ProtectionError("dpapi_requires_windows")
|
||||
self._crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
|
||||
self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
self._crypt32.CryptProtectData.argtypes = (
|
||||
ctypes.POINTER(_DataBlob),
|
||||
wintypes.LPCWSTR,
|
||||
ctypes.POINTER(_DataBlob),
|
||||
wintypes.LPVOID,
|
||||
wintypes.LPVOID,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(_DataBlob),
|
||||
)
|
||||
self._crypt32.CryptProtectData.restype = wintypes.BOOL
|
||||
self._crypt32.CryptUnprotectData.argtypes = (
|
||||
ctypes.POINTER(_DataBlob),
|
||||
ctypes.POINTER(wintypes.LPWSTR),
|
||||
ctypes.POINTER(_DataBlob),
|
||||
wintypes.LPVOID,
|
||||
wintypes.LPVOID,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(_DataBlob),
|
||||
)
|
||||
self._crypt32.CryptUnprotectData.restype = wintypes.BOOL
|
||||
self._kernel32.LocalFree.argtypes = (wintypes.HLOCAL,)
|
||||
self._kernel32.LocalFree.restype = wintypes.HLOCAL
|
||||
|
||||
def protect(self, plaintext: bytes, *, purpose: str) -> bytes:
|
||||
if not isinstance(plaintext, bytes) or not plaintext:
|
||||
raise ProtectionError("invalid_plaintext")
|
||||
entropy = self._entropy(purpose)
|
||||
source, source_buffer = _blob(plaintext)
|
||||
entropy_blob, entropy_buffer = _blob(entropy)
|
||||
output = _DataBlob()
|
||||
if not self._crypt32.CryptProtectData(
|
||||
ctypes.byref(source), None, ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output)
|
||||
):
|
||||
raise ProtectionError("dpapi_protect_failed")
|
||||
# ctypes 指针不持有底层 Python buffer;局部引用必须活到系统调用返回。
|
||||
del source_buffer, entropy_buffer
|
||||
return self._take_output(output, "dpapi_protect_failed")
|
||||
|
||||
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
|
||||
if not isinstance(ciphertext, bytes) or not ciphertext:
|
||||
raise ProtectionError("invalid_ciphertext")
|
||||
entropy = self._entropy(purpose)
|
||||
source, source_buffer = _blob(ciphertext)
|
||||
entropy_blob, entropy_buffer = _blob(entropy)
|
||||
output = _DataBlob()
|
||||
description = wintypes.LPWSTR()
|
||||
if not self._crypt32.CryptUnprotectData(
|
||||
ctypes.byref(source), ctypes.byref(description), ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output)
|
||||
):
|
||||
raise ProtectionError("dpapi_unprotect_failed")
|
||||
del source_buffer, entropy_buffer
|
||||
if description:
|
||||
self._kernel32.LocalFree(ctypes.cast(description, wintypes.HLOCAL))
|
||||
return self._take_output(output, "dpapi_unprotect_failed")
|
||||
|
||||
def _take_output(self, output: _DataBlob, reason: str) -> bytes:
|
||||
if not output.pbData or output.cbData <= 0:
|
||||
raise ProtectionError(reason)
|
||||
try:
|
||||
return ctypes.string_at(output.pbData, output.cbData)
|
||||
finally:
|
||||
self._kernel32.LocalFree(ctypes.cast(output.pbData, wintypes.HLOCAL))
|
||||
|
||||
@classmethod
|
||||
def _entropy(cls, purpose: str) -> bytes:
|
||||
if not isinstance(purpose, str) or cls._PURPOSE_RE.fullmatch(purpose) is None:
|
||||
raise ProtectionError("invalid_protection_purpose")
|
||||
return cls._ENTROPY_PREFIX + purpose.encode("ascii")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""同一本地数据库的 Windows named mutex。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cmbuyer_client.core.errors import SingleInstanceError
|
||||
|
||||
|
||||
class NamedMutex:
|
||||
_ALREADY_EXISTS = 183
|
||||
|
||||
def __init__(self, database_path: Path) -> None:
|
||||
if os.name != "nt":
|
||||
raise SingleInstanceError("named_mutex_requires_windows")
|
||||
canonical = str(database_path.expanduser().resolve()).casefold().encode("utf-8")
|
||||
# Global namespace 覆盖同一 Windows 用户的多个交互 session;默认 DACL 不向其他用户泄露句柄。
|
||||
name = "Global\\cmbuyer-" + hashlib.sha256(canonical).hexdigest()
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.CreateMutexW.argtypes = (wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR)
|
||||
kernel32.CreateMutexW.restype = wintypes.HANDLE
|
||||
kernel32.ReleaseMutex.argtypes = (wintypes.HANDLE,)
|
||||
kernel32.ReleaseMutex.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
ctypes.set_last_error(0)
|
||||
handle = kernel32.CreateMutexW(None, True, name)
|
||||
if not handle:
|
||||
raise SingleInstanceError("named_mutex_failed")
|
||||
if ctypes.get_last_error() == self._ALREADY_EXISTS:
|
||||
kernel32.CloseHandle(handle)
|
||||
raise SingleInstanceError("instance_already_running")
|
||||
self._kernel32 = kernel32
|
||||
self._handle = handle
|
||||
|
||||
def close(self) -> None:
|
||||
handle = getattr(self, "_handle", None)
|
||||
if handle:
|
||||
self._kernel32.ReleaseMutex(handle)
|
||||
self._kernel32.CloseHandle(handle)
|
||||
self._handle = None
|
||||
|
||||
def __enter__(self) -> "NamedMutex":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
self.close()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ _KEY_VALUE_PATTERN = re.compile(
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
|
||||
_BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[0-9a-f]{64}\b")
|
||||
_BARE_TOKEN_PATTERN = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
|
||||
|
||||
|
||||
def redact_text(message: str) -> str:
|
||||
@@ -31,7 +33,9 @@ def redact_text(message: str) -> str:
|
||||
def replace_key_value(match: re.Match[str]) -> str:
|
||||
return f"{match.group('key')}{match.group('separator')}{REDACTED}"
|
||||
|
||||
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, message)
|
||||
redacted = _BEARER_PATTERN.sub("Bearer " + REDACTED, message)
|
||||
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, redacted)
|
||||
redacted = _BARE_TOKEN_PATTERN.sub(REDACTED, redacted)
|
||||
return _PHONE_PATTERN.sub(REDACTED, redacted)
|
||||
|
||||
|
||||
@@ -47,6 +51,16 @@ class SensitiveDataFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
|
||||
class RedactingFormatter(logging.Formatter):
|
||||
"""再次处理完整格式化文本,覆盖异常 traceback 中的敏感值。"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return redact_text(super().format(record))
|
||||
|
||||
def formatException(self, exc_info: tuple[type[BaseException], BaseException, object]) -> str:
|
||||
return redact_text(super().formatException(exc_info))
|
||||
|
||||
|
||||
def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
||||
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。"""
|
||||
|
||||
@@ -61,6 +75,6 @@ def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
||||
|
||||
handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
|
||||
handler.addFilter(SensitiveDataFilter())
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
handler.setFormatter(RedactingFormatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""只连接固定本机采购服务的 HTTP 适配器。"""
|
||||
|
||||
from .evidence_sink import HttpEvidenceSink
|
||||
from .http_transport import HttpTransport, LOOPBACK_SERVICE_URL
|
||||
from .task_source import HttpTaskSource
|
||||
|
||||
__all__ = ["HttpEvidenceSink", "HttpTaskSource", "HttpTransport", "LOOPBACK_SERVICE_URL"]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""仅上传调用方显式提供的单个 PNG 的窄 EvidenceSink。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError, ValidationError
|
||||
from cmbuyer_client.core.models import AssetReceipt, DeviceCredentials, EvidenceUpload
|
||||
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
|
||||
|
||||
from .http_transport import HttpTransport
|
||||
from .wire import SMALL_RESPONSE_LIMIT, classify_bodyless_error, common_headers, parse_json_response
|
||||
|
||||
|
||||
class HttpEvidenceSink:
|
||||
def __init__(self, transport: HttpTransport) -> None:
|
||||
self._transport = transport
|
||||
|
||||
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt:
|
||||
boundary = "cmbuyer-" + evidence.upload_key.replace("-", "")
|
||||
marker = ("--" + boundary).encode("ascii")
|
||||
if marker in evidence.content:
|
||||
raise ProtocolRemoteError("multipart_boundary_collision")
|
||||
body = _multipart_body(boundary, evidence)
|
||||
response = self._transport.request(
|
||||
"POST",
|
||||
f"/api/v1/tasks/{evidence.task_id}/evidence",
|
||||
common_headers(
|
||||
credentials.device_id,
|
||||
credentials.token.value,
|
||||
"multipart/form-data; boundary=" + boundary,
|
||||
),
|
||||
body,
|
||||
response_limit=SMALL_RESPONSE_LIMIT,
|
||||
)
|
||||
if response.status not in (200, 201):
|
||||
classify_bodyless_error(response)
|
||||
try:
|
||||
receipt = AssetReceipt.from_wire(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT))
|
||||
except ValidationError as error:
|
||||
raise AmbiguousRemoteError("invalid_evidence_success_response") from error
|
||||
if (
|
||||
receipt.task_id != evidence.task_id
|
||||
or receipt.attempt_id != evidence.attempt_id
|
||||
or receipt.kind != evidence.kind
|
||||
or receipt.privacy_tier != evidence.privacy_tier
|
||||
or receipt.sha256 != evidence.sha256
|
||||
or receipt.byte_size != len(evidence.content)
|
||||
or receipt.width_px != evidence.width_px
|
||||
or receipt.height_px != evidence.height_px
|
||||
or rfc3339_z_nanoseconds(receipt.captured_at) != rfc3339_z_nanoseconds(evidence.captured_at)
|
||||
):
|
||||
raise AmbiguousRemoteError("evidence_response_mismatch")
|
||||
return receipt
|
||||
|
||||
|
||||
def _multipart_body(boundary: str, evidence: EvidenceUpload) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
|
||||
def add_field(name: str, value: str) -> None:
|
||||
chunks.extend(
|
||||
(
|
||||
f"--{boundary}\r\n".encode("ascii"),
|
||||
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("ascii"),
|
||||
value.encode("utf-8"),
|
||||
b"\r\n",
|
||||
)
|
||||
)
|
||||
|
||||
add_field("upload_key", evidence.upload_key)
|
||||
add_field("attempt_id", evidence.attempt_id)
|
||||
add_field("kind", evidence.kind)
|
||||
add_field("privacy_tier", evidence.privacy_tier)
|
||||
add_field("sha256", evidence.sha256)
|
||||
add_field("captured_at", evidence.captured_at)
|
||||
chunks.extend(
|
||||
(
|
||||
f"--{boundary}\r\n".encode("ascii"),
|
||||
b'Content-Disposition: form-data; name="file"; filename="evidence.png"\r\n',
|
||||
b"Content-Type: image/png\r\n\r\n",
|
||||
evidence.content,
|
||||
b"\r\n",
|
||||
f"--{boundary}--\r\n".encode("ascii"),
|
||||
)
|
||||
)
|
||||
return b"".join(chunks)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""无代理、无重定向、无隐藏重试的 localhost HTTP transport。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import http.client
|
||||
import re
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError
|
||||
|
||||
|
||||
LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080"
|
||||
_HOST = "127.0.0.1"
|
||||
_PORT = 8080
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpResponse:
|
||||
status: int
|
||||
headers: tuple[tuple[str, str], ...]
|
||||
body: bytes
|
||||
|
||||
def header_values(self, name: str) -> tuple[str, ...]:
|
||||
wanted = name.lower()
|
||||
return tuple(value for key, value in self.headers if key.lower() == wanted)
|
||||
|
||||
|
||||
class HttpTransport:
|
||||
"""每次调用只创建一个直连 TCP 请求;重试只能由持久化恢复层决定。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_url: str = LOOPBACK_SERVICE_URL,
|
||||
*,
|
||||
timeout_seconds: int = 10,
|
||||
connection_factory: Callable[..., http.client.HTTPConnection] = http.client.HTTPConnection,
|
||||
) -> None:
|
||||
if service_url != LOOPBACK_SERVICE_URL:
|
||||
raise ProtocolRemoteError("service_url_not_allowed")
|
||||
if type(timeout_seconds) is not int or not 1 <= timeout_seconds <= 120:
|
||||
raise ProtocolRemoteError("invalid_http_timeout")
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._connection_factory = connection_factory
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
headers: Iterable[tuple[str, str]],
|
||||
body: bytes,
|
||||
*,
|
||||
response_limit: int,
|
||||
) -> HttpResponse:
|
||||
if method != "POST" or not path.startswith("/api/v1/") or "?" in path or "#" in path:
|
||||
raise ProtocolRemoteError("invalid_http_target")
|
||||
if not isinstance(body, bytes) or type(response_limit) is not int or response_limit <= 0:
|
||||
raise ProtocolRemoteError("invalid_http_request")
|
||||
header_items = tuple(headers)
|
||||
normalized: dict[str, str] = {}
|
||||
for key, value in header_items:
|
||||
lowered = key.lower()
|
||||
if lowered in normalized or "\r" in key or "\n" in key or "\r" in value or "\n" in value:
|
||||
raise ProtocolRemoteError("invalid_http_headers")
|
||||
normalized[lowered] = value
|
||||
|
||||
connection: http.client.HTTPConnection | None = None
|
||||
result: HttpResponse | None = None
|
||||
failure: str | None = None
|
||||
try:
|
||||
connection = self._connection_factory(_HOST, _PORT, timeout=self._timeout_seconds)
|
||||
connection.request(method, path, body=body, headers={key: value for key, value in header_items})
|
||||
response = connection.getresponse()
|
||||
response_headers = tuple(response.getheaders())
|
||||
content_lengths = tuple(value for key, value in response_headers if key.lower() == "content-length")
|
||||
transfer_encodings = tuple(value for key, value in response_headers if key.lower() == "transfer-encoding")
|
||||
if len(content_lengths) > 1:
|
||||
raise AmbiguousRemoteError("invalid_content_length")
|
||||
if content_lengths and transfer_encodings:
|
||||
raise AmbiguousRemoteError("ambiguous_response_framing")
|
||||
if len(transfer_encodings) > 1 or (
|
||||
transfer_encodings and transfer_encodings[0].lower() != "chunked"
|
||||
):
|
||||
raise AmbiguousRemoteError("invalid_transfer_encoding")
|
||||
declared = content_lengths[0] if content_lengths else None
|
||||
declared_length: int | None = None
|
||||
if declared is not None:
|
||||
if re.fullmatch(r"[0-9]+", declared, flags=re.ASCII) is None:
|
||||
raise AmbiguousRemoteError("invalid_content_length")
|
||||
if len(declared) > 10:
|
||||
raise AmbiguousRemoteError("response_too_large")
|
||||
declared_length = int(declared)
|
||||
if declared_length > response_limit:
|
||||
raise AmbiguousRemoteError("response_too_large")
|
||||
response_body = response.read(response_limit + 1)
|
||||
if len(response_body) > response_limit:
|
||||
raise AmbiguousRemoteError("response_too_large")
|
||||
if declared_length is not None and len(response_body) != declared_length:
|
||||
raise AmbiguousRemoteError("truncated_response")
|
||||
result = HttpResponse(response.status, response_headers, response_body)
|
||||
except AmbiguousRemoteError as error:
|
||||
failure = error.reason
|
||||
except (OSError, TimeoutError, http.client.HTTPException):
|
||||
failure = "http_result_unknown"
|
||||
finally:
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.close()
|
||||
except OSError:
|
||||
if result is None:
|
||||
failure = "http_result_unknown"
|
||||
if failure is not None:
|
||||
raise AmbiguousRemoteError(failure)
|
||||
if result is None:
|
||||
raise AmbiguousRemoteError("http_result_unknown")
|
||||
return result
|
||||
@@ -0,0 +1,75 @@
|
||||
"""领取与续租的固定 localhost HTTP 适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError
|
||||
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest, RenewResult
|
||||
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
|
||||
|
||||
from .http_transport import HttpTransport
|
||||
from .wire import (
|
||||
JSON_RESPONSE_LIMIT,
|
||||
SMALL_RESPONSE_LIMIT,
|
||||
classify_json_error,
|
||||
common_headers,
|
||||
encode_json,
|
||||
parse_json_response,
|
||||
)
|
||||
|
||||
|
||||
class HttpTaskSource:
|
||||
def __init__(self, transport: HttpTransport) -> None:
|
||||
self._transport = transport
|
||||
|
||||
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None:
|
||||
body = encode_json(request.to_wire())
|
||||
response = self._transport.request(
|
||||
"POST",
|
||||
"/api/v1/tasks/claim-next",
|
||||
common_headers(credentials.device_id, credentials.token.value, "application/json"),
|
||||
body,
|
||||
response_limit=JSON_RESPONSE_LIMIT,
|
||||
)
|
||||
if response.status == 204:
|
||||
if response.body or response.header_values("Content-Encoding"):
|
||||
raise AmbiguousRemoteError("invalid_empty_claim_response")
|
||||
return None
|
||||
if response.status != 200:
|
||||
classify_json_error(
|
||||
response,
|
||||
allowed_409=frozenset(("idempotency_conflict", "claim_requires_manual")),
|
||||
)
|
||||
try:
|
||||
claimed = ClaimedTask.from_wire(parse_json_response(response, maximum=JSON_RESPONSE_LIMIT))
|
||||
except ValidationError as error:
|
||||
raise AmbiguousRemoteError("invalid_claim_success_response") from error
|
||||
if rfc3339_z_nanoseconds(claimed.attempt.lease_expires_at) > rfc3339_z_nanoseconds(claimed.authorization.expires_at):
|
||||
raise AmbiguousRemoteError("invalid_claim_lease")
|
||||
return claimed
|
||||
|
||||
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult:
|
||||
response = self._transport.request(
|
||||
"POST",
|
||||
f"/api/v1/tasks/{request.task_id}/lease/renew",
|
||||
common_headers(credentials.device_id, credentials.token.value, "application/json"),
|
||||
encode_json(request.to_wire()),
|
||||
response_limit=SMALL_RESPONSE_LIMIT,
|
||||
)
|
||||
if response.status != 200:
|
||||
classify_json_error(
|
||||
response,
|
||||
allowed_409=frozenset(("idempotency_conflict", "claim_not_current")),
|
||||
)
|
||||
try:
|
||||
result = RenewResult.from_wire(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT))
|
||||
except ValidationError as error:
|
||||
raise AmbiguousRemoteError("invalid_renew_success_response") from error
|
||||
if (
|
||||
result.task_id != request.task_id
|
||||
or result.attempt_id != request.attempt_id
|
||||
or result.claim_generation != request.claim_generation
|
||||
or rfc3339_z_nanoseconds(result.lease_expires_at) < rfc3339_z_nanoseconds(request.expected_lease_expires_at)
|
||||
or rfc3339_z_nanoseconds(result.lease_expires_at) > rfc3339_z_nanoseconds(request.authorization_expires_at)
|
||||
):
|
||||
raise AmbiguousRemoteError("renew_response_mismatch")
|
||||
return result
|
||||
@@ -0,0 +1,106 @@
|
||||
"""T-302/T-204 固定 HTTP wire 的编码、解码与错误分类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from cmbuyer_client.core.errors import (
|
||||
AmbiguousRemoteError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
ValidationError,
|
||||
)
|
||||
from cmbuyer_client.core.validation import require_exact_fields, strict_json_loads
|
||||
|
||||
from .http_transport import HttpResponse
|
||||
|
||||
|
||||
JSON_REQUEST_LIMIT = 4096
|
||||
JSON_RESPONSE_LIMIT = 32 * 1024
|
||||
SMALL_RESPONSE_LIMIT = 8 * 1024
|
||||
JSON_CONTENT_TYPES = frozenset(("application/json", "application/json; charset=utf-8"))
|
||||
|
||||
|
||||
def encode_json(value: Mapping[str, object]) -> bytes:
|
||||
body = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
if len(body) > JSON_REQUEST_LIMIT:
|
||||
raise ProtocolRemoteError("request_too_large")
|
||||
return body
|
||||
|
||||
|
||||
def common_headers(device_id: str, token: str, content_type: str) -> tuple[tuple[str, str], ...]:
|
||||
return (
|
||||
("Authorization", "Bearer " + token),
|
||||
("X-CMBuyer-Device-ID", device_id),
|
||||
("Accept", "application/json"),
|
||||
("Content-Type", content_type),
|
||||
)
|
||||
|
||||
|
||||
def parse_json_response(response: HttpResponse, *, maximum: int) -> object:
|
||||
encodings = response.header_values("Content-Encoding")
|
||||
types = response.header_values("Content-Type")
|
||||
if encodings or len(types) != 1 or types[0].lower() not in JSON_CONTENT_TYPES:
|
||||
raise ValidationError("invalid_response_content_type")
|
||||
return strict_json_loads(response.body, maximum=maximum)
|
||||
|
||||
|
||||
def require_empty_response(response: HttpResponse) -> None:
|
||||
if response.body or response.header_values("Content-Encoding"):
|
||||
raise ProtocolRemoteError("unexpected_error_body")
|
||||
|
||||
|
||||
def classify_json_error(response: HttpResponse, *, allowed_409: frozenset[str]) -> None:
|
||||
"""抛出错误,不返回。调用方只在非成功状态使用。"""
|
||||
|
||||
if 200 <= response.status <= 299:
|
||||
# 服务端可能已提交幂等事实;未知 2xx 绝不能终结本地槽或换 key。
|
||||
raise AmbiguousRemoteError("unknown_success_status")
|
||||
if response.status == 401:
|
||||
require_empty_response(response)
|
||||
raise CredentialRemoteError("device_credential_rejected")
|
||||
if response.status == 503 or 500 <= response.status <= 599:
|
||||
# 5xx 无法证明服务端是否在提交响应前完成事务。
|
||||
raise AmbiguousRemoteError("server_result_unknown")
|
||||
if response.status == 409:
|
||||
try:
|
||||
data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",))
|
||||
code = data["error"]
|
||||
except ValidationError as error:
|
||||
raise ProtocolRemoteError("invalid_conflict_response") from error
|
||||
if not isinstance(code, str) or code not in allowed_409:
|
||||
raise ProtocolRemoteError("unknown_conflict")
|
||||
raise ManualRemoteError(code)
|
||||
expected = {400: "invalid_request", 413: "request_too_large", 415: "unsupported_media_type"}
|
||||
if response.status in expected:
|
||||
try:
|
||||
data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",))
|
||||
except ValidationError as error:
|
||||
raise ProtocolRemoteError("invalid_error_response") from error
|
||||
if data["error"] != expected[response.status]:
|
||||
raise ProtocolRemoteError("unexpected_error_code")
|
||||
raise ProtocolRemoteError(expected[response.status])
|
||||
if 300 <= response.status <= 399:
|
||||
raise ProtocolRemoteError("redirect_rejected")
|
||||
raise ProtocolRemoteError("unexpected_http_status")
|
||||
|
||||
|
||||
def classify_bodyless_error(response: HttpResponse) -> None:
|
||||
if 200 <= response.status <= 299:
|
||||
raise AmbiguousRemoteError("unknown_success_status")
|
||||
if response.status == 401:
|
||||
require_empty_response(response)
|
||||
raise CredentialRemoteError("device_credential_rejected")
|
||||
if response.status == 503 or 500 <= response.status <= 599:
|
||||
raise AmbiguousRemoteError("server_result_unknown")
|
||||
if response.status == 409:
|
||||
require_empty_response(response)
|
||||
raise ManualRemoteError("evidence_conflict")
|
||||
if response.status in (400, 403, 413, 415):
|
||||
require_empty_response(response)
|
||||
raise ProtocolRemoteError("evidence_request_rejected")
|
||||
if 300 <= response.status <= 399:
|
||||
raise ProtocolRemoteError("redirect_rejected")
|
||||
raise ProtocolRemoteError("unexpected_http_status")
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -17,21 +18,34 @@ class RuntimePaths:
|
||||
root: Path
|
||||
logs: Path
|
||||
artifacts: Path
|
||||
state: Path
|
||||
database: Path
|
||||
|
||||
@classmethod
|
||||
def from_root(cls, root: Path) -> "RuntimePaths":
|
||||
resolved_root = root.expanduser()
|
||||
# 路径在进程启动时一次性固化;之后 cwd 改变不能打开第二套数据库或绕过原 mutex。
|
||||
resolved_root = root.expanduser().resolve(strict=False)
|
||||
state = resolved_root / "state"
|
||||
return cls(
|
||||
root=resolved_root,
|
||||
logs=resolved_root / "logs",
|
||||
artifacts=resolved_root / "artifacts",
|
||||
state=state,
|
||||
database=state / "client-state.sqlite3",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> "RuntimePaths":
|
||||
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||
if local_app_data:
|
||||
return cls.from_root(Path(local_app_data) / "cmbuyer")
|
||||
local_root = Path(local_app_data).expanduser()
|
||||
if not local_root.is_absolute():
|
||||
raise RuntimeError("local_app_data_must_be_absolute")
|
||||
return cls.from_root(local_root / "cmbuyer")
|
||||
|
||||
if os.name == "nt":
|
||||
# Windows 上回退到 home 会悄悄创建第二套状态库并绕开同一 mutex,必须失败闭合。
|
||||
raise RuntimeError("local_app_data_required")
|
||||
|
||||
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
|
||||
|
||||
@@ -40,3 +54,49 @@ class RuntimePaths:
|
||||
|
||||
self.logs.mkdir(parents=True, exist_ok=True)
|
||||
self.artifacts.mkdir(parents=True, exist_ok=True)
|
||||
self.state.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalStateRuntime:
|
||||
"""持有 named mutex 与本地状态库,保证 mutex 总是先取得。"""
|
||||
|
||||
paths: RuntimePaths
|
||||
mutex: Any
|
||||
store: Any
|
||||
|
||||
@classmethod
|
||||
def open(
|
||||
cls,
|
||||
paths: RuntimePaths | None = None,
|
||||
*,
|
||||
mutex_factory: Callable[[Path], Any] | None = None,
|
||||
protector_factory: Callable[[], Any] | None = None,
|
||||
store_factory: Callable[[Path, Any], Any] | None = None,
|
||||
) -> "LocalStateRuntime":
|
||||
from .localstate.protection import DpapiProtector
|
||||
from .localstate.single_instance import NamedMutex
|
||||
from .localstate.store import LocalStateStore
|
||||
|
||||
selected = paths or RuntimePaths.default()
|
||||
selected.ensure_exists()
|
||||
make_mutex = mutex_factory or NamedMutex
|
||||
make_protector = protector_factory or DpapiProtector
|
||||
make_store = store_factory or LocalStateStore
|
||||
mutex = make_mutex(selected.database)
|
||||
try:
|
||||
protector = make_protector()
|
||||
store = make_store(selected.database, protector)
|
||||
except Exception:
|
||||
mutex.close()
|
||||
raise
|
||||
return cls(selected, mutex, store)
|
||||
|
||||
def close(self) -> None:
|
||||
self.mutex.close()
|
||||
|
||||
def __enter__(self) -> "LocalStateRuntime":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
self.close()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""core tests。"""
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import ValidationError
|
||||
from cmbuyer_client.core.models import ClaimedTask, SecretToken
|
||||
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds, strict_json_loads
|
||||
|
||||
|
||||
TASK_ID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
AUTH_ID = "73c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
ATTEMPT_ID = "53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
TOKEN = "0123456789abcdef" * 4
|
||||
|
||||
|
||||
def claim_wire() -> dict[str, object]:
|
||||
return {
|
||||
"task": {
|
||||
"id": TASK_ID,
|
||||
"version": 3,
|
||||
"title": "纯棉短袖",
|
||||
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
|
||||
"goods_id": "937122477375",
|
||||
"sku_color": "黑色CHA(纯棉)",
|
||||
"sku_size": "M(建议100-115)",
|
||||
"quantity": 2,
|
||||
"max_total_price": "30.00",
|
||||
},
|
||||
"authorization": {"id": AUTH_ID, "task_version": 2, "expires_at": "2026-08-04T10:00:00Z"},
|
||||
"attempt": {
|
||||
"id": ATTEMPT_ID,
|
||||
"claim_token": TOKEN,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:05:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CoreModelsTests(unittest.TestCase):
|
||||
def test_claim_wire_round_trip_and_secret_repr(self) -> None:
|
||||
claimed = ClaimedTask.from_wire(claim_wire())
|
||||
self.assertEqual(claimed.task.quantity, 2)
|
||||
self.assertNotIn(TOKEN, repr(claimed))
|
||||
self.assertNotIn(TOKEN, repr(SecretToken(TOKEN)))
|
||||
|
||||
def test_rejects_bool_float_wrong_url_and_version_drift(self) -> None:
|
||||
mutations = []
|
||||
for mutate in (
|
||||
lambda value: value["task"].__setitem__("quantity", True),
|
||||
lambda value: value["task"].__setitem__("max_total_price", "30.0"),
|
||||
lambda value: value["task"].__setitem__("max_total_price", "0.00"),
|
||||
lambda value: value["task"].__setitem__("product_url", "https://example.invalid/"),
|
||||
lambda value: value["task"].__setitem__("version", 2),
|
||||
):
|
||||
value = claim_wire()
|
||||
mutate(value)
|
||||
mutations.append(value)
|
||||
for value in mutations:
|
||||
with self.subTest(value=value), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(value)
|
||||
|
||||
def test_strict_json_rejects_nested_duplicates_float_nan_bom_and_utf8(self) -> None:
|
||||
bad_values = (
|
||||
b'{"task":{"id":1,"id":2}}',
|
||||
b'{"value":1.0}',
|
||||
b'{"value":NaN}',
|
||||
b'\xef\xbb\xbf{}',
|
||||
b'\xff',
|
||||
('{"value":' + "9" * 5000 + '}').encode(),
|
||||
)
|
||||
for raw in bad_values:
|
||||
with self.subTest(raw=raw), self.assertRaises(ValidationError):
|
||||
strict_json_loads(raw, maximum=1024)
|
||||
self.assertEqual(strict_json_loads(json.dumps({"value": 1}).encode(), maximum=1024), {"value": 1})
|
||||
|
||||
def test_rfc3339_nano_comparison_preserves_all_fraction_digits(self) -> None:
|
||||
equal = (
|
||||
"2026-08-04T09:01:00.1Z",
|
||||
"2026-08-04T09:01:00.100000Z",
|
||||
"2026-08-04T09:01:00.100000000Z",
|
||||
)
|
||||
self.assertEqual(len({rfc3339_z_nanoseconds(value) for value in equal}), 1)
|
||||
ordered = (
|
||||
"2026-08-04T09:01:00Z",
|
||||
"2026-08-04T09:01:00.000001Z",
|
||||
"2026-08-04T09:01:00.0000011Z",
|
||||
"2026-08-04T09:01:00.000001101Z",
|
||||
"2026-08-04T09:01:01Z",
|
||||
)
|
||||
self.assertEqual([rfc3339_z_nanoseconds(value) for value in ordered], sorted(rfc3339_z_nanoseconds(value) for value in ordered))
|
||||
|
||||
def test_money_accepts_positive_subunit_but_rejects_zero_and_noncanonical_forms(self) -> None:
|
||||
value = claim_wire()
|
||||
value["task"]["max_total_price"] = "0.01"
|
||||
self.assertEqual(ClaimedTask.from_wire(value).task.max_total_price, "0.01")
|
||||
for invalid in ("0.00", "00.01", "1.0", "1.000", "1", 1.0, "1.12", "1.٠٠", "12.00"):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||
changed = claim_wire()
|
||||
changed["task"]["max_total_price"] = invalid
|
||||
ClaimedTask.from_wire(changed)
|
||||
|
||||
wide = claim_wire()
|
||||
wide_goods = "1" * 33
|
||||
wide["task"].update(
|
||||
goods_id=wide_goods,
|
||||
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + wide_goods,
|
||||
max_total_price="1" * 31 + ".00",
|
||||
quantity=2_147_483_648,
|
||||
)
|
||||
self.assertEqual(ClaimedTask.from_wire(wide).task.quantity, 2_147_483_648)
|
||||
for invalid_goods in ("123", "1٢3"):
|
||||
changed = claim_wire()
|
||||
changed["task"]["goods_id"] = invalid_goods
|
||||
changed["task"]["product_url"] = "https://mobile.yangkeduo.com/goods.html?goods_id=" + invalid_goods
|
||||
with self.subTest(invalid_goods=invalid_goods), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(changed)
|
||||
too_large = claim_wire()
|
||||
too_large["task"]["quantity"] = 9_223_372_036_854_775_808
|
||||
with self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(too_large)
|
||||
|
||||
def test_wire_strings_reject_lone_surrogates_but_accept_valid_pair(self) -> None:
|
||||
for escaped in (r'"\ud800"', r'"\udc00"'):
|
||||
value = claim_wire()
|
||||
value["task"]["title"] = json.loads(escaped)
|
||||
with self.subTest(escaped=escaped), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(value)
|
||||
value = claim_wire()
|
||||
value["task"]["title"] = json.loads(r'"\ud83d\ude00"')
|
||||
self.assertEqual(ClaimedTask.from_wire(value).task.title, "😀")
|
||||
|
||||
def test_title_rejects_ascii_and_unicode_whitespace_only(self) -> None:
|
||||
for title in ("", " \t\r\n", "\u3000", " \u3000\t"):
|
||||
value = claim_wire()
|
||||
value["task"]["title"] = title
|
||||
with self.subTest(title=repr(title)), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(value)
|
||||
@@ -0,0 +1 @@
|
||||
"""localstate tests。"""
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
SRC = Path(__file__).resolve().parents[2] / "src" / "cmbuyer_client"
|
||||
SCOPED = tuple((SRC / name) for name in ("core", "remote", "localstate"))
|
||||
|
||||
|
||||
class StaticBoundaryTests(unittest.TestCase):
|
||||
def test_scoped_modules_do_not_import_device_pdd_or_unapproved_capabilities(self) -> None:
|
||||
forbidden_modules = ("cmbuyer_client.device", "cmbuyer_client.pdd")
|
||||
forbidden_text = (
|
||||
"ResultSink",
|
||||
"/events",
|
||||
"/fail",
|
||||
"/submission-fence",
|
||||
"/result",
|
||||
"click_permitted",
|
||||
)
|
||||
for directory in SCOPED:
|
||||
for path in directory.glob("*.py"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(text)
|
||||
imports = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imports.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
imports.append(node.module)
|
||||
for module in forbidden_modules:
|
||||
self.assertFalse(any(name.startswith(module) for name in imports), (path, module))
|
||||
for value in forbidden_text:
|
||||
self.assertNotIn(value, text, (path, value))
|
||||
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, StateError
|
||||
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken
|
||||
from cmbuyer_client.localstate.facade import DurableClientGateway
|
||||
from cmbuyer_client.localstate.models import ProfileSettings
|
||||
from cmbuyer_client.localstate.store import LocalStateStore
|
||||
from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink
|
||||
from cmbuyer_client.remote.http_transport import HttpResponse
|
||||
from cmbuyer_client.remote.task_source import HttpTaskSource
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, claim_wire
|
||||
from tests.localstate.test_store import DEVICE_TOKEN, FakeProtector, PROFILE
|
||||
from tests.remote.test_task_source import DEVICE_ID, FakeTransport, response
|
||||
|
||||
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
class InspectingSource:
|
||||
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
|
||||
self.store = store
|
||||
self.profile_id = profile_id
|
||||
self.calls = 0
|
||||
self.mode = "success"
|
||||
self.request_ids: list[str] = []
|
||||
|
||||
def claim_next(self, credentials, request):
|
||||
self.calls += 1
|
||||
self.request_ids.append(request.claim_request_id)
|
||||
# HTTP 适配器被调用时,幂等请求必须已经 durable。
|
||||
self.assert_pending(request.claim_request_id)
|
||||
if self.mode == "ambiguous":
|
||||
raise AmbiguousRemoteError("http_result_unknown")
|
||||
if self.mode == "manual":
|
||||
raise ManualRemoteError("claim_requires_manual")
|
||||
return ClaimedTask.from_wire(claim_wire())
|
||||
|
||||
def renew(self, credentials, request):
|
||||
raise AssertionError("not used")
|
||||
|
||||
def assert_pending(self, request_id: str) -> None:
|
||||
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
|
||||
if snapshot.pending_claim is None or snapshot.pending_claim.claim_request_id != request_id:
|
||||
raise AssertionError("HTTP happened before durable prepare")
|
||||
|
||||
|
||||
class InspectingSink:
|
||||
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
|
||||
self.store = store
|
||||
self.profile_id = profile_id
|
||||
self.calls = 0
|
||||
|
||||
def upload(self, credentials, upload):
|
||||
self.calls += 1
|
||||
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
|
||||
if not snapshot.pending_evidence or snapshot.pending_evidence[0].upload_key != upload.upload_key:
|
||||
raise AssertionError("HTTP happened before durable evidence slot")
|
||||
return AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
upload.task_id,
|
||||
upload.attempt_id,
|
||||
upload.kind,
|
||||
upload.privacy_tier,
|
||||
upload.sha256,
|
||||
len(upload.content),
|
||||
"image/png",
|
||||
1,
|
||||
1,
|
||||
upload.captured_at,
|
||||
)
|
||||
|
||||
|
||||
class InspectingRenewSource:
|
||||
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
|
||||
self.store = store
|
||||
self.profile_id = profile_id
|
||||
self.calls = 0
|
||||
|
||||
def claim_next(self, credentials, request):
|
||||
raise AssertionError("not used")
|
||||
|
||||
def renew(self, credentials, request):
|
||||
self.calls += 1
|
||||
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
|
||||
if snapshot.pending_renew is None or snapshot.pending_renew.renew_request_id != request.renew_request_id:
|
||||
raise AssertionError("HTTP happened before durable renew")
|
||||
return RenewResult(request.task_id, request.attempt_id, request.claim_generation, request.expected_lease_expires_at)
|
||||
|
||||
|
||||
class DurableClientGatewayTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.database = Path(self.directory.name) / "client-state.sqlite3"
|
||||
self.store = LocalStateStore(
|
||||
self.database,
|
||||
FakeProtector(),
|
||||
now=lambda: datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
profile = ProfileSettings(
|
||||
PROFILE,
|
||||
"http://127.0.0.1:8080",
|
||||
DEVICE_ID,
|
||||
"D:/Portable/adb/adb.exe",
|
||||
"192.168.0.173:5555",
|
||||
"wifi",
|
||||
)
|
||||
self.store.save_profile(profile, SecretToken(DEVICE_TOKEN))
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
self.source = InspectingSource(self.store, PROFILE)
|
||||
self.sink = InspectingSink(self.store, PROFILE)
|
||||
self.gateway = DurableClientGateway(self.store, self.source, self.sink)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.directory.cleanup()
|
||||
|
||||
def test_claim_unknown_replays_same_durable_key_then_commits(self) -> None:
|
||||
self.source.mode = "ambiguous"
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
self.gateway.claim_next(PROFILE)
|
||||
self.source.mode = "success"
|
||||
claimed = self.gateway.claim_next(PROFILE)
|
||||
self.assertEqual(claimed.task.id, TASK_ID)
|
||||
self.assertEqual(self.source.request_ids[0], self.source.request_ids[1])
|
||||
self.assertIsNotNone(self.store.active_claim(PROFILE))
|
||||
|
||||
def test_unknown_claim_2xx_keeps_pending_key_for_real_adapter_replay(self) -> None:
|
||||
transport = FakeTransport(response(201, claim_wire()))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.claim_next(PROFILE)
|
||||
pending = self.store.recovery_snapshot(PROFILE).pending_claim
|
||||
self.assertIsNotNone(pending)
|
||||
transport.response = response(200, claim_wire())
|
||||
claimed = gateway.claim_next(PROFILE)
|
||||
self.assertEqual(claimed.task.id, TASK_ID)
|
||||
sent = [call[3] for call in transport.calls]
|
||||
self.assertEqual(sent[0], sent[1])
|
||||
|
||||
def test_claim_401_allows_token_repair_and_same_key_replay(self) -> None:
|
||||
transport = FakeTransport(HttpResponse(401, (), b""))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(CredentialRemoteError):
|
||||
gateway.claim_next(PROFILE)
|
||||
request_id = self.store.recovery_snapshot(PROFILE).pending_claim.claim_request_id
|
||||
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
|
||||
transport.response = response(200, claim_wire())
|
||||
gateway.claim_next(PROFILE)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
|
||||
self.assertEqual(json.loads(transport.calls[1][3])["claim_request_id"], request_id)
|
||||
|
||||
def test_profile_read_sql_failure_after_prepare_is_fixed_error_and_zero_http(self) -> None:
|
||||
original_connect = self.store._connect
|
||||
calls = 0
|
||||
|
||||
def fail_second_connection():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
connection = original_connect()
|
||||
if calls == 2:
|
||||
connection.set_authorizer(
|
||||
lambda action, table, *_: sqlite3.SQLITE_DENY
|
||||
if action == sqlite3.SQLITE_READ and table == "profiles"
|
||||
else sqlite3.SQLITE_OK
|
||||
)
|
||||
return connection
|
||||
|
||||
with mock.patch.object(self.store, "_connect", side_effect=fail_second_connection):
|
||||
with self.assertRaisesRegex(StateError, "localstate_read_failed") as captured:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
self.assertEqual(self.source.calls, 0)
|
||||
self.assertNotIn(str(self.database), repr(captured.exception))
|
||||
|
||||
def test_renew_is_durable_before_http(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
source = InspectingRenewSource(self.store, PROFILE)
|
||||
gateway = DurableClientGateway(self.store, source, self.sink)
|
||||
result = gateway.renew(PROFILE)
|
||||
self.assertEqual(result.attempt_id, ATTEMPT_ID)
|
||||
self.assertEqual(source.calls, 1)
|
||||
|
||||
def test_unknown_renew_2xx_keeps_pending_payload_for_replay(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
payload = {
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:06:00Z",
|
||||
}
|
||||
transport = FakeTransport(response(201, payload))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.renew(PROFILE)
|
||||
pending = self.store.recovery_snapshot(PROFILE).pending_renew
|
||||
self.assertIsNotNone(pending)
|
||||
transport.response = response(200, payload)
|
||||
gateway.renew(PROFILE)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
|
||||
def test_renew_401_allows_bearer_repair_without_changing_claim_payload(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
payload = {
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:06:00Z",
|
||||
}
|
||||
transport = FakeTransport(HttpResponse(401, (), b""))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(CredentialRemoteError):
|
||||
gateway.renew(PROFILE)
|
||||
self.assertIsNotNone(self.store.recovery_snapshot(PROFILE).pending_renew)
|
||||
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
|
||||
transport.response = response(200, payload)
|
||||
gateway.renew(PROFILE)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
|
||||
|
||||
def test_unknown_evidence_2xx_keeps_pending_multipart_for_replay(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "unknown.png"
|
||||
path.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
transport = FakeTransport(HttpResponse(202, (), b""))
|
||||
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
pending = self.store.recovery_snapshot(PROFILE).pending_evidence
|
||||
self.assertEqual(len(pending), 1)
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
receipt = {
|
||||
"asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode())
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
|
||||
def test_evidence_401_allows_bearer_repair_with_same_file_and_multipart(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "credential.png"
|
||||
path.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
transport = FakeTransport(HttpResponse(401, (), b""))
|
||||
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
|
||||
with self.assertRaises(CredentialRemoteError):
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(len(self.store.recovery_snapshot(PROFILE).pending_evidence), 1)
|
||||
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
receipt = {
|
||||
"asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode())
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
|
||||
|
||||
def test_equivalent_captured_at_replays_exact_original_multipart_bytes(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "exact-replay.png"
|
||||
path.write_bytes(PNG)
|
||||
transport = FakeTransport(HttpResponse(202, (), b""))
|
||||
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
|
||||
first = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.1Z")
|
||||
equivalent = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.100000Z")
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.upload_evidence(PROFILE, first)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.upload_evidence(PROFILE, equivalent)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
|
||||
def test_manual_claim_is_durable_and_never_gets_new_key(self) -> None:
|
||||
self.source.mode = "manual"
|
||||
with self.assertRaises(ManualRemoteError):
|
||||
self.gateway.claim_next(PROFILE)
|
||||
with self.assertRaises(Exception):
|
||||
self.gateway.claim_next(PROFILE)
|
||||
self.assertEqual(self.source.calls, 1)
|
||||
|
||||
def test_evidence_slot_exists_before_http_and_success_never_reuploads(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "one.png"
|
||||
path.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.120000Z")
|
||||
first = self.gateway.upload_evidence(PROFILE, asset)
|
||||
path.write_bytes(PNG + b"changed")
|
||||
second = self.gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(self.sink.calls, 1)
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import ProtectionError
|
||||
from cmbuyer_client.localstate.protection import DpapiProtector
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "DPAPI 仅在 Windows 验证")
|
||||
class DpapiProtectorTests(unittest.TestCase):
|
||||
def test_current_user_round_trip_purpose_isolation_and_corruption(self) -> None:
|
||||
protector = DpapiProtector()
|
||||
plaintext = b"a" * 64
|
||||
device_purpose = "device-token:default:33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
claim_purpose = "claim-token:default:53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
ciphertext = protector.protect(plaintext, purpose=device_purpose)
|
||||
self.assertNotIn(plaintext, ciphertext)
|
||||
self.assertEqual(protector.unprotect(ciphertext, purpose=device_purpose), plaintext)
|
||||
with self.assertRaises(ProtectionError):
|
||||
protector.unprotect(ciphertext, purpose=claim_purpose)
|
||||
damaged = ciphertext[:-1] + bytes((ciphertext[-1] ^ 1,))
|
||||
with self.assertRaises(ProtectionError):
|
||||
protector.unprotect(damaged, purpose=device_purpose)
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import SingleInstanceError
|
||||
from cmbuyer_client.localstate.single_instance import NamedMutex
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "named mutex 仅在 Windows 验证")
|
||||
class NamedMutexTests(unittest.TestCase):
|
||||
def test_second_process_for_same_database_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
database = Path(directory) / "state.sqlite3"
|
||||
first = NamedMutex(database)
|
||||
try:
|
||||
with self.assertRaises(SingleInstanceError):
|
||||
NamedMutex(database)
|
||||
code = (
|
||||
"from pathlib import Path; "
|
||||
"from cmbuyer_client.localstate.single_instance import NamedMutex; "
|
||||
"from cmbuyer_client.core.errors import SingleInstanceError; "
|
||||
f"p=Path({str(database)!r}); "
|
||||
"\ntry:\n NamedMutex(p)\nexcept SingleInstanceError:\n raise SystemExit(17)\nraise SystemExit(0)"
|
||||
)
|
||||
environment = dict(os.environ)
|
||||
environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[2] / "src")
|
||||
result = subprocess.run([sys.executable, "-c", code], env=environment, check=False)
|
||||
self.assertEqual(result.returncode, 17)
|
||||
finally:
|
||||
first.close()
|
||||
|
||||
with NamedMutex(database):
|
||||
pass
|
||||
@@ -0,0 +1,786 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from cmbuyer_client.core.errors import ProtectionError, StateError
|
||||
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken
|
||||
from cmbuyer_client.localstate.models import ProfileSettings
|
||||
from cmbuyer_client.localstate.store import LocalStateStore, _read_stable_png
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN, claim_wire
|
||||
from tests.remote.test_task_source import DEVICE_ID
|
||||
|
||||
|
||||
DEVICE_TOKEN = "b" * 64
|
||||
PROFILE = "default"
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
class FakeProtector:
|
||||
def protect(self, plaintext: bytes, *, purpose: str) -> bytes:
|
||||
key = hashlib.sha256(("test:" + purpose).encode()).digest()
|
||||
encrypted = bytes(value ^ key[index % len(key)] for index, value in enumerate(plaintext))
|
||||
return hmac.digest(key, plaintext, "sha256") + encrypted
|
||||
|
||||
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
|
||||
if len(ciphertext) < 33:
|
||||
raise ProtectionError("fake_unprotect_failed")
|
||||
key = hashlib.sha256(("test:" + purpose).encode()).digest()
|
||||
plaintext = bytes(value ^ key[index % len(key)] for index, value in enumerate(ciphertext[32:]))
|
||||
if not hmac.compare_digest(ciphertext[:32], hmac.digest(key, plaintext, "sha256")):
|
||||
raise ProtectionError("fake_unprotect_failed")
|
||||
return plaintext
|
||||
|
||||
|
||||
def settings(device_id: str = DEVICE_ID) -> ProfileSettings:
|
||||
return ProfileSettings(
|
||||
PROFILE,
|
||||
"http://127.0.0.1:8080",
|
||||
device_id,
|
||||
"D:/Portable/adb/adb.exe",
|
||||
"192.168.0.173:5555",
|
||||
"wifi",
|
||||
)
|
||||
|
||||
|
||||
class LocalStateStoreTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.database = Path(self.directory.name) / "state" / "client-state.sqlite3"
|
||||
self.clock = [datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc)]
|
||||
self.store = self.new_store()
|
||||
self.store.save_profile(settings(), SecretToken(DEVICE_TOKEN))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.directory.cleanup()
|
||||
|
||||
def new_store(self) -> LocalStateStore:
|
||||
return LocalStateStore(self.database, FakeProtector(), now=lambda: self.clock[0])
|
||||
|
||||
def test_claim_unknown_restart_stop_and_atomic_success(self) -> None:
|
||||
session = self.store.start_or_resume_polling(PROFILE)
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
self.assertEqual(self.store.prepare_claim(PROFILE), request)
|
||||
|
||||
# 模拟 HTTP 已成功但进程在落库前退出;重启只能恢复原 key。
|
||||
restarted = self.new_store()
|
||||
self.assertEqual(restarted.recovery_snapshot(PROFILE).pending_claim, request)
|
||||
self.assertEqual(restarted.prepare_claim(PROFILE), request)
|
||||
|
||||
# stop 不能丢弃飞行中的 claim,返回结果仍必须落 active。
|
||||
restarted.request_stop(PROFILE)
|
||||
claimed = ClaimedTask.from_wire(claim_wire())
|
||||
restarted.commit_claim_success(PROFILE, request, claimed)
|
||||
snapshot = self.new_store().recovery_snapshot(PROFILE)
|
||||
self.assertFalse(snapshot.session.accept_new)
|
||||
self.assertIsNone(snapshot.pending_claim)
|
||||
self.assertEqual(snapshot.active_claim.task.id, TASK_ID)
|
||||
self.assertEqual(snapshot.active_claim.attempt.claim_token.value, TOKEN)
|
||||
with self.assertRaises(StateError):
|
||||
restarted.prepare_claim(PROFILE)
|
||||
|
||||
def test_relative_database_path_is_frozen_across_cwd_changes(self) -> None:
|
||||
original_cwd = Path.cwd()
|
||||
first = Path(self.directory.name) / "first-cwd"
|
||||
second = Path(self.directory.name) / "second-cwd"
|
||||
first.mkdir()
|
||||
second.mkdir()
|
||||
try:
|
||||
os.chdir(first)
|
||||
relative_store = LocalStateStore(Path("relative/state.sqlite3"), FakeProtector(), now=lambda: self.clock[0])
|
||||
relative_store.save_profile(settings(), SecretToken(DEVICE_TOKEN))
|
||||
frozen_path = relative_store.database_path
|
||||
os.chdir(second)
|
||||
self.assertEqual(relative_store.load_profile(PROFILE).settings, settings())
|
||||
self.assertEqual(relative_store.database_path, frozen_path)
|
||||
self.assertTrue(frozen_path.is_absolute())
|
||||
self.assertFalse((second / "relative" / "state.sqlite3").exists())
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def test_empty_allows_new_key_but_terminal_does_not(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
first = self.store.prepare_claim(PROFILE)
|
||||
self.store.commit_claim_empty(PROFILE, first)
|
||||
second = self.store.prepare_claim(PROFILE)
|
||||
self.assertNotEqual(first.claim_request_id, second.claim_request_id)
|
||||
self.store.mark_claim_terminal(PROFILE, second, "MANUAL")
|
||||
with self.assertRaises(StateError):
|
||||
self.store.prepare_claim(PROFILE)
|
||||
|
||||
def test_idle_profile_identity_change_creates_new_session_and_key(self) -> None:
|
||||
first_session = self.store.start_or_resume_polling(PROFILE)
|
||||
first = self.store.prepare_claim(PROFILE)
|
||||
self.store.commit_claim_empty(PROFILE, first)
|
||||
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
self.store.save_profile(settings(other_id), SecretToken("c" * 64))
|
||||
self.assertIsNone(self.store.recovery_snapshot(PROFILE).session)
|
||||
second_session = self.store.start_or_resume_polling(PROFILE)
|
||||
second = self.store.prepare_claim(PROFILE)
|
||||
self.assertNotEqual(first_session.session_id, second_session.session_id)
|
||||
self.assertNotEqual(first.claim_request_id, second.claim_request_id)
|
||||
self.assertEqual(second.session_id, second_session.session_id)
|
||||
|
||||
def test_device_change_requires_new_token_because_cipher_is_identity_bound(self) -> None:
|
||||
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
with self.assertRaisesRegex(StateError, "device_token_required_for_device_change"):
|
||||
self.store.save_profile(settings(other_id), None)
|
||||
self.store.save_profile(settings(other_id), SecretToken("c" * 64))
|
||||
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "c" * 64)
|
||||
|
||||
def test_tampered_profile_session_mismatch_is_not_auto_repaired(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute(
|
||||
"UPDATE profiles SET device_id=? WHERE profile_id=?",
|
||||
("f3c9f507-7473-4fa6-8d71-8786c34c6301", PROFILE),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "polling_identity_mismatch"):
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
|
||||
def test_pending_and_active_freeze_service_device_and_token_identity(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
self.store.save_profile(settings(), SecretToken("c" * 64))
|
||||
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "c" * 64)
|
||||
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
with self.assertRaises(StateError):
|
||||
self.store.save_profile(settings(other_id), None)
|
||||
base = settings()
|
||||
changed_profiles = (
|
||||
replace(base, adb_path="D:/other/adb.exe"),
|
||||
replace(base, adb_serial="usb-other"),
|
||||
replace(base, transport="usb"),
|
||||
replace(base, poll_interval_seconds=16),
|
||||
replace(base, failure_threshold=4),
|
||||
replace(base, http_timeout_seconds=11),
|
||||
replace(base, step_timeout_seconds=46),
|
||||
)
|
||||
for changed in changed_profiles:
|
||||
with self.subTest(changed=changed), self.assertRaises(StateError):
|
||||
self.store.save_profile(changed, None)
|
||||
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
|
||||
self.store.save_profile(settings(), SecretToken("d" * 64))
|
||||
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "d" * 64)
|
||||
|
||||
def test_renew_reuses_exact_payload_across_restart_and_cas_updates_only_lease(self) -> None:
|
||||
self._claim_active()
|
||||
request = self.store.prepare_renew(PROFILE)
|
||||
self.clock[0] = datetime(2026, 8, 4, 9, 7, tzinfo=timezone.utc)
|
||||
recovered = self.new_store().prepare_renew(PROFILE)
|
||||
self.assertEqual(recovered.renew_request_id, request.renew_request_id)
|
||||
self.assertEqual(recovered.to_wire(), request.to_wire())
|
||||
result = RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z")
|
||||
self.store.commit_renew_success(PROFILE, request, result)
|
||||
active = self.new_store().active_claim(PROFILE)
|
||||
self.assertEqual(active.attempt.claim_generation, 1)
|
||||
self.assertEqual(active.attempt.claim_token.value, TOKEN)
|
||||
self.assertEqual(active.attempt.lease_expires_at, "2026-08-04T09:06:00Z")
|
||||
|
||||
with self.assertRaises(StateError):
|
||||
self.store.prepare_renew(PROFILE)
|
||||
|
||||
def test_evidence_slot_is_persisted_before_send_and_rejects_file_change(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "explicit.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
recovered = self.new_store().prepare_or_resume_evidence(PROFILE, asset)
|
||||
self.assertEqual(recovered.upload_key, upload.upload_key)
|
||||
self.assertEqual(recovered.content, upload.content)
|
||||
|
||||
image.write_bytes(PNG + b"changed")
|
||||
with self.assertRaises(StateError):
|
||||
self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
image.write_bytes(PNG)
|
||||
|
||||
receipt = AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
TASK_ID,
|
||||
ATTEMPT_ID,
|
||||
"SKU_PANEL_GATE_1",
|
||||
"INTERNAL_RAW",
|
||||
upload.sha256,
|
||||
len(PNG),
|
||||
"image/png",
|
||||
1,
|
||||
1,
|
||||
"2026-08-04T09:01:00Z",
|
||||
)
|
||||
self.store.commit_evidence_success(PROFILE, upload, receipt)
|
||||
image.write_bytes(PNG + b"different-after-success")
|
||||
self.assertEqual(self.new_store().prepare_or_resume_evidence(PROFILE, asset), receipt)
|
||||
image.unlink()
|
||||
self.assertEqual(self.new_store().prepare_or_resume_evidence(PROFILE, asset), receipt)
|
||||
|
||||
def test_evidence_terminal_outcome_retains_slot_and_blocks_resend(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "manual.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
self.store.mark_evidence_terminal(PROFILE, upload, "MANUAL")
|
||||
snapshot = self.store.recovery_snapshot(PROFILE)
|
||||
self.assertEqual(snapshot.pending_evidence[0].status, "MANUAL")
|
||||
with self.assertRaises(StateError):
|
||||
self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
|
||||
def test_receipt_dimensions_must_match_local_png_and_pending_slot_survives(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "dimension.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
wrong = AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
|
||||
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 2, 2, upload.captured_at,
|
||||
)
|
||||
with self.assertRaisesRegex(StateError, "evidence_response_mismatch"):
|
||||
self.store.commit_evidence_success(PROFILE, upload, wrong)
|
||||
pending = self.new_store().recovery_snapshot(PROFILE).pending_evidence
|
||||
self.assertEqual(len(pending), 1)
|
||||
self.assertEqual(pending[0].upload_key, upload.upload_key)
|
||||
|
||||
def test_evidence_slot_is_profile_owned_and_missing_half_fails_closed(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "owned.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
|
||||
other = replace(settings(), profile_id="other")
|
||||
self.store.save_profile(other, SecretToken("c" * 64))
|
||||
with self.assertRaisesRegex(StateError, "evidence_slot_not_owned"):
|
||||
self.store.prepare_or_resume_evidence("other", asset)
|
||||
|
||||
connection = sqlite3.connect(self.database)
|
||||
connection.execute("DROP TRIGGER evidence_slots_no_delete")
|
||||
connection.execute("DELETE FROM evidence_slots WHERE attempt_id=?", (ATTEMPT_ID,))
|
||||
connection.commit()
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "evidence_marker_mismatch"):
|
||||
self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
|
||||
def test_evidence_reader_rejects_reparse_and_path_identity_change(self) -> None:
|
||||
image = Path(self.directory.name) / "stable.png"
|
||||
image.write_bytes(PNG)
|
||||
real = os.lstat(image)
|
||||
reparse = SimpleNamespace(
|
||||
st_mode=real.st_mode,
|
||||
st_file_attributes=0x400,
|
||||
st_dev=real.st_dev,
|
||||
st_ino=real.st_ino,
|
||||
st_size=real.st_size,
|
||||
st_mtime_ns=real.st_mtime_ns,
|
||||
)
|
||||
with mock.patch("cmbuyer_client.localstate.store.os.lstat", return_value=reparse):
|
||||
with self.assertRaisesRegex(StateError, "evidence_file_not_regular"):
|
||||
_read_stable_png(image)
|
||||
|
||||
replaced = SimpleNamespace(
|
||||
st_mode=real.st_mode,
|
||||
st_file_attributes=0,
|
||||
st_dev=real.st_dev,
|
||||
st_ino=real.st_ino + 1,
|
||||
st_size=real.st_size,
|
||||
st_mtime_ns=real.st_mtime_ns,
|
||||
)
|
||||
with mock.patch("cmbuyer_client.localstate.store.os.lstat", side_effect=(real, replaced)):
|
||||
with self.assertRaisesRegex(StateError, "evidence_changed_during_read"):
|
||||
_read_stable_png(image)
|
||||
|
||||
def test_corrupt_database_and_ciphertext_fail_closed(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("DROP TRIGGER active_claims_identity_immutable")
|
||||
connection.execute("UPDATE active_claims SET claim_token_cipher=?", (sqlite3.Binary(b"corrupt"),))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(ProtectionError):
|
||||
self.new_store().active_claim(PROFILE)
|
||||
|
||||
corrupt = Path(self.directory.name) / "corrupt.sqlite3"
|
||||
corrupt.write_bytes(b"not-a-sqlite-database")
|
||||
with self.assertRaises(StateError):
|
||||
LocalStateStore(corrupt, FakeProtector())
|
||||
|
||||
def test_claim_token_cipher_cannot_be_swapped_between_attempt_histories(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
|
||||
connection.commit()
|
||||
connection.close()
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
wire = claim_wire()
|
||||
wire["task"]["id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
wire["authorization"]["id"] = "93c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
wire["attempt"]["id"] = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
wire["attempt"]["claim_token"] = "f" * 64
|
||||
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(wire))
|
||||
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("DROP TRIGGER active_claims_identity_immutable")
|
||||
rows = connection.execute("SELECT attempt_id,claim_token_cipher FROM active_claims ORDER BY created_at,attempt_id").fetchall()
|
||||
connection.execute("UPDATE active_claims SET claim_token_cipher=? WHERE attempt_id=?", (rows[1][1], rows[0][0]))
|
||||
connection.execute("UPDATE active_claims SET claim_token_cipher=? WHERE attempt_id=?", (rows[0][1], rows[1][0]))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(ProtectionError):
|
||||
self.new_store().active_claim(PROFILE)
|
||||
|
||||
def test_device_token_cipher_cannot_be_swapped_between_profiles(self) -> None:
|
||||
other = replace(settings(), profile_id="other", device_id="f3c9f507-7473-4fa6-8d71-8786c34c6301")
|
||||
self.store.save_profile(other, SecretToken("c" * 64))
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
rows = connection.execute(
|
||||
"SELECT profile_id,device_token_cipher FROM profiles WHERE profile_id IN (?,?) ORDER BY profile_id",
|
||||
(PROFILE, "other"),
|
||||
).fetchall()
|
||||
connection.execute("UPDATE profiles SET device_token_cipher=? WHERE profile_id=?", (rows[1][1], rows[0][0]))
|
||||
connection.execute("UPDATE profiles SET device_token_cipher=? WHERE profile_id=?", (rows[0][1], rows[1][0]))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(ProtectionError):
|
||||
self.new_store().load_profile(PROFILE)
|
||||
|
||||
def test_state_graph_rejects_missing_active_pending_overlap_and_snapshot_drift(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO claim_requests VALUES(?,?,?,'PENDING',?,?)",
|
||||
(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
PROFILE,
|
||||
self.store.recovery_snapshot(PROFILE).session.session_id,
|
||||
"2026-08-04T09:00:00Z",
|
||||
"2026-08-04T09:00:00Z",
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "claim_state_conflict"):
|
||||
self.store.prepare_claim(PROFILE)
|
||||
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("DROP TRIGGER claim_requests_no_delete")
|
||||
connection.execute("DELETE FROM claim_requests WHERE status='PENDING'")
|
||||
connection.execute("DROP TRIGGER active_claims_identity_immutable")
|
||||
connection.execute("UPDATE active_claims SET task_id=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "active_claim_snapshot_mismatch"):
|
||||
self.store.active_claim(PROFILE)
|
||||
|
||||
def test_succeeded_claim_tombstone_detects_missing_history(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("DROP TRIGGER active_claims_no_delete")
|
||||
connection.execute("DELETE FROM active_claims")
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "active_claim_request_mismatch"):
|
||||
self.store.prepare_claim(PROFILE)
|
||||
|
||||
def test_closed_history_is_retained_but_does_not_block_next_claim(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z'")
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertIsNone(self.store.active_claim(PROFILE))
|
||||
changed = replace(settings(), poll_interval_seconds=16)
|
||||
self.store.save_profile(changed, None)
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
self.assertIsNotNone(request.claim_request_id)
|
||||
|
||||
def test_pending_renew_and_evidence_revalidate_active_graph(self) -> None:
|
||||
self._claim_active()
|
||||
renew = self.store.prepare_renew(PROFILE)
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("DROP TRIGGER renew_requests_identity_immutable")
|
||||
connection.execute("UPDATE renew_requests SET task_id=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "renew_active_mismatch"):
|
||||
self.store.prepare_renew(PROFILE)
|
||||
|
||||
def test_success_receipt_must_match_immutable_slot(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "receipt.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
receipt = AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
TASK_ID,
|
||||
ATTEMPT_ID,
|
||||
upload.kind,
|
||||
upload.privacy_tier,
|
||||
upload.sha256,
|
||||
len(upload.content),
|
||||
"image/png",
|
||||
1,
|
||||
1,
|
||||
upload.captured_at,
|
||||
)
|
||||
self.store.commit_evidence_success(PROFILE, upload, receipt)
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
wrong = dict(receipt.__dict__)
|
||||
wrong["asset_id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("UPDATE evidence_slots SET receipt_json=?", (json.dumps(wrong),))
|
||||
connection.rollback()
|
||||
connection.execute("DROP TRIGGER evidence_slots_receipt_immutable")
|
||||
connection.execute("UPDATE evidence_slots SET receipt_json=?", (json.dumps(wrong),))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "evidence_receipt_mismatch"):
|
||||
self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
|
||||
def test_marker_and_slot_append_only_triggers_prevent_erasing_history(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "append-only.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("DELETE FROM evidence_slot_markers")
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("UPDATE evidence_slot_markers SET upload_key=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("DELETE FROM evidence_slots")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_capped_equal_renew_is_success_but_authorization_overrun_is_rejected(self) -> None:
|
||||
self._claim_active()
|
||||
request = self.store.prepare_renew(PROFILE)
|
||||
equal = RenewResult(TASK_ID, ATTEMPT_ID, 1, request.expected_lease_expires_at)
|
||||
self.store.commit_renew_success(PROFILE, request, equal)
|
||||
self.assertEqual(self.store.active_claim(PROFILE).attempt.lease_expires_at, request.expected_lease_expires_at)
|
||||
next_request = self.store.prepare_renew(PROFILE)
|
||||
beyond = RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T10:00:00.000000001Z")
|
||||
with self.assertRaisesRegex(StateError, "renew_response_mismatch"):
|
||||
self.store.commit_renew_success(PROFILE, next_request, beyond)
|
||||
|
||||
def test_sqlite_database_wal_and_shm_never_contain_plaintext_tokens(self) -> None:
|
||||
self._claim_active()
|
||||
# 强制 checkpoint 后同时扫描主文件和可能存在的 WAL/SHM。
|
||||
connection = sqlite3.connect(self.database)
|
||||
connection.execute("PRAGMA wal_checkpoint(FULL)")
|
||||
connection.close()
|
||||
for path in (self.database, Path(str(self.database) + "-wal"), Path(str(self.database) + "-shm")):
|
||||
if path.exists():
|
||||
raw = path.read_bytes()
|
||||
self.assertNotIn(DEVICE_TOKEN.encode(), raw)
|
||||
self.assertNotIn(bytes.fromhex(DEVICE_TOKEN), raw)
|
||||
self.assertNotIn(TOKEN.encode(), raw)
|
||||
self.assertNotIn(bytes.fromhex(TOKEN), raw)
|
||||
|
||||
def test_concurrent_stop_and_claim_commit_never_loses_result(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
barrier = threading.Barrier(2)
|
||||
failures: list[BaseException] = []
|
||||
|
||||
def stop() -> None:
|
||||
try:
|
||||
barrier.wait()
|
||||
self.store.request_stop(PROFILE)
|
||||
except BaseException as error:
|
||||
failures.append(error)
|
||||
|
||||
thread = threading.Thread(target=stop)
|
||||
thread.start()
|
||||
barrier.wait()
|
||||
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
|
||||
thread.join()
|
||||
self.assertEqual(failures, [])
|
||||
snapshot = self.store.recovery_snapshot(PROFILE)
|
||||
self.assertFalse(snapshot.session.accept_new)
|
||||
self.assertIsNotNone(snapshot.active_claim)
|
||||
|
||||
def test_recovery_snapshot_uses_one_sqlite_read_snapshot(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
writer = self.new_store()
|
||||
failures: list[BaseException] = []
|
||||
|
||||
class PausingStore(LocalStateStore):
|
||||
armed = False
|
||||
|
||||
def _connect(inner_self):
|
||||
connection = super(PausingStore, inner_self)._connect()
|
||||
if inner_self.armed:
|
||||
def trace(statement: str) -> None:
|
||||
if inner_self.armed and "FROM claim_requests" in statement:
|
||||
inner_self.armed = False
|
||||
thread = threading.Thread(target=do_stop)
|
||||
thread.start()
|
||||
thread.join()
|
||||
|
||||
connection.set_trace_callback(trace)
|
||||
return connection
|
||||
|
||||
def do_stop() -> None:
|
||||
try:
|
||||
writer.request_stop(PROFILE)
|
||||
except BaseException as error:
|
||||
failures.append(error)
|
||||
|
||||
reader = PausingStore(self.database, FakeProtector(), now=lambda: self.clock[0])
|
||||
reader.armed = True
|
||||
snapshot = reader.recovery_snapshot(PROFILE)
|
||||
self.assertEqual(failures, [])
|
||||
self.assertTrue(snapshot.session.accept_new)
|
||||
self.assertFalse(writer.recovery_snapshot(PROFILE).session.accept_new)
|
||||
|
||||
def test_business_snapshot_is_immutable_hashed_and_renew_never_rewrites_it(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
raw, digest, initial_lease, current_lease = connection.execute(
|
||||
"""SELECT snapshot_json,snapshot_digest,initial_lease_expires_at,lease_expires_at
|
||||
FROM active_claims WHERE closed_at IS NULL"""
|
||||
).fetchone()
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("UPDATE active_claims SET snapshot_json='{}' WHERE closed_at IS NULL")
|
||||
connection.rollback()
|
||||
connection.execute("DROP TRIGGER active_claims_identity_immutable")
|
||||
for field, changed in (
|
||||
("sku_color", "白色"),
|
||||
("sku_size", "XL"),
|
||||
("quantity", 99),
|
||||
("max_total_price", "999.00"),
|
||||
):
|
||||
payload = json.loads(raw)
|
||||
payload["task"][field] = changed
|
||||
tampered = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
connection.execute("UPDATE active_claims SET snapshot_json=? WHERE closed_at IS NULL", (tampered,))
|
||||
connection.commit()
|
||||
with self.subTest(field=field), self.assertRaisesRegex(StateError, "active_claim_snapshot_mismatch"):
|
||||
self.store.active_claim(PROFILE)
|
||||
connection.execute(
|
||||
"UPDATE active_claims SET snapshot_json=?,snapshot_digest=? WHERE closed_at IS NULL", (raw, digest)
|
||||
)
|
||||
connection.commit()
|
||||
connection.execute(
|
||||
"""UPDATE active_claims SET initial_lease_expires_at='2026-08-04T09:59:00Z',
|
||||
lease_expires_at='2026-08-04T09:59:00Z' WHERE closed_at IS NULL"""
|
||||
)
|
||||
connection.commit()
|
||||
with self.assertRaisesRegex(StateError, "invalid_stored_claim"):
|
||||
self.store.active_claim(PROFILE)
|
||||
connection.execute(
|
||||
"""UPDATE active_claims SET initial_lease_expires_at=?,lease_expires_at=?
|
||||
WHERE closed_at IS NULL""",
|
||||
(initial_lease, current_lease),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
renew = self.store.prepare_renew(PROFILE)
|
||||
self.store.commit_renew_success(PROFILE, renew, RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z"))
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
self.assertEqual(
|
||||
connection.execute("SELECT snapshot_json,snapshot_digest FROM active_claims WHERE closed_at IS NULL").fetchone(),
|
||||
(raw, digest),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_open_session_detects_tampering_of_every_non_token_profile_setting(self) -> None:
|
||||
self._claim_active()
|
||||
changed_values = {
|
||||
"service_url": "http://127.0.0.1:9999",
|
||||
"device_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"adb_path": "D:/other/adb.exe",
|
||||
"adb_serial": "usb-other",
|
||||
"transport": "usb",
|
||||
"poll_interval_seconds": 16,
|
||||
"failure_threshold": 4,
|
||||
"http_timeout_seconds": 11,
|
||||
"step_timeout_seconds": 46,
|
||||
}
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
for field, changed in changed_values.items():
|
||||
original = connection.execute(f"SELECT {field} FROM profiles WHERE profile_id=?", (PROFILE,)).fetchone()[0]
|
||||
connection.execute(f"UPDATE profiles SET {field}=? WHERE profile_id=?", (changed, PROFILE))
|
||||
connection.commit()
|
||||
with self.subTest(field=field), self.assertRaisesRegex(StateError, "polling_identity_mismatch"):
|
||||
self.store.active_claim(PROFILE)
|
||||
connection.execute(f"UPDATE profiles SET {field}=? WHERE profile_id=?", (original, PROFILE))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_closed_attempt_evidence_is_history_not_current_recovery_work(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "old-manual.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
self.store.mark_evidence_terminal(PROFILE, upload, "MANUAL")
|
||||
connection = sqlite3.connect(self.database)
|
||||
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
next_request = self.store.prepare_claim(PROFILE)
|
||||
self.assertIsNotNone(next_request.claim_request_id)
|
||||
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_evidence, ())
|
||||
|
||||
def test_succeeded_evidence_history_does_not_block_next_claim(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "old-success.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
receipt = AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
|
||||
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 1, 1, upload.captured_at,
|
||||
)
|
||||
self.store.commit_evidence_success(PROFILE, upload, receipt)
|
||||
connection = sqlite3.connect(self.database)
|
||||
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
|
||||
connection.commit()
|
||||
connection.close()
|
||||
self.assertIsNotNone(self.store.prepare_claim(PROFILE))
|
||||
|
||||
def test_evidence_slot_without_corresponding_claim_history_fails_closed(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "orphan.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
receipt = AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
|
||||
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 1, 1, upload.captured_at,
|
||||
)
|
||||
self.store.commit_evidence_success(PROFILE, upload, receipt)
|
||||
orphan = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("DROP TRIGGER evidence_slot_markers_immutable")
|
||||
connection.execute("DROP TRIGGER evidence_slots_identity_immutable")
|
||||
connection.execute("UPDATE evidence_slot_markers SET attempt_id=?", (orphan,))
|
||||
connection.execute("UPDATE evidence_slots SET attempt_id=?", (orphan,))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "evidence_claim_history_mismatch"):
|
||||
self.store.recovery_snapshot(PROFILE)
|
||||
|
||||
def test_terminal_renew_is_scoped_to_old_closed_attempt(self) -> None:
|
||||
self._claim_active()
|
||||
old_renew = self.store.prepare_renew(PROFILE)
|
||||
self.store.mark_renew_terminal(PROFILE, old_renew, "MANUAL")
|
||||
connection = sqlite3.connect(self.database)
|
||||
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
wire = claim_wire()
|
||||
wire["task"]["id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
wire["authorization"]["id"] = "93c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
wire["attempt"]["id"] = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(wire))
|
||||
next_renew = self.store.prepare_renew(PROFILE)
|
||||
self.assertNotEqual(next_renew.renew_request_id, old_renew.renew_request_id)
|
||||
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_renew, next_renew)
|
||||
|
||||
def test_successful_renew_response_is_write_once_and_digest_checked(self) -> None:
|
||||
self._claim_active()
|
||||
request = self.store.prepare_renew(PROFILE)
|
||||
self.store.commit_renew_success(PROFILE, request, RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z"))
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("UPDATE renew_requests SET response_json='{}' WHERE renew_request_id=?", (request.renew_request_id,))
|
||||
connection.rollback()
|
||||
connection.execute("DROP TRIGGER renew_requests_response_immutable")
|
||||
connection.execute("UPDATE renew_requests SET response_json='{}' WHERE renew_request_id=?", (request.renew_request_id,))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "renew_response_mismatch"):
|
||||
self.store.active_claim(PROFILE)
|
||||
|
||||
def test_invalid_or_reversed_session_and_claim_timestamps_fail_closed(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T08:59:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "invalid_claim_timeline"):
|
||||
self.store.recovery_snapshot(PROFILE)
|
||||
|
||||
def test_invalid_session_closed_at_fails_closed(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("UPDATE polling_sessions SET closed_at='not-a-time' WHERE profile_id=?", (PROFILE,))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(StateError):
|
||||
self.store.recovery_snapshot(PROFILE)
|
||||
|
||||
def test_store_rejects_forged_claim_whose_lease_exceeds_authorization(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
claimed = ClaimedTask.from_wire(claim_wire())
|
||||
object.__setattr__(claimed.attempt, "lease_expires_at", "2026-08-04T10:00:00.000000001Z")
|
||||
with self.assertRaisesRegex(StateError, "claim_lease_exceeds_authorization"):
|
||||
self.store.commit_claim_success(PROFILE, request, claimed)
|
||||
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_claim, request)
|
||||
|
||||
def _claim_active(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
|
||||
@@ -0,0 +1 @@
|
||||
"""remote tests。"""
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import base64
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.models import DeviceCredentials, EvidenceUpload, SecretToken
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError
|
||||
from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink
|
||||
from cmbuyer_client.remote.http_transport import HttpResponse
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN
|
||||
from tests.remote.test_task_source import DEVICE_ID, FakeTransport
|
||||
|
||||
|
||||
UPLOAD_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
ASSET_ID = "63c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
class EvidenceSinkTests(unittest.TestCase):
|
||||
def test_upload_has_fixed_fields_and_never_contains_local_path(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
payload = {
|
||||
"asset_id": ASSET_ID,
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
transport = FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))
|
||||
receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(receipt.asset_id, ASSET_ID)
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
body = transport.calls[0][3]
|
||||
self.assertIn(b'filename="evidence.png"', body)
|
||||
self.assertNotIn(b"C:\\", body)
|
||||
self.assertNotIn(b"manifest", body)
|
||||
self.assertNotIn(b".xml", body)
|
||||
names = re.findall(br'Content-Disposition: form-data; name="([^"]+)"', body)
|
||||
self.assertEqual(
|
||||
names,
|
||||
[b"upload_key", b"attempt_id", b"kind", b"privacy_tier", b"sha256", b"captured_at", b"file"],
|
||||
)
|
||||
self.assertEqual(body.count(b'filename="evidence.png"'), 1)
|
||||
self.assertNotIn(b"claim_token", body)
|
||||
self.assertNotIn(b"session_id", body)
|
||||
boundary = dict(transport.calls[0][2])["Content-Type"].split("boundary=", 1)[1]
|
||||
self.assertEqual(boundary, "cmbuyer-" + UPLOAD_ID.replace("-", ""))
|
||||
|
||||
def test_captured_at_equivalent_trailing_zeros_are_accepted(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00.120000Z", PNG)
|
||||
payload = {
|
||||
"asset_id": ASSET_ID,
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00.12Z",
|
||||
}
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
for status in (200, 201):
|
||||
with self.subTest(status=status):
|
||||
transport = FakeTransport(HttpResponse(status, (("Content-Type", "application/json"),), raw))
|
||||
receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(receipt.captured_at, "2026-08-04T09:01:00.12Z")
|
||||
|
||||
def test_wrong_content_hash_fails_before_http_object_can_be_built(self) -> None:
|
||||
transport = FakeTransport(HttpResponse(500, (), b""))
|
||||
with self.assertRaises(ValidationError):
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, "0" * 64, "2026-08-04T09:01:00Z", PNG)
|
||||
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(transport.calls, [])
|
||||
|
||||
def test_unknown_2xx_is_ambiguous(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
for status in (202, 204, 206):
|
||||
with self.subTest(status=status):
|
||||
transport = FakeTransport(HttpResponse(status, (), b""))
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
|
||||
def test_boundary_collision_and_receipt_mismatch_fail_closed(self) -> None:
|
||||
marker = ("--cmbuyer-" + UPLOAD_ID.replace("-", "")).encode()
|
||||
collision_content = PNG + marker
|
||||
collision = EvidenceUpload(
|
||||
TASK_ID,
|
||||
UPLOAD_ID,
|
||||
ATTEMPT_ID,
|
||||
hashlib.sha256(collision_content).hexdigest(),
|
||||
"2026-08-04T09:01:00Z",
|
||||
collision_content,
|
||||
)
|
||||
transport = FakeTransport(HttpResponse(500, (), b""))
|
||||
from cmbuyer_client.core.errors import ProtocolRemoteError
|
||||
with self.assertRaises(ProtocolRemoteError):
|
||||
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), collision)
|
||||
self.assertEqual(transport.calls, [])
|
||||
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
mismatch = {
|
||||
"asset_id": ASSET_ID,
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": "f" * 64,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
raw = json.dumps(mismatch, separators=(",", ":")).encode()
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload(
|
||||
DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload
|
||||
)
|
||||
|
||||
dimension_mismatch = dict(mismatch)
|
||||
dimension_mismatch["sha256"] = digest
|
||||
dimension_mismatch["width_px"] = 2
|
||||
raw = json.dumps(dimension_mismatch, separators=(",", ":")).encode()
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload(
|
||||
DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload
|
||||
)
|
||||
|
||||
def test_evidence_error_status_matrix(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN))
|
||||
from cmbuyer_client.core.errors import CredentialRemoteError, ManualRemoteError, ProtocolRemoteError
|
||||
|
||||
cases = (
|
||||
(401, CredentialRemoteError),
|
||||
(400, ProtocolRemoteError),
|
||||
(403, ProtocolRemoteError),
|
||||
(409, ManualRemoteError),
|
||||
(413, ProtocolRemoteError),
|
||||
(415, ProtocolRemoteError),
|
||||
(500, AmbiguousRemoteError),
|
||||
(503, AmbiguousRemoteError),
|
||||
)
|
||||
for status, expected in cases:
|
||||
with self.subTest(status=status), self.assertRaises(expected):
|
||||
HttpEvidenceSink(FakeTransport(HttpResponse(status, (), b""))).upload(credentials, upload)
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import http.client
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError
|
||||
from cmbuyer_client.remote.http_transport import HttpTransport
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __init__(self, body: bytes = b"{}", headers: list[tuple[str, str]] | None = None) -> None:
|
||||
self.body = body
|
||||
self.headers = headers or [("Content-Type", "application/json")]
|
||||
|
||||
def getheader(self, name: str) -> str | None:
|
||||
return str(len(self.body)) if name == "Content-Length" else None
|
||||
|
||||
def getheaders(self) -> list[tuple[str, str]]:
|
||||
return self.headers
|
||||
|
||||
def read(self, maximum: int) -> bytes:
|
||||
return self.body[:maximum]
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, host: str, port: int, timeout: int) -> None:
|
||||
self.created = (host, port, timeout)
|
||||
self.calls = 0
|
||||
self.closed = False
|
||||
self.sent_headers: dict[str, str] = {}
|
||||
self.response = FakeResponse()
|
||||
|
||||
def request(self, method: str, path: str, body: bytes, headers: dict[str, str]) -> None:
|
||||
self.calls += 1
|
||||
self.sent_headers = headers
|
||||
|
||||
def getresponse(self) -> FakeResponse:
|
||||
return self.response
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class HttpTransportTests(unittest.TestCase):
|
||||
def test_exact_loopback_and_proxy_environment_is_irrelevant(self) -> None:
|
||||
made: list[FakeConnection] = []
|
||||
|
||||
def factory(*args: object, **kwargs: object) -> FakeConnection:
|
||||
connection = FakeConnection(*args, **kwargs)
|
||||
made.append(connection)
|
||||
return connection
|
||||
|
||||
with mock.patch.dict(os.environ, {"HTTP_PROXY": "http://example.invalid:9999"}):
|
||||
result = HttpTransport(connection_factory=factory).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (("Content-Type", "application/json"),), b"{}", response_limit=10
|
||||
)
|
||||
self.assertEqual(result.status, 200)
|
||||
self.assertEqual(made[0].created, ("127.0.0.1", 8080, 10))
|
||||
self.assertEqual(made[0].calls, 1)
|
||||
self.assertTrue(made[0].closed)
|
||||
|
||||
for url in ("http://localhost:8080", "http://127.0.0.1:8081", "http://127.0.0.1:8080/", "https://127.0.0.1:8080"):
|
||||
with self.subTest(url=url), self.assertRaises(ProtocolRemoteError):
|
||||
HttpTransport(url)
|
||||
|
||||
def test_network_failure_is_ambiguous_without_retry(self) -> None:
|
||||
class Broken(FakeConnection):
|
||||
def getresponse(self) -> FakeResponse:
|
||||
raise OSError("offline")
|
||||
|
||||
made: list[Broken] = []
|
||||
|
||||
def factory(*args: object, **kwargs: object) -> Broken:
|
||||
connection = Broken(*args, **kwargs)
|
||||
made.append(connection)
|
||||
return connection
|
||||
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTransport(connection_factory=factory).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=10
|
||||
)
|
||||
self.assertEqual(made[0].calls, 1)
|
||||
|
||||
def test_generator_headers_and_content_length_framing(self) -> None:
|
||||
made: list[FakeConnection] = []
|
||||
|
||||
def factory(*args: object, **kwargs: object) -> FakeConnection:
|
||||
connection = FakeConnection(*args, **kwargs)
|
||||
made.append(connection)
|
||||
return connection
|
||||
|
||||
headers = ((name, value) for name, value in (("Content-Type", "application/json"), ("Accept", "application/json")))
|
||||
HttpTransport(connection_factory=factory).request(
|
||||
"POST", "/api/v1/tasks/claim-next", headers, b"{}", response_limit=8
|
||||
)
|
||||
self.assertEqual(made[0].sent_headers["Content-Type"], "application/json")
|
||||
self.assertEqual(made[0].sent_headers["Accept"], "application/json")
|
||||
|
||||
legal = FakeConnection("127.0.0.1", 8080, 10)
|
||||
legal.response = FakeResponse(b"{}", [("Content-Length", "2")])
|
||||
accepted = HttpTransport(connection_factory=lambda *args, **kwargs: legal).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
self.assertEqual(accepted.body, b"{}")
|
||||
chunked = FakeConnection("127.0.0.1", 8080, 10)
|
||||
chunked.response = FakeResponse(b"{}", [("Transfer-Encoding", "Chunked")])
|
||||
accepted_chunked = HttpTransport(connection_factory=lambda *args, **kwargs: chunked).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
self.assertEqual(accepted_chunked.body, b"{}")
|
||||
|
||||
cases = (
|
||||
([('Transfer-Encoding', 'chunked'), ('Content-Length', '2')], b'{}'),
|
||||
([('Transfer-Encoding', 'gzip')], b'{}'),
|
||||
([('Transfer-Encoding', ' chunked ')], b'{}'),
|
||||
([('Transfer-Encoding', 'chunked,gzip')], b'{}'),
|
||||
([('Transfer-Encoding', 'chunked'), ('Transfer-Encoding', 'chunked')], b'{}'),
|
||||
([("Content-Length", "2"), ("Content-Length", "2")], b"{}"),
|
||||
([("Content-Length", "+2")], b"{}"),
|
||||
([("Content-Length", "-0")], b""),
|
||||
([("Content-Length", "2x")], b"{}"),
|
||||
([("Content-Length", "3")], b"{}"),
|
||||
([("Content-Length", "1")], b"{}"),
|
||||
([("Content-Length", "999")], b"{}"),
|
||||
([], b"0123456789"),
|
||||
)
|
||||
for response_headers, body in cases:
|
||||
with self.subTest(headers=response_headers, body=body):
|
||||
connection = FakeConnection("127.0.0.1", 8080, 10)
|
||||
connection.response = FakeResponse(body, response_headers)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTransport(connection_factory=lambda *args, value=connection, **kwargs: value).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
|
||||
def test_timeout_incomplete_read_and_close_do_not_expose_partial_body(self) -> None:
|
||||
token = ("a" * 64).encode()
|
||||
|
||||
class Incomplete(FakeResponse):
|
||||
def read(self, maximum: int) -> bytes:
|
||||
raise http.client.IncompleteRead(token, 1)
|
||||
|
||||
class Connection(FakeConnection):
|
||||
def getresponse(self) -> FakeResponse:
|
||||
return Incomplete()
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
raise OSError("close failed")
|
||||
|
||||
with self.assertRaises(AmbiguousRemoteError) as captured:
|
||||
HttpTransport(connection_factory=Connection).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=128
|
||||
)
|
||||
self.assertNotIn(token.decode(), _exception_graph(captured.exception))
|
||||
|
||||
class Timeout(FakeConnection):
|
||||
def getresponse(self) -> FakeResponse:
|
||||
raise TimeoutError("timed out")
|
||||
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTransport(connection_factory=Timeout).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
|
||||
|
||||
def _exception_graph(error: BaseException) -> str:
|
||||
seen: set[int] = set()
|
||||
values: list[str] = []
|
||||
pending: list[object] = [error]
|
||||
while pending:
|
||||
value = pending.pop()
|
||||
if id(value) in seen:
|
||||
continue
|
||||
seen.add(id(value))
|
||||
values.append(repr(value))
|
||||
if isinstance(value, BaseException):
|
||||
pending.extend(item for item in (value.__cause__, value.__context__) if item is not None)
|
||||
pending.extend(value.__dict__.values())
|
||||
return "\n".join(values)
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, ProtocolRemoteError
|
||||
from cmbuyer_client.core.models import ClaimRequest, DeviceCredentials, RenewRequest, SecretToken
|
||||
from cmbuyer_client.remote.http_transport import HttpResponse
|
||||
from cmbuyer_client.remote.task_source import HttpTaskSource
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN, claim_wire
|
||||
from tests.remote.test_http_transport import _exception_graph
|
||||
|
||||
|
||||
DEVICE_ID = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
SESSION_ID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
REQUEST_ID = "33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
RENEW_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self, response: HttpResponse) -> None:
|
||||
self.response = response
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
|
||||
def request(self, *args: object, **kwargs: object) -> HttpResponse:
|
||||
self.calls.append(args + (kwargs,))
|
||||
return self.response
|
||||
|
||||
|
||||
def response(status: int, value: object | None = None) -> HttpResponse:
|
||||
body = b"" if value is None else json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
headers = () if value is None else (("Content-Type", "application/json; charset=utf-8"),)
|
||||
return HttpResponse(status, headers, body)
|
||||
|
||||
|
||||
class TaskSourceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN))
|
||||
|
||||
def test_claim_success_and_empty_each_send_once_with_exact_headers(self) -> None:
|
||||
transport = FakeTransport(response(200, claim_wire()))
|
||||
claimed = HttpTaskSource(transport).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertEqual(claimed.task.id, TASK_ID)
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
args = transport.calls[0]
|
||||
self.assertEqual(args[1], "/api/v1/tasks/claim-next")
|
||||
headers = dict(args[2])
|
||||
self.assertEqual(headers["Authorization"], "Bearer " + TOKEN)
|
||||
self.assertEqual(headers["X-CMBuyer-Device-ID"], DEVICE_ID)
|
||||
|
||||
empty = FakeTransport(response(204))
|
||||
self.assertIsNone(HttpTaskSource(empty).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)))
|
||||
self.assertEqual(len(empty.calls), 1)
|
||||
|
||||
def test_invalid_2xx_is_unknown_and_redirect_is_not_followed(self) -> None:
|
||||
malformed = FakeTransport(HttpResponse(200, (("Content-Type", "application/json"),), b'{"task":'))
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(malformed).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertEqual(len(malformed.calls), 1)
|
||||
|
||||
secret_body = b'{"claim_token":"' + TOKEN.encode()
|
||||
leaking = FakeTransport(HttpResponse(200, (("Content-Type", "application/json"),), secret_body))
|
||||
with self.assertRaises(AmbiguousRemoteError) as captured:
|
||||
HttpTaskSource(leaking).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertNotIn(TOKEN, _exception_graph(captured.exception))
|
||||
|
||||
for status in (201, 202, 206):
|
||||
with self.subTest(status=status), self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(response(status, claim_wire()))).claim_next(
|
||||
self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)
|
||||
)
|
||||
|
||||
redirect = FakeTransport(HttpResponse(302, (("Location", "http://example.invalid"),), b""))
|
||||
with self.assertRaises(ProtocolRemoteError):
|
||||
HttpTaskSource(redirect).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertEqual(len(redirect.calls), 1)
|
||||
|
||||
def test_fixed_conflict_and_renew_cas(self) -> None:
|
||||
conflict = FakeTransport(response(409, {"error": "claim_requires_manual"}))
|
||||
with self.assertRaises(ManualRemoteError):
|
||||
HttpTaskSource(conflict).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
|
||||
request = RenewRequest(TASK_ID, RENEW_ID, SESSION_ID, ATTEMPT_ID, 1, SecretToken(TOKEN), "2026-08-04T09:05:00Z", "2026-08-04T10:00:00Z")
|
||||
renewed = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T09:06:00Z"})
|
||||
result = HttpTaskSource(FakeTransport(renewed)).renew(self.credentials, request)
|
||||
self.assertEqual(result.claim_generation, 1)
|
||||
|
||||
capped = RenewRequest(TASK_ID, RENEW_ID, SESSION_ID, ATTEMPT_ID, 1, SecretToken(TOKEN), "2026-08-04T10:00:00.000000000Z", "2026-08-04T10:00:00Z")
|
||||
capped_result = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T10:00:00Z"})
|
||||
self.assertEqual(HttpTaskSource(FakeTransport(capped_result)).renew(self.credentials, capped).lease_expires_at, "2026-08-04T10:00:00Z")
|
||||
|
||||
beyond_cap = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T10:00:00.000000001Z"})
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(beyond_cap)).renew(self.credentials, request)
|
||||
|
||||
stale = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T09:04:00Z"})
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(stale)).renew(self.credentials, request)
|
||||
for status in (201, 204):
|
||||
with self.subTest(status=status), self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(response(status, None if status == 204 else {
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:06:00Z",
|
||||
}))).renew(self.credentials, request)
|
||||
|
||||
def test_claim_and_renew_error_status_matrix(self) -> None:
|
||||
claim_request = ClaimRequest(SESSION_ID, REQUEST_ID)
|
||||
claim_cases = (
|
||||
(HttpResponse(401, (), b""), CredentialRemoteError),
|
||||
(response(400, {"error": "invalid_request"}), ProtocolRemoteError),
|
||||
(HttpResponse(403, (), b""), ProtocolRemoteError),
|
||||
(response(413, {"error": "request_too_large"}), ProtocolRemoteError),
|
||||
(response(415, {"error": "unsupported_media_type"}), ProtocolRemoteError),
|
||||
(HttpResponse(500, (), b""), AmbiguousRemoteError),
|
||||
(HttpResponse(503, (), b""), AmbiguousRemoteError),
|
||||
(HttpResponse(418, (), b""), ProtocolRemoteError),
|
||||
)
|
||||
for wire_response, expected in claim_cases:
|
||||
with self.subTest(status=wire_response.status), self.assertRaises(expected):
|
||||
HttpTaskSource(FakeTransport(wire_response)).claim_next(self.credentials, claim_request)
|
||||
|
||||
renew_request = RenewRequest(
|
||||
TASK_ID,
|
||||
RENEW_ID,
|
||||
SESSION_ID,
|
||||
ATTEMPT_ID,
|
||||
1,
|
||||
SecretToken(TOKEN),
|
||||
"2026-08-04T09:05:00Z",
|
||||
"2026-08-04T10:00:00Z",
|
||||
)
|
||||
for code in ("idempotency_conflict", "claim_not_current"):
|
||||
with self.subTest(code=code), self.assertRaises(ManualRemoteError):
|
||||
HttpTaskSource(FakeTransport(response(409, {"error": code}))).renew(self.credentials, renew_request)
|
||||
@@ -47,3 +47,24 @@ class LoggingPolicyTests(unittest.TestCase):
|
||||
self.assertNotIn("not-for-log", content)
|
||||
self.assertNotIn("13900139000", content)
|
||||
self.assertIn("[已隐藏]", content)
|
||||
|
||||
def test_bearer_bare_token_and_traceback_are_redacted(self) -> None:
|
||||
token = "a" * 64
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = RuntimePaths.from_root(Path(directory))
|
||||
logger = configure_application_logger(paths)
|
||||
try:
|
||||
try:
|
||||
raise RuntimeError("credential=" + token)
|
||||
except RuntimeError:
|
||||
logger.exception("Authorization: Bearer %s bare=%s", token, token)
|
||||
for handler in logger.handlers:
|
||||
handler.flush()
|
||||
content = (paths.logs / "client.log").read_text(encoding="utf-8")
|
||||
finally:
|
||||
for handler in tuple(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
self.assertNotIn(token, content)
|
||||
self.assertIn("[已隐藏]", content)
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.runtime import RuntimePaths
|
||||
from cmbuyer_client.runtime import LocalStateRuntime, RuntimePaths
|
||||
|
||||
|
||||
class RuntimePathsTests(unittest.TestCase):
|
||||
@@ -23,3 +25,58 @@ class RuntimePathsTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(paths.logs.is_dir())
|
||||
self.assertTrue(paths.artifacts.is_dir())
|
||||
self.assertTrue(paths.state.is_dir())
|
||||
self.assertEqual(paths.database, paths.state / "client-state.sqlite3")
|
||||
|
||||
def test_localstate_runtime_acquires_mutex_before_protector_and_store(self) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class Mutex:
|
||||
def __init__(self, path: Path) -> None:
|
||||
events.append("mutex")
|
||||
|
||||
def close(self) -> None:
|
||||
events.append("close")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
runtime = LocalStateRuntime.open(
|
||||
RuntimePaths.from_root(Path(directory)),
|
||||
mutex_factory=Mutex,
|
||||
protector_factory=lambda: events.append("protector") or object(),
|
||||
store_factory=lambda path, protector: events.append("store") or object(),
|
||||
)
|
||||
runtime.close()
|
||||
self.assertEqual(events, ["mutex", "protector", "store", "close"])
|
||||
|
||||
def test_localstate_runtime_releases_mutex_if_open_fails(self) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class Mutex:
|
||||
def __init__(self, path: Path) -> None:
|
||||
events.append("mutex")
|
||||
|
||||
def close(self) -> None:
|
||||
events.append("close")
|
||||
|
||||
def fail() -> object:
|
||||
raise RuntimeError("failed")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, self.assertRaises(RuntimeError):
|
||||
LocalStateRuntime.open(
|
||||
RuntimePaths.from_root(Path(directory)),
|
||||
mutex_factory=Mutex,
|
||||
protector_factory=fail,
|
||||
)
|
||||
self.assertEqual(events, ["mutex", "close"])
|
||||
|
||||
def test_windows_without_localappdata_fails_instead_of_creating_second_database(self) -> None:
|
||||
with mock.patch("cmbuyer_client.runtime.os.name", "nt"), mock.patch.dict(os.environ, {}, clear=True):
|
||||
with self.assertRaisesRegex(RuntimeError, "local_app_data_required"):
|
||||
RuntimePaths.default()
|
||||
|
||||
def test_runtime_root_is_frozen_absolute_and_relative_localappdata_is_rejected(self) -> None:
|
||||
paths = RuntimePaths.from_root(Path("relative-runtime"))
|
||||
self.assertTrue(paths.root.is_absolute())
|
||||
with mock.patch.dict(os.environ, {"LOCALAPPDATA": "relative-local-app-data"}, clear=True):
|
||||
with self.assertRaisesRegex(RuntimeError, "local_app_data_must_be_absolute"):
|
||||
RuntimePaths.default()
|
||||
|
||||
@@ -87,13 +87,15 @@ T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择
|
||||
|
||||
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
||||
|
||||
1. T-111 先冻结“开始采购授权”的单趟契约;同时主审并合入只创建 `DRAFT` 的 T-202。
|
||||
2. T-111 完成后恢复 **T-103 真机取证**;按依赖推进 T-104 → T-107 的分段安全判据。
|
||||
3. admin agent 先做 T-209 把旧两趟 schema / 状态机迁到单趟,再推进 T-203 服务端批量开始采购 /
|
||||
一次性授权;两者不依赖页面选择器。T-205 起的 attempt 与证据字段等待 T-103。
|
||||
4. Phase 3:双端打通与单趟围栏前 dry-run。
|
||||
5. Phase 4:单趟真实提交、待付款、调和与收尾。
|
||||
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
|
||||
1. 完成 **T-103 人工真机验收**,再按依赖推进 T-104 → T-107 的分段安全判据。
|
||||
2. admin agent 在 T-107 后推进 T-210 → T-205 → T-206 → T-207 → T-208,冻结三闸门证据、
|
||||
attempt 事件、围栏、一次性结果与人工调和。
|
||||
3. client agent 先做当前可领取的 T-303,再推进 T-304 / T-306 / T-307 / T-308;与 admin 写路径
|
||||
不重叠的任务继续并行。
|
||||
4. T-305 完成围栏前真机 dry-run;T-400 只用 fixture/fake port 建立离线一次性提交安全闭包。
|
||||
5. T-403 → T-402 先准备围栏后调和与人工付款事实记录;T-401 才在新真实单趟中首次点击一次,
|
||||
点击后固定报 `UNKNOWN` 并由人调和,绝不付款。
|
||||
6. T-405 生成可核验 Windows 包,T-404 对同一候选 commit 与产物做最终只读验收;V2 能力继续后置。
|
||||
|
||||
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
|
||||
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
| 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 |
|
||||
| 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 |
|
||||
| 截图处理 | `Pillow` | 已定 | 判断页面是否渲染完成,避免保存白屏壳层 |
|
||||
| HTTP 客户端 | 标准库 `urllib` 或 `httpx` | **待定** | 先用标准库;确有重试/连接池需求再评估 |
|
||||
| HTTP 客户端 | 标准库 `http.client` | 已定 | 只直连 `127.0.0.1:8080`;不读代理、不跟随重定向、不做隐藏重试或连接池 |
|
||||
| 可恢复状态 | 标准库 `sqlite3`(WAL / FULL) | 已定 | request/slot 先落库再 HTTP;每个操作独立连接,恢复读取使用单一事务快照 |
|
||||
| 秘密保护 | Windows Current User DPAPI | 已定 | token 原始 32 字节只以绑定 profile+device/attempt context 的密文 BLOB 入库;非 Windows 不降级 |
|
||||
| 单实例 | Windows `Global\` named mutex | 已定 | 以规范数据库路径 hash 命名,先于 DPAPI/SQLite 取得,覆盖同用户跨 session |
|
||||
| Excel | 不引入 | 已定 | Excel 解析移到采购服务;采购工具不再直接读表 |
|
||||
| 测试 | `unittest`(标准库) | 已定 | 前序项目 171 项测试均用标准库,无需 pytest |
|
||||
| 打包 | `pyinstaller` | 已定 | 交付给运营电脑;开发期依赖 |
|
||||
@@ -55,6 +58,10 @@
|
||||
Go 侧重写表头校验和行级报错,不能直接复用前序项目的 Python 实现。
|
||||
- **采购工具不持有业务权威。** 金额上限、授权有效性、任务状态流转的判定权在采购服务;
|
||||
采购工具本地校验只作为第二道防线,两边不一致时一律转人工。
|
||||
- **HTTP 不做自动重试。** claim/renew/evidence 的重放权属于持有 durable 幂等槽的恢复门面;底层每次
|
||||
方法最多一个请求。401 修复 Bearer 后、结果不明或重启恢复都必须复用原 key 与原 body/file。
|
||||
- **客户端 SQLite 只保存恢复事实。** append-only session/claim/renew/evidence history 不是服务端任务
|
||||
权威;它的作用是阻止崩溃、并发或状态损坏导致第二次领取、换图或换 key。
|
||||
- **不引入 pytest / 不引入 ORM。** 同一职责不并存两套方案。
|
||||
|
||||
## 五、构建与运行命令
|
||||
|
||||
+20
-1
@@ -381,6 +381,24 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
||||
- 围栏建立后即使租约过期也只恢复同一 `order_submission` 的调和,不能回到可领取队列。
|
||||
- 每种非终态都必须给出安全下一步,不能出现隐藏表单导致任务永久锁死。
|
||||
|
||||
#### 采购工具本地恢复状态
|
||||
|
||||
- 生产入口先取得基于规范数据库路径的 Windows `Global\` named mutex,再构造 DPAPI 和 SQLite;不能
|
||||
以服务端“单设备最多一个 claim”替代本机单实例。
|
||||
- profile、polling session、claim/renew request、open/historical claim、evidence marker/slot 使用 WAL、
|
||||
`synchronous=FULL` 与 append-only/单调关闭约束。当前 session/claim 由 `closed_at IS NULL` partial
|
||||
unique 保证唯一,历史关闭后不阻塞下一条,但不得删除或复活。
|
||||
- `DurableClientGateway` 是轮询和截图接入的唯一顺序入口:先 durable prepare,再一次 HTTP,最后原子
|
||||
commit。停止只把当前 session 的 `accept_new` 设为 false;飞行中响应仍提交,pending/open 不清除。
|
||||
- 每次发送前校验 profile/session/request/active immutable business snapshot、renew history、evidence marker/slot/
|
||||
receipt 的完整状态图;snapshot、renew response 与 evidence receipt 还保存不可变摘要。任一冗余事实不一致、
|
||||
DPAPI context 不匹配、数据库缺行或文件 identity 变化时零 HTTP 停止,不能自行“修复”。
|
||||
- 设备 Bearer 的 401 发生在服务端读取 body/写幂等事实前,因此槽保持 `PENDING`;只允许同 device id
|
||||
更新 Bearer,并在用户再次开始后用原 key/body/file 尝试。claim token 和其他 frozen 配置不变。
|
||||
- 原始 token 以 DPAPI current-user context 密文保存:device token 绑定 profile+device,claim/renew token
|
||||
绑定 profile+attempt,密文不能跨行复用;日志 formatter 对 Bearer、裸 64 位 token 和
|
||||
traceback 做最终脱敏。异常对象也只保留固定 reason,不挂接含响应 body/partial/path 的异常上下文。
|
||||
|
||||
### 5.4 证据分层
|
||||
|
||||
| 数据 | 位置 | 边界 |
|
||||
@@ -458,7 +476,8 @@ cmbuyer/
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
执行器依赖 `TaskSource` / `ResultSink`,不直接读取 Excel 或拼接 HTTP。来源变化不得改变安全执行器。
|
||||
执行器依赖核心端口,不直接读取 Excel 或拼接 HTTP。T-303 只提供 `TaskSource` / `EvidenceSink`;完整
|
||||
`ResultSink` 在服务端 events/fail/fence/result 契约落地后分阶段组合,来源变化不得改变安全执行器。
|
||||
|
||||
## 九、架构纪律
|
||||
|
||||
|
||||
+13
-11
@@ -20,9 +20,9 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
|
||||
| 1 | T-001 → T-004 → T-201 → T-202 | T-002 → T-101 → T-102 | T-003、原型与契约 |
|
||||
| 2 | T-209 → T-203 → T-204 | T-103 → T-104 → T-105 → T-107 | T-111 单趟重构;逐任务主审 |
|
||||
| 3 | T-210 → T-205 → T-206 → T-207 → T-208 | T-301 → T-302 → T-303,再并行 T-304 / T-306 / T-307 | 双端契约完整门禁 |
|
||||
| 4 | - | T-305 | 围栏前端到端 dry-run 主审 |
|
||||
| 5 | T-402 / T-403 | T-401 | 真机人工授权、提交与调和验收 |
|
||||
| 6 | - | T-405 | T-404 完整验收 |
|
||||
| 4 | T-403 → T-402 | T-305;T-208 / T-303 / T-307 后做 T-308;再汇合到 T-400 | 围栏前 dry-run、客户端围栏恢复与离线单次提交主审 |
|
||||
| 5 | - | T-401 | 新真实单趟、首次一次性提交与人工调和验收 |
|
||||
| 6 | - | T-405 | T-404 完整只读验收 |
|
||||
|
||||
并行门禁:
|
||||
|
||||
@@ -90,21 +90,23 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
|
||||
| --- | --- | --- | --- |
|
||||
| T-301 | 设备凭据与身份隔离(F-013) | T-201 | Bearer 不能建单/授权;管理会话不能领任务;凭据可撤销 |
|
||||
| T-302 | 已授权任务原子领取与租约(F-005) | T-301, T-203 | 只领 PENDING+有效授权;并发唯一;重放同一 attempt;claim token/generation 有效 |
|
||||
| T-303 | HTTP 任务源、证据 sink 与可恢复本地状态 | T-002, T-204, T-302 | 严格 claim/renew/evidence HTTP、DPAPI/SQLite、单实例和原子恢复槽;不伪造完整 ResultSink |
|
||||
| T-303 | HTTP 任务源、证据 sink 与可恢复本地状态 | T-002, T-204, T-302 | `http.client` 单次回环请求、DPAPI/SQLite append-only 状态图、Global 单实例和 durable gateway;不伪造完整 ResultSink |
|
||||
| T-304 | 定时轮询与会话边界 | T-303, T-006 | 人启动后轮询;停止只阻止下次领取;连续失败停;当前任务/记录详情双视图 |
|
||||
| T-306 | 规格面板原始截图与可靠证据上传 | T-104, T-303 | 只发布 Gate1 的显式原始 PNG;唯一恢复槽;可含页面地址/手机号;不上传 XML/路径或扩 kind |
|
||||
| T-307 | 客户端 attempt 事件与失败 sink | T-205, T-303 | 窄 events/fail HTTP 与同键恢复;不实现 ResultSink、围栏、结果、PDD 或 UI |
|
||||
| T-308 | 客户端提交围栏与结果 sink | T-208, T-303, T-307 | 复用同一 transport/localstate;首次明确且 durable 的 CREATE_ONCE 才产生当前进程 opaque permit;重启不可重建 |
|
||||
| T-305 | **单趟围栏前端到端 dry-run** | T-107, T-304, T-306, T-307, T-210 | 连续跑到三闸门及最终控件只读观察,随后一次安全 Back 并以固定 reason 停在围栏前;不申请许可、不点击提交 |
|
||||
|
||||
### Phase 4 · 真实提交与收尾
|
||||
|
||||
| ID | 任务 | 依赖 | 验收要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| T-401 | **单趟采购与提交围栏端到端** | T-305, T-208 | 同一设备会话通过三闸门、首次明确围栏许可后点击一次;结果不明只调和;明确创建转待付款;绝不付款 |
|
||||
| T-402 | 待付款收口与手工完成 | T-401 | 详情展示订单证据;人付款后手工标记;待付款不等于成功 |
|
||||
| T-403 | 失败分类与证据归档 | T-401 | 覆盖需求 reason;终态原子回写;围栏后无重试 |
|
||||
| T-404 | 完整 MVP 验收 | T-402, T-403 | P0 验收、双端门禁、真机记录和 current-state 全部通过 |
|
||||
| T-405 | 采购工具打包与运行文档 | T-404 | 运营电脑可安装运行,记录产物 SHA-256 |
|
||||
| T-400 | 离线单次提交安全编排 | T-305, T-308 | 只用 fixture/fake port 证明四条件、点击意图先落盘、调用恰好一次和崩溃后只调和;生产接线仍不可达 |
|
||||
| T-403 | 失败分类、证据归档与管理员调和视图 | T-208 | 只展示已有事实并复用同一 submission 调和事务;围栏后无重试、释放或重新授权 |
|
||||
| T-402 | 待付款人工收口与付款事实记录 | T-403 | 只记录人已在外部付款的事实;三组确认、完整 lineage 与追加式审计;系统不付款 |
|
||||
| T-401 | **首次真实提交与生产接线** | T-400, T-402 | 新真实单趟重新通过三闸门;首次明确许可后点击一次;首次运行固定 UNKNOWN 并人工调和;绝不付款 |
|
||||
| T-405 | 采购工具 Windows 打包与运行文档 | T-401 | PyInstaller onedir ZIP;拒绝秘密/状态/证据/tests/XML;记录 commit 与 SHA-256 |
|
||||
| T-404 | 完整 MVP 只读验收 | T-401, T-402, T-403, T-405 | 冻结同一候选 commit/产物做 P0、双端门禁和真机记录复核;缺陷退回原任务,不在验收任务修复 |
|
||||
|
||||
### V2 及以后
|
||||
|
||||
@@ -126,8 +128,8 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
|
||||
- **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103)
|
||||
- **M3**:真机设置数量、闸门二、确认页闸门三和唯一提交控件 dry-run 通过,不点击。(T-107)
|
||||
- **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302)
|
||||
- **M5**:单趟从领取到围栏前 dry-run 跑通,事件与证据齐全。(T-305)
|
||||
- **M6**:单趟真实提交只创建待付款订单,围栏/调和/人工收口闭环。(T-401~T-404)
|
||||
- **M5**:围栏前 dry-run 跑通,客户端围栏恢复和离线一次性提交安全闭包成立。(T-305、T-308、T-400)
|
||||
- **M6**:新真实单趟只创建可能的待付款订单,围栏/调和/人工收口、Windows 交付和只读验收闭环。(T-401~T-405)
|
||||
|
||||
**M2 仍是生死线。** M2 前可以并行管理会话、DRAFT 建单和开始采购的服务端授权事务,但不能编写
|
||||
依赖未取证页面字段的生产执行逻辑。提速来自两端并行、窄任务和主审复用门禁,不来自删除安全闸门。
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
| IX-004 | US-002、US-004 | web 列表行 / `/tasks/{id}` | 双击行、浏览器前进后退 | 路由化详情抽屉或完整页,显示执行证据 | P0 | 已定 |
|
||||
| IX-005 | US-005 | web 批量操作条 | 点击开始采购 | 创建一次性授权并进入 `PENDING` | P0 | 已定 |
|
||||
| IX-006 | US-006 | web 待付款详情 | 查看后手工标记 | 只记录人工付款完成,不执行支付 | P0 | 已定 |
|
||||
| IX-007 | US-003 | desk 配置 tab | 保存并检查连接 | 服务、ADB、App 版本就绪 | P0 | 已定 |
|
||||
| IX-007 | US-003 | desk 配置 tab | 保存并校验配置 | 本地字段/路径有效;服务身份与真机能力在真实流程中验证 | P0 | 已定 |
|
||||
| IX-008 | US-003、US-008 | desk 采购执行 tab | 开始 / 停止轮询、选择记录、Esc | 单趟执行和安全人工接管 | P0 | 已定 |
|
||||
| IX-009 | US-008 | web / desk 通用错误反馈 | 请求或流程失败 | 显示阶段、原因和安全下一步 | P0 | 已定 |
|
||||
| IX-010 | US-005 | web 围栏前人工处理 | 重置 / 取消 | 关闭旧授权后回 `DRAFT` 或取消 | P0 | 已定 |
|
||||
@@ -57,7 +57,8 @@
|
||||
|
||||
### IX-006 待付款核对与标记完成
|
||||
|
||||
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和订单截图。
|
||||
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和三闸门内部证据;MVP
|
||||
没有提交后结果截图类型,不把确认页图片冒充订单结果图。
|
||||
- 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。”
|
||||
- “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。
|
||||
- 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。
|
||||
@@ -105,7 +106,8 @@
|
||||
1. 当前任务在同一趟内完成闸门二和闸门三,并确认提交控件精确唯一。
|
||||
2. 客户端显示“正在申请提交围栏”,提交当前验证摘要。
|
||||
3. 只有服务端首次明确返回 `click_permitted=true`,客户端才先持久化唯一点击意图,再精确点击一次。
|
||||
4. 明确创建转待付款;外部支付、安全校验、超时、断连或未知页面转待调和。
|
||||
4. MVP 首次真实点击后因没有已取证的提交后页面判据,无条件上报 `UNKNOWN` 并转待调和;只有管理员
|
||||
对同一 submission 明确调和为已创建,才转待付款。
|
||||
5. 围栏后的 web 详情只显示同一 `submission_id` 的证据、调和记录与人工结论。
|
||||
|
||||
| 状态 | 允许动作 | 禁止动作 |
|
||||
|
||||
+36
-11
@@ -372,23 +372,48 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
|
||||
|
||||
## 三、采购工具本地模块合约
|
||||
|
||||
### `TaskSource` / `ResultSink`
|
||||
### T-303 已实现端口
|
||||
|
||||
```python
|
||||
class TaskSource(Protocol):
|
||||
def claim_next(self, session: Session) -> ClaimedPurchase | None: ...
|
||||
def renew_lease(self, claim: Claim) -> Lease: ...
|
||||
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
|
||||
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
|
||||
|
||||
class ResultSink(Protocol):
|
||||
def append_events(self, claim: Claim, events: list[TaskEvent]) -> None: ...
|
||||
def upload_screenshot(self, claim: Claim, asset: ScreenshotAsset) -> AssetRef: ...
|
||||
def fail_attempt(self, claim: Claim, failure: AttemptFailure) -> None: ...
|
||||
def create_submission_fence(self, claim: Claim, proof: SubmissionProof) -> SubmissionPermit: ...
|
||||
def report_submission_result(self, permit: SubmissionPermit, result: SubmissionResult) -> None: ...
|
||||
class EvidenceSink(Protocol):
|
||||
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
|
||||
```
|
||||
|
||||
执行器不能依赖具体 HTTP 或 Excel 实现。`SubmissionPermit` 只能由 `ResultSink` 的服务端成功响应构造,
|
||||
业务代码不能手工 new 一个许可。
|
||||
T-303 只实现 claim/renew 与 `SKU_PANEL_GATE_1` 单张 PNG;不得用运行时 `NotImplementedError` 伪造
|
||||
events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `DurableClientGateway` 调用:它先把
|
||||
同一个 request/upload key 与完整载荷写入 SQLite,再最多发送一次 HTTP;401 保留 `PENDING`,由用户修复
|
||||
同一 device id 的 Bearer 后显式重放;网络、超时、503、截断、非法/未知 2xx 同样只保留原槽。协议/409
|
||||
终止槽但不换 key。成功响应落库失败时,重启仍用原 key 向服务端恢复事实。
|
||||
|
||||
金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零);wire
|
||||
整数为正 int64,拒绝 bool。claim 成功响应总上限 32 KiB;因此客户端不额外发明 goods/title/SKU/金额
|
||||
的单字段业务上限。RFC3339Nano 按 0--9 位小数的纳秒时间轴比较,不能用 Python 微秒精度截断。
|
||||
|
||||
### 本地恢复合约
|
||||
|
||||
- 数据库固定为 `%LOCALAPPDATA%\cmbuyer\state\client-state.sqlite3`,WAL + `synchronous=FULL`;Windows
|
||||
缺少 `LOCALAPPDATA` 时失败,不回退到 home 创建第二套状态。
|
||||
- device token 与 claim token 的原始 32 字节只以当前用户 DPAPI 密文 BLOB 入库;前者 context 绑定
|
||||
profile+device,后者绑定 profile+attempt,跨行交换密文会解密失败。claim token 不可更换;有
|
||||
pending/open 状态时冻结 service/device/ADB/transport/轮询与超时配置,仅允许同 device id 修复 Bearer;
|
||||
idle 时切换 device id 也必须同时提供新 token。
|
||||
- `Global\cmbuyer-<db-path-hash>` named mutex 在任何 SQLite/DPAPI 打开前取得;同一状态库跨 Windows
|
||||
session 只允许一个采购工具进程。
|
||||
- 运行根目录与数据库路径在构造时固化为绝对路径;之后 cwd 改变不得打开第二套库或绕过原 mutex。
|
||||
- polling session、claim history、renew request 和 evidence marker/slot 都是 append-only 历史;当前行用
|
||||
`closed_at IS NULL` partial unique 表示。恢复或发送前在同一 SQLite 读快照校验整张状态图,冗余列、
|
||||
snapshot、request、claim token、marker、slot、receipt 任一不一致都零 HTTP 失败闭合。
|
||||
- evidence 槽唯一键是 `(attempt_id, kind)`。首次发送前固定显式路径的 regular/non-reparse 文件 identity、
|
||||
size、mtime、SHA-256、IHDR 尺寸与字节;pending 时变化即停,并始终用首次保存的 exact metadata 重放
|
||||
相同 multipart。成功后 receipt 成为事实,源文件变化或删除只返回原 receipt,不再次上传;receipt
|
||||
尺寸必须与本地 IHDR 一致。客户端做签名/IHDR/尺寸/hash 防御;采购服务仍负责完整 PNG 解码权威校验。
|
||||
|
||||
完整 `ResultSink` 只有在 T-205/T-208 服务端契约完成后才由后续任务组合;`SubmissionPermit` 只能由
|
||||
服务端首次明确成功响应构造,业务代码不能手工创建。
|
||||
|
||||
### 真机能力分层
|
||||
|
||||
|
||||
+10
-4
@@ -24,9 +24,11 @@
|
||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
||||
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
|
||||
绑定 PKG110 / Android 16 / 拼多多 8.17.0 的证据工具,以及受控规格入口、精确规格选择、SKU 当前价
|
||||
读取、原始截图和一次安全退出能力;该能力尚待最终人工真机验收,数量/确认页/提交仍未开放。
|
||||
读取、原始截图和一次安全退出能力;并已具备严格 localhost claim/renew/evidence HTTP、Windows DPAPI
|
||||
凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。真机能力尚待最终人工
|
||||
验收,数量/确认页/提交仍未开放。
|
||||
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
|
||||
采购工具 112 项离线单元测试(全部 mock,不连接真机)。
|
||||
采购工具 189 项离线单元测试(全部 mock,不连接真机)。
|
||||
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
|
||||
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。仓库不含业务实例数据。
|
||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||
@@ -51,11 +53,11 @@
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-210、T-301~T-307) | T-302 已完成;T-103 等最终真机验收,后续三闸门与双端组合任务已按依赖落成 |
|
||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-210、T-301~T-308、T-400~T-405) | T-302 已完成;T-103 等最终真机验收;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
|
||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
|
||||
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、真机取证及受控规格选择/读价/安全退出;数量、确认页和提交未开放 |
|
||||
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/安全退出;数量、确认页和提交未开放 |
|
||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||
|
||||
## 任务状态
|
||||
@@ -75,6 +77,10 @@
|
||||
稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。
|
||||
- T-103 离线 `SkuSelectionFlow` 已实现并通过独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107
|
||||
按数量/确认页风险逐段开放。服务端后续顺序已收紧为 T-210 → T-205 → T-206 → T-207 → T-208。
|
||||
- T-303 已完成并通过三重审查、189 项客户端测试和合并后根门禁;客户端下一步按依赖推进
|
||||
T-304/T-306/T-307/T-308,再与 T-305 汇合到
|
||||
T-400 离线一次性提交安全编排。围栏后管理链为 T-403 → T-402;T-401 必须在新的真实单趟重新
|
||||
通过三闸门,首次点击后固定进入 `UNKNOWN`/人工调和,T-405 打包后由 T-404 只读验收。
|
||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||
可读字段时必须先回修原型与交互清单。
|
||||
|
||||
|
||||
+6
-3
@@ -76,7 +76,8 @@
|
||||
2. 开始采购授权:授权 id、授权人、锁定任务版本、创建/有效期、当前状态。
|
||||
3. 设备执行:attempt、设备、App 版本、步骤时间线和失败 code。
|
||||
4. 三道闸门:两次规格面板单价、规格/数量读回、确认页总额与判定。
|
||||
5. 内部截图:规格面板、确认页和结果页;只经受保护端点读取。
|
||||
5. 内部截图:三道闸门已批准的规格面板/确认页证据;只经受保护端点读取。MVP 不存在提交后结果
|
||||
截图类型,不展示外部支付页,也不把 Gate3 图片冒充结果图。
|
||||
6. 提交围栏:submission id、是否首次明确许可、唯一点击和调和记录。
|
||||
7. 待付款收口:明确写系统尚未付款;人工核对后记录完成。
|
||||
|
||||
@@ -122,10 +123,12 @@ T-204 只显示数据库中当前实际存在的任务、授权、attempt、subm
|
||||
|
||||
### 配置 tab
|
||||
|
||||
- 采购服务 URL、设备 token(密码框,不回显完整值);
|
||||
- 采购服务 URL、设备 token(密码框,不回显完整值;首次必填,已有凭据时留空表示保留,非空表示替换);
|
||||
- ADB 路径、设备 serial、USB/WiFi 通道选择;
|
||||
- 轮询间隔与连续失败停止阈值;
|
||||
- 连接检查:服务、设备身份、拼多多安装和已取证版本。
|
||||
- 只做本地字段与路径格式检查,并提示服务身份、设备连接、拼多多安装和已取证版本将在首次真实领取/
|
||||
后续已批准真机流程中验证。配置页不调用 `claim-next`、renew、evidence 等业务 API,也不运行 ADB/PDD
|
||||
作为“连接检查”;MVP 不提供清除已保存设备凭据的命令。
|
||||
|
||||
配置凭据进入系统安全存储;日志和界面不显示完整 token。
|
||||
|
||||
|
||||
+48
-31
@@ -6,7 +6,7 @@ deps: [T-103]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 32
|
||||
context_ref: 9a4d11f
|
||||
context_ref: 3a0a41d
|
||||
work_branch: task/t-104-safe-exit
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
@@ -21,37 +21,41 @@ write_paths:
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=32 synced=2026-08-04T13:46:36Z sha256=1bbf16ce51d223a2f5c6b756424d004d15fe04d99dd9e744c303ecac64fa0b49 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=32 synced=2026-08-04T15:33:56Z sha256=cd649f78ae8d79edcfe94fb3702f8dbf99b12d1d4cbf2c0069b14cffa8703464 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-103 已实现受控规格选择、读价、截图和单次 Back,但当前 `exit_sku_panel_safely()` 在 hierarchy 变化且规格面板判据不再成立时就返回成功。2026-08-04 的只读诊断已证明:通知栏覆盖时 `app_current` 仍可能报告拼多多,而节点树全部属于 SystemUI。因此“已不在面板”不是安全退出的充分条件。T-104 用独立真机证据把后置条件收紧为“稳定回到同一目标商品详情页”。
|
||||
T-103 已实现受控规格选择、闸门一读价、规格面板原始截图和单次 Back,但当前 `exit_sku_panel_safely()` 只要 hierarchy 变化且规格面板判据不再成立就返回成功。现有纯 mock 测试还把 Back 后的空 hierarchy 当成成功并允许 runner 发布 `safe_exit=completed`。2026-08-04 的只读诊断同时证明:通知栏覆盖时 `app_current` 仍可能报告拼多多,而节点树全部属于 SystemUI。因此“面板消失”或“前台包仍是 PDD”都不是安全退出的充分条件;T-104 必须先取得独立 post-exit 真机证据,再把后置条件收紧为稳定回到同一目标商品详情页。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006 单趟流程中的安全退出边界。
|
||||
- 架构/API:复用 `SkuSelectionFlow.exit_sku_panel_safely()`;不改变服务端 API。
|
||||
- 依赖:T-103 完成真机规格选择/读价/人眼安全退出验收后才开工。
|
||||
- 架构/API:复用并收紧 `SkuSelectionFlow.exit_sku_panel_safely()` 与现有 runner;不改变服务端 API,不新建第二套采购流程。
|
||||
- 依赖:T-103 完成真机规格选择、读价、原始截图和人工安全退出验收后才可开工;T-105 与 T-306 消费本任务结果。
|
||||
- `status` 保持 `TODO`,`needs_device` 与 `needs_human_review` 均保持 true;开始后也必须由人完成真机验收。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 先新增窄取证脚本:只在已验证 PDD 8.17.0 目标规格面板上发送一次 Back,随后采集本机截图/XML/App/设备/goods_id 元数据;不得打开确认页、设置数量或点击任何页面控件。
|
||||
2. 由人确认 post-exit 截图确为 goods_id `937122477375` 商品详情、规格面板已关闭、未进入确认页/外部支付页,并确认截图/XML 对应;`needs_device=true`,agent 保持 DOING。
|
||||
3. 从本项目 post-exit XML 提取只含判据节点的最小 fixture。成功判据必须稳定命中该商品详情的证据绑定正结构;可复用 T-103 的“快要抢光”五层入口链,但必须由新的 post-exit 证据再次验证。
|
||||
4. 收紧 `exit_sku_panel_safely()`:版本和前台包正确、节点树仅含 PDD 受控页面、目标商品详情正判据精确唯一并连续稳定;面板仍在、SystemUI/锁屏、PDD 其他页、确认/提交/支付语义、入口缺失/重复或结构漂移均不得记成功。
|
||||
5. Back 最多发送一次。超时或响应不明不重试;故障调和也不能产生第二次 Back。失败不得发布 `safe_exit=completed` manifest。
|
||||
6. 复用 T-103 runner/flow,不新建第二套采购流程;可增加 post-exit 本机原始截图和无页面正文摘要,完整 XML 仍只留本机,不上传、Git 或日志。
|
||||
1. 阶段 A 只新增窄取证脚本。人先把手机停在已验证的拼多多 8.17.0、goods_id `937122477375` 目标规格面板;脚本必须先在内存中核验版本、PDD 前台、已知面板结构、目标颜色/尺码选中态与已取证当前价,任一不符时零动作、零产物。
|
||||
2. 阶段 A 唯一状态改变是一次命名 Android Back。脚本不得发送商品 intent,不得调用任何页面 `click`,不得点击关闭坐标、空白、购买、数量、确认、提交或支付控件;Back 调用前即封存本次机会,超时、响应不明或故障调和均不得重试。
|
||||
3. Back 返回后只读采集 post-exit screenshot/XML/App/设备/goods_id 摘要并原子发布本机证据。此阶段 manifest 只能声明 `human_review_required` 与 `back_attempts=1`,人工确认前绝不能写 `safe_exit=completed`,也不得由 agent 根据“面板消失”自行判定成功。
|
||||
4. 人必须确认 post-exit 截图确为同一 goods_id `937122477375` 商品详情、规格面板已关闭、未进入确认/提交/支付或外部支付页、未创建订单,且截图/XML 对应。只有完成该确认后,阶段 B 才能从本项目 post-exit XML 提取不含个人信息或支付凭据的最小 `product_exit_8_17_0.xml` fixture。
|
||||
5. T-103 的旧 `product_entry_8_17_0.xml` 只有通用“快要抢光”五层入口链和“免拼购买”,不能单独证明是同一商品。它最多作为新证据中的候选结构,必须由本轮 post-exit 证据重新验证,并与新证据里的同商品唯一正锚共同成立;不得把 Activity、旧 XML、前序项目、OCR、裸坐标、相似文本或其他购买文案当作同商品证明。
|
||||
6. 如果新的真实 XML 没有可唯一绑定同一目标商品的正锚,任务开始后保持 `DOING` 并记录等待新证据,不编造 selector、不把通用入口链升级为同商品判据,也不放宽为“任意 PDD 商品页”。
|
||||
7. 阶段 B 只收紧现有 Flow/runner:版本和前台包正确、节点树属于证据绑定的 PDD 受控页面、同一商品详情正判据精确唯一,并连续两次轮询命中后才算退出成功。两次之间发生漂移必须重新计数,超时仍失败。
|
||||
8. 空 hierarchy、SystemUI、锁屏、非 PDD overlay、PDD 其他页、面板仍在、正锚缺失/重复/属性漂移、版本或前台漂移、确认/提交/支付危险语义均不得成功,且不得发布 completed manifest。危险词只能作为保守硬拒绝;没有 T-106/T-107 真机证据时不得把合成反例宣称为真实确认页或支付页分类器。
|
||||
9. 所有正常、失败、超时和 finally 调和路径的 Back 总数都必须小于等于 1;Back 结果不明不重试。只有规格选择、闸门一读价、规格面板截图和严格安全退出全部成功,runner 才能原子发布结果与 `safe_exit=completed`。
|
||||
10. `SkuSelectionRunResult` 必须显式返回规格面板原始 `screenshot_path` 和 UTC-aware `captured_at`。`captured_at` 在截图 RPC 成功附近获取,并把同一个值写入 manifest,禁止在退出后重新猜时间。若本机另存 post-exit 截图,必须使用独立文件名和独立角色;它没有 `SKU_PANEL_GATE_1` 资格,不能成为 `result.screenshot_path`,也不能被 T-306 上传。
|
||||
11. 原始完整 post-exit XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入 Vikunja 或普通日志;runner/CLI 不输出页面正文、设备 serial 或敏感路径。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 真机证据记录路径、截图/XML hash、PKG110、Android 16、Wi-Fi/USB、拼多多 8.17.0 和 goods_id;只有人能完成页面对应性与无订单创建确认。
|
||||
- 离线测试覆盖成功稳定详情页,以及面板未退、SystemUI/锁屏、PDD 其他页、确认/提交/支付页、入口缺失/重复/漂移、版本/前台漂移。
|
||||
- 所有失败分支 Back 总数最多 1;超时不重试;失败不发布 completed manifest。
|
||||
- 静态证明没有数量、确认页导航、提交订单、围栏或支付能力。
|
||||
- client 全量单测、compileall、完整 init、上下文校验与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
等待 T-103 完成后认领。
|
||||
- 阶段 A 证据记录 screenshot/XML 路径与 SHA-256、PKG110、Android 16、Wi-Fi/USB、拼多多 8.17.0、goods_id 和 `back_attempts=1`;人工确认前没有 `safe_exit=completed`。
|
||||
- 用新的 `product_exit_8_17_0.xml` 正例替换旧“空 hierarchy 即成功”的 fake;空 hierarchy 必须失败。成功需连续两次正判据,单次命中后漂移必须重置。
|
||||
- 离线覆盖面板未退、SystemUI/锁屏、非 PDD overlay、PDD 其他页、正锚缺失/重复/漂移、版本/前台漂移和保守危险语义反例;所有分支 Back 调用总数小于等于 1,失败无 target、manifest 或 staging 残留。
|
||||
- runner 成功结果的 `screenshot_path` 精确指向规格面板原始 PNG,`captured_at` 是 UTC-aware 且与 manifest 同值;post-exit 图不能冒充 Gate1。
|
||||
- 静态 API、AST 和导入检查证明取证脚本零 intent/页面 click,T-104 不可达数量、确认页导航、授权、提交围栏、`submit_order_once()` 或任何支付能力。
|
||||
- 只有人能确认真实最终页面为同一商品详情、未进入确认/提交/支付页且未创建订单;agent 完成离线实现后仍保持 `DOING`,不得自行标 `DONE`。
|
||||
- client focused/full unittest、compileall、完整 init、上下文校验、Vikunja 导出检查与 diff-check 全部通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
@@ -64,21 +68,34 @@ T-103 已实现受控规格选择、读价、截图和单次 Back,但当前 `e
|
||||
|
||||
- T-104 必须等待 T-103 完成人工真机验收后才可转 `DOING`。本任务先取证、后写退出成功判据;不得从
|
||||
当前实现、前序项目、旧 XML、Activity 名或推理直接声明商品详情页。
|
||||
- 取证动作只允许在已证明为拼多多 8.17.0、goods_id `937122477375` 的目标规格面板上发送一次
|
||||
Android Back,随后只读采集截图/XML/App/设备摘要。不得点击关闭坐标、空白处、购买、数量、确认、
|
||||
提交或支付控件,也不得在 Back 失败或结果不明时重试。
|
||||
- “规格面板已消失”不是退出成功。成功必须由本项目 post-exit 真机证据证明:版本和前台包正确、节点树
|
||||
仅属于 PDD 受控页面、同一目标商品详情正判据精确唯一并连续稳定;SystemUI/锁屏、PDD 其他页面、
|
||||
确认页、提交页、外部支付页、入口缺失/重复或结构漂移一律停止且不得发布 completed manifest。
|
||||
- 可以复用 T-103 已取证的“快要抢光”五层入口链作为候选结构,但必须在新的 post-exit 截图/XML 上
|
||||
重新验证并由人确认页面对应性;不得使用相似文本、包含/前缀、OCR、裸坐标或其他购买文案兜底。
|
||||
- 阶段 A 取证脚本必须先在内存中核验拼多多 8.17.0、goods_id `937122477375` 的已知目标规格面板、
|
||||
目标颜色/尺码选中态和已取证当前价。唯一状态改变是一次命名 Android Back;不得发送 intent,不得调用
|
||||
页面 `click`,不得点击关闭坐标、空白、购买、数量、确认、提交或支付控件。Back 调用前即封存机会,
|
||||
超时、响应不明和 finally 调和均不得重试。
|
||||
- 阶段 A 在 Back 后只能只读采集 post-exit screenshot/XML/App/设备摘要并原子发布本机证据;manifest
|
||||
只能声明 `human_review_required` 与 `back_attempts=1`。人工确认前绝不能写 `safe_exit=completed`,
|
||||
agent 也不得把“面板消失”或“前台包仍是 PDD”自行解释为安全退出。
|
||||
- 人确认 post-exit 截图确为同一目标商品详情、面板已关闭、未进入确认/提交/支付或外部支付页、未创建
|
||||
订单,且截图/XML 对应后,才允许从本项目 XML 提取最小 `product_exit_8_17_0.xml` fixture。
|
||||
- 旧 `product_entry_8_17_0.xml` 只有通用“快要抢光”五层入口链和“免拼购买”,不能单独证明同一
|
||||
商品;它最多是新证据中的候选结构,必须与新证据的同商品唯一正锚共同成立。若真实 XML 没有唯一
|
||||
正锚,任务开始后保持 `DOING` 等待新证据,不臆造 selector、不放宽为任意 PDD 商品页。
|
||||
- “规格面板已消失”不是退出成功。阶段 B 成功必须由本项目 post-exit 证据证明:版本和前台包正确、
|
||||
节点树属于证据绑定的 PDD 受控页面、同一目标商品详情正判据精确唯一,并连续两次轮询命中;中间漂移
|
||||
必须重新计数。空 hierarchy、SystemUI/锁屏、非 PDD overlay、PDD 其他页、面板仍在、正锚缺失/
|
||||
重复/属性漂移、版本或前台漂移一律失败且不得发布 completed manifest。
|
||||
- 确认/提交/支付文字只能作为保守硬拒绝。T-106/T-107 真机取证前,不得把合成反例宣称为真实确认页、
|
||||
提交页或支付页分类器;未知页面只能 fail closed,不能加同义、包含、OCR、裸坐标或 Activity 兜底。
|
||||
- 只收紧现有 `SkuSelectionFlow` 与 runner 的退出后置条件,不新建第二套采购流程,不新增通用 `click`、
|
||||
数量、确认页导航、授权、提交围栏、创建待付款订单或支付能力。第一趟与任何真实提交函数继续静态隔离。
|
||||
- 原始完整 XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入
|
||||
Vikunja 或日志。Git 中最小 fixture 只能保留退出判据所需结构,并由人确认不含地址、手机号或支付凭据。
|
||||
- 供 T-306 消费的成功结果必须显式携带规格面板原始 `screenshot_path` 与实际截图时刻 `captured_at`
|
||||
(UTC aware);只有规格选择、闸门一读价、截图和严格安全退出全部成功后才可返回该结果。不得让
|
||||
T-306 枚举目录或读取 manifest 猜文件/时间;post-exit 商品详情截图也不得冒充规格面板证据。
|
||||
- 所有正常、失败、超时和 finally 调和路径的 Back 总数都必须小于等于 1;Back 结果不明不重试。
|
||||
只有规格选择、闸门一读价、规格面板截图和严格安全退出全部成功,runner 才能原子发布结果与
|
||||
`safe_exit=completed`;旧的空 hierarchy success 测试必须改成失败反例。
|
||||
- 供 T-306 消费的 `SkuSelectionRunResult` 必须显式携带规格面板原始 `screenshot_path` 与截图 RPC
|
||||
成功附近取得的 UTC-aware `captured_at`,并把同一个时间值写入 manifest。不得枚举目录或读取
|
||||
manifest 猜文件/时间;post-exit 商品详情截图必须使用独立文件名和角色,绝不能冒充 Gate1 证据。
|
||||
- `needs_device: true`:agent 不得自行标 `DONE`。只有人确认 post-exit 截图为同一目标商品详情、面板
|
||||
已关闭、未进入确认/提交/支付页、未创建订单,且截图/XML 对应后,任务才可完成。
|
||||
- 本任务不实现、不调用 `set_quantity_and_readback()`、`go_to_order_confirm()`、
|
||||
|
||||
+34
-3
@@ -3,10 +3,10 @@ id: T-303
|
||||
title: 客户端 HTTP 任务源、证据 sink 与可恢复本地状态
|
||||
phase: 3
|
||||
deps: [T-002, T-204, T-302]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 36
|
||||
context_ref: bd4c855
|
||||
context_ref: 3a0a41d
|
||||
work_branch: task/t-303-http-localstate
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
@@ -26,9 +26,10 @@ write_paths:
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/06-tasks.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=36 synced=2026-08-04T13:41:52Z sha256=8de92c2f530442762dddf5c3a7b73115fb9c293bb3838ff01485feee0edade48 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=36 synced=2026-08-04T17:04:20Z sha256=b63f054754fcbc56b123abaea81915eaaf10ca486dd790730c68d8e06fbbd8c4 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-302 完成后,采购工具需要真实、安全、可恢复地领取/续租任务;T-204 已提供受控截图上传接口。但 client 当前只有真机能力和最小窗口,没有 HTTP 抽象、设备凭据存储或断网/重启幂等状态。T-303 建立 `HttpTaskSource`、窄 `HttpEvidenceSink` 与 Windows 本地恢复底座;事件/fail/fence/result 尚分别依赖 T-205/T-208,本任务不伪造完整 `HttpResultSink` 或占位请求。
|
||||
@@ -74,6 +75,36 @@ T-302 完成后,采购工具需要真实、安全、可恢复地领取/续租
|
||||
### 2026-08-04T13:41:49Z · ila
|
||||
|
||||
2026-08-04 T-306 预研反向收紧 T-303:localstate 必须按 (attempt_id, evidence_kind) 暴露唯一原子上传槽。首次 HTTP 前保存 upload_key、完整元数据、文件 identity/hash;结果不明只能恢复同一槽;成功后持久保留 AssetRef,重复调用返回原结果,不能清 pending 后生成新 key 或上传第二张。T-306 不得另建 SQLite。
|
||||
|
||||
### 2026-08-04T15:25:33Z · ila
|
||||
|
||||
2026-08-04 开始 T-303:基于 main@3a0a41d,在独立 worktree 实现客户端 HTTP 任务源、证据 sink 与可恢复本地状态;严格限制为 localhost 服务端契约,不触碰真机、页面操作、提交订单或付款能力。
|
||||
|
||||
### 2026-08-04T16:54:36Z · ila
|
||||
|
||||
2026-08-04 T-303 实现完成并进入审阅冻结:已落地严格 localhost HttpTaskSource/HttpEvidenceSink、durable gateway、DPAPI 身份绑定密文、SQLite append-only 状态图与 Global named mutex。claim/renew/evidence 均先持久化后最多一次 HTTP;business snapshot、renew response、evidence receipt 使用不可变约束与摘要,renew 从初始租约锚重放;证据绑定 PNG IHDR 尺寸并精确重放首次 metadata。独立对抗审计已通过。验证:client unittest 189/189,compileall、diff-check、agent-context 通过;根 init.ps1 通过 admin test/vet/build、client install/test/compile 与上下文门禁。任务保持 DOING,等待大脑固定提交复审,不标 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T13:27:53Z · ila
|
||||
|
||||
2026-08-04 T-304 预研反向约束 T-303:localstate 必须提供唯一的原子 API 给 UI 使用,至少覆盖 profile 配置读写、同 profile 单实例 guard、recovery snapshot、polling session start/resume/stop、pending claim/active claim 查询与原 session_id+claim_request_id 恢复;T-304 不得另写 SQLite/DPAPI/mutex。profile 需承载 service_url/device_id+加密 token、adb_path、serial、transport、poll interval、failure threshold、request/step timeout。当前服务端没有 heartbeat,配置页不能拿 claim-next 做无副作用连接探测,只能显示“凭据已安全保存,将在领取时验证”。
|
||||
|
||||
### 2026-08-04T13:41:49Z · ila
|
||||
|
||||
2026-08-04 T-306 预研反向收紧 T-303:localstate 必须按 (attempt_id, evidence_kind) 暴露唯一原子上传槽。首次 HTTP 前保存 upload_key、完整元数据、文件 identity/hash;结果不明只能恢复同一槽;成功后持久保留 AssetRef,重复调用返回原结果,不能清 pending 后生成新 key 或上传第二张。T-306 不得另建 SQLite。
|
||||
|
||||
### 2026-08-04T15:25:33Z · ila
|
||||
|
||||
2026-08-04 开始 T-303:基于 main@3a0a41d,在独立 worktree 实现客户端 HTTP 任务源、证据 sink 与可恢复本地状态;严格限制为 localhost 服务端契约,不触碰真机、页面操作、提交订单或付款能力。
|
||||
|
||||
### 2026-08-04T16:54:36Z · ila
|
||||
|
||||
2026-08-04 T-303 实现完成并进入审阅冻结:已落地严格 localhost HttpTaskSource/HttpEvidenceSink、durable gateway、DPAPI 身份绑定密文、SQLite append-only 状态图与 Global named mutex。claim/renew/evidence 均先持久化后最多一次 HTTP;business snapshot、renew response、evidence receipt 使用不可变约束与摘要,renew 从初始租约锚重放;证据绑定 PNG IHDR 尺寸并精确重放首次 metadata。独立对抗审计已通过。验证:client unittest 189/189,compileall、diff-check、agent-context 通过;根 init.ps1 通过 admin test/vet/build、client install/test/compile 与上下文门禁。任务保持 DOING,等待大脑固定提交复审,不标 DONE。
|
||||
|
||||
### 2026-08-04T17:01:28Z · ila
|
||||
|
||||
2026-08-05 主脑终审与合并完成:T-303 固定实现提交 b5bb45c 已通过第三方终验,无新阻断;主脑已合入 main@31f07ac,并在 main 上重跑 init.ps1 全绿。现按验收结果将线上任务标记完成并移动到 Done;title、description 与 labels 保持不变。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
+19
-10
@@ -22,7 +22,7 @@ write_paths:
|
||||
- docs/06-tasks.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=37 synced=2026-08-04T13:36:35Z sha256=43abf519936af610d20823dfaf4e55a98529fc0cbb21a8cc3e90f00eb3daa713 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=37 synced=2026-08-04T15:18:59Z sha256=70cde8bfacd40b2c2a7132dc0f0390689682403c7934587efb25348b40796826 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-006 已确认采购工具双 Tab 原型,T-303 将提供唯一的 HTTP、本地恢复、DPAPI 与单实例底座。T-304 把原型转换为可测试的原生 PySide6 主界面和定时轮询协调器,但在真实单趟执行器接入前不得领取真实任务,避免形成无人消费的 active claim。
|
||||
@@ -41,17 +41,20 @@ T-006 已确认采购工具双 Tab 原型,T-303 将提供唯一的 HTTP、本
|
||||
2. 采购执行页顶部显示连接、轮询、当前任务与连续失败状态,开始/停止复用同一 `QAction`。独立运行且未注入 execution consumer 时开始按钮禁用,并显示“单趟执行能力尚未接入,不能领取真实任务”。
|
||||
3. 主体使用 `QSplitter`。左侧 `QStackedWidget` 在“当前任务”和“采购记录详情”间切换;当前任务左侧文字、右侧图片约 2:1,下方为滚动日志。右侧使用 `QTableView + QAbstractTableModel` 显示按时间倒序的标题和状态,不放假记录。
|
||||
4. 记录表单击只选择;双击非控件区域、Enter、显式“查看所选记录”和上下文命令复用同一 QAction。详情态选择另一行立即更新;Esc 仅从详情返回当前任务,不停止轮询、不关闭程序、不改变服务端状态,并恢复记录选择、滚动和焦点。
|
||||
5. 配置页使用 `QScrollArea + QFormLayout` 显式保存。service URL 为只读精确 `http://127.0.0.1:8080`;设备 token 使用密码框,保存后立即清空、不回填。没有 heartbeat 时不得调用 claim-next 测试连接,只显示凭据将在首次真实领取时验证。
|
||||
6. 配置承载 device id、ADB 路径、serial/transport、轮询间隔默认 15 秒且范围 5–300、连续失败阈值默认 3 且范围 1–10、HTTP 超时默认 10 秒且范围 1–120、真机步骤超时默认 45 秒且范围 5–300。pending/active claim 时冻结 service/device 身份。
|
||||
7. 轮询协调器状态固定为 STOPPED、STARTING、BLOCKED、RECOVERING、WAITING、CLAIMING、ACTIVE、RECOVERY_REQUIRED。启动必须先取得 T-303 单实例 guard 并加载 recovery snapshot;重启不自动开始,active claim 只显示待安全恢复。
|
||||
8. 停止只设置 `accept_new_claims=false`。WAITING 取消下一计时器;CLAIMING 等待有界结果并先落库;返回 claim 后进入 RECOVERY_REQUIRED。不得中断已发请求、清 pending/active、release/abandon、生成新 key/session 或调用真机返回动作。
|
||||
9. `QTimer` 只调度下一轮;阻塞 I/O 放长期 worker QObject/QThread,worker 不接触 Widget。claim 业务结果必须先交 T-303 持久化,不能因 UI generation 过期而丢弃;迟到的纯视图结果按稳定 request id 丢弃。
|
||||
10. 连续失败只统计 T-303 标记为可用同幂等键安全重放的网络、超时、5xx 或截断;同 pending request 按轮询间隔重放,达到阈值立即停止。401、协议错误、409、DPAPI/SQLite 错误立即转持久 Banner,不进入普通重试。
|
||||
5. 配置页使用 `QScrollArea + QFormLayout` 显式保存。service URL 为只读精确 `http://127.0.0.1:8080`;token 使用密码框且永不回填,只显示“已保存/未保存”布尔状态。首次保存且 `has_stored_device_token=false` 时 token 必填;已有凭据时,规范化后的空输入表示保留现有 DPAPI 密文,非空输入才通过 T-303 原子 profile API 替换。成功后清空输入框;失败保留全部输入并聚焦首个错误。MVP 不提供清除/删除凭据命令,空值绝不能覆盖或删除既有凭据。
|
||||
6. 配置承载 device id、ADB 路径、serial/transport、轮询间隔默认 15 秒且范围 5–300、连续失败阈值默认 3 且范围 1–10、HTTP 超时默认 10 秒且范围 1–120、真机步骤超时默认 45 秒且范围 5–300。只执行必填、格式、范围以及本地路径形态/存在性校验,不执行路径所指程序;pending/active claim 时冻结 service/device 身份。
|
||||
7. 配置页不提供“检查连接/测试连接”按钮、QAction、worker 或隐藏探测。保存和本地校验不得发送 HTTP health、`claim-next`、`renew`、`evidence`,也不得调用 ADB、uiautomator2 或拼多多。稳定内联提示为“服务身份将在首次真实领取时验证;设备与 App 状态由后续已取证执行能力验证”。本地校验通过不等于服务、设备或 App 已就绪。
|
||||
8. 轮询协调器状态固定为 STOPPED、STARTING、BLOCKED、RECOVERING、WAITING、CLAIMING、ACTIVE、RECOVERY_REQUIRED。启动必须先取得 T-303 单实例 guard 并加载 recovery snapshot;重启不自动开始,active claim 只显示待安全恢复。
|
||||
9. 停止只设置 `accept_new_claims=false`。WAITING 取消下一计时器;CLAIMING 等待有界结果并先落库;返回 claim 后进入 RECOVERY_REQUIRED。不得中断已发请求、清 pending/active、release/abandon、生成新 key/session 或调用真机返回动作。
|
||||
10. `QTimer` 只调度下一轮;阻塞 I/O 放长期 worker QObject/QThread,worker 不接触 Widget。claim 业务结果必须先交 T-303 持久化,不能因 UI generation 过期而丢弃;迟到的纯视图结果按稳定 request id 丢弃。
|
||||
11. 连续失败只统计 T-303 标记为可用同幂等键安全重放的网络、超时、5xx 或截断;同 pending request 按轮询间隔重放,达到阈值立即停止。401、协议错误、409、DPAPI/SQLite 错误立即转持久 Banner,不进入普通重试。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 纯状态机覆盖开始、EMPTY、等待、停止、请求中停止、claim 落库、无重叠请求、同键恢复、失败阈值、401/403/409/本地错误和 active 阻止新领取。
|
||||
- QtTest 覆盖默认 Tab、固定 Tab 状态、按钮禁用原因、配置验证/Ctrl+S/token 不回显、记录倒序、单击/双击/Enter/按钮、详情切换、Esc 层级与焦点恢复。
|
||||
- QtTest 覆盖默认 Tab、固定 Tab 状态、按钮禁用原因、记录倒序、单击/双击/Enter/按钮、详情切换、Esc 层级与焦点恢复。
|
||||
- 配置测试覆盖:首次无凭据时空 token 拒绝且零 T-303 写入;已有凭据时空 token 原样保留既有 DPAPI 密文;非空 token 只替换一次且成功后清空输入;失败保留输入并聚焦首个错误;界面无 token 回读和清除/删除命令,日志与异常均无 token。
|
||||
- 配置页不存在连接检查命令;保存、本地校验以及 consumer 未注入时点击禁用的开始入口均产生零 HTTP health、零 `claim-next`/`renew`/`evidence`、零 ADB/uiautomator2/PDD,并显示延后验证提示。静态检查不得出现网络或 ADB 探测调用链。
|
||||
- compact/medium/wide 不重建模型或丢选择;长文案、100%–200% DPI、浅色/深色/高对比与纯键盘行为可人工复核。
|
||||
- 应用独立启动、切换 Tab、Esc、查看记录与关闭窗口均产生零 claim、零 release、零 ADB;代码不导入 PDD 点击、数量、确认页、围栏、提交或付款能力。
|
||||
- client 全量 unittest、compileall、wheel metadata、根目录完整 init、Vikunja 导出、上下文校验与 diff-check 通过。
|
||||
@@ -76,8 +79,14 @@ T-006 已确认采购工具双 Tab 原型,T-303 将提供唯一的 HTTP、本
|
||||
必须先由 T-303 原子落库再更新 UI。
|
||||
- 重启不自动开始轮询,不自动恢复任何真机点击。pending claim 只能用原 session/request 恢复;active
|
||||
claim 只显示“待安全恢复”并阻止新领取,租约过期或 409 交 T-207 人工处理。
|
||||
- 配置页不得使用 `claim-next` 充当“测试连接”,因为它会产生 EMPTY 幂等事实甚至领取任务。服务地址
|
||||
只读为精确 `http://127.0.0.1:8080`;token 保存后立即清空输入框,不回填、不入日志或异常文本。
|
||||
- 配置页只允许必填、格式、范围以及本地路径形态/存在性校验,不得提供或隐藏任何网络/ADB“连接检查”;
|
||||
保存、校验都不得调用 HTTP health、`claim-next`、`renew`、`evidence`,也不得执行 ADB、uiautomator2
|
||||
或拼多多探测。界面必须明确提示“服务身份将在首次真实领取时验证;设备与 App 状态由后续已取证执行
|
||||
能力验证”,本地校验通过不能显示服务、设备或 App 已就绪。
|
||||
- 服务地址只读为精确 `http://127.0.0.1:8080`。首次保存且没有既有 DPAPI 凭据时 token 必填;已有
|
||||
凭据时空输入表示原样保留现有密文,非空输入才通过 T-303 的原子 profile API 替换,绝不能用空值
|
||||
覆盖或删除。保存成功后清空 token 输入框且永不回填;保存失败保留输入并聚焦首个错误。MVP 不提供
|
||||
清除/删除凭据命令,token 不得进入日志或异常文本。
|
||||
- Qt worker 不得直接访问 Widget,UI 回调不得丢弃迟到的 claim 业务结果。连续失败只允许用 T-303
|
||||
已持久化的同一幂等键重放安全网络操作;`retryable` 绝不推导为页面点击、证据替换、下单或提交可重试。
|
||||
- 记录表的双击仅是非破坏性详情入口,并必须有 Enter 和显式按钮等价路径。Esc 只在详情态返回当前任务,
|
||||
|
||||
+43
-26
@@ -6,7 +6,7 @@ deps: [T-104, T-303]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 38
|
||||
context_ref: 41e54e3
|
||||
context_ref: 3a0a41d
|
||||
work_branch: task/t-306-evidence-upload
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
@@ -16,10 +16,12 @@ write_paths:
|
||||
- client/tests/evidence/**
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=38 synced=2026-08-04T13:48:05Z sha256=5bcea505093335085e44e062064d01d95e5c64a70d0d8faf85e1ea10852d7fbb -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=38 synced=2026-08-04T15:31:58Z sha256=9d3d86a2c34aab67a4ada76f3735ee8c79eddb89b1112b697f43b108a629af57 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-104 将在规格选择、闸门一读价、原始截图和严格安全退出全部成功后返回显式 PNG 产物;T-303 提供受设备认证的 HttpEvidenceSink 与可崩溃恢复的唯一上传槽。T-306 只把这张规格面板原始截图可靠上传为内部审计证据,不连接 UI/轮询、不扩展页面能力或服务端证据类型。
|
||||
T-104 将在规格选择、闸门一读价、原始截图和严格安全退出全部成功后返回显式 PNG 路径与实际 UTC 截图时刻;T-303 提供受设备认证的 EvidenceSink 与可崩溃恢复的唯一上传槽。T-306 只把调用方显式给出的这张 Gate1 规格面板原始截图可靠上传为内部审计证据,不连接 UI/轮询、不扩展页面能力或服务端证据类型。
|
||||
|
||||
服务端当前只以设备主体与 upload_key 保证幂等,并没有数据库级 `(attempt_id, kind)` 唯一约束。因此“一个 attempt 只发布一张 Gate1”必须由 T-303 本地唯一槽失败闭合保证;T-306 不能在本地状态丢失或损坏后生成新 key、扫描目录或寻找替代资产。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
@@ -27,28 +29,30 @@ T-104 将在规格选择、闸门一读价、原始截图和严格安全退出
|
||||
- 用户故事:US-004、US-005。
|
||||
- 依赖:T-104、T-303;T-103 由 T-104 传递,T-204/T-302 由 T-303 传递。
|
||||
- 后续消费者:T-305 围栏前 dry-run。
|
||||
- 本任务可与 T-304、T-205 并行,不修改 app/polling/ui、remote/localstate 或 admin。
|
||||
- 本任务可与 T-304、T-205 并行,不修改 app/polling/ui、remote/localstate、PDD 或 admin。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 定义窄 EvidencePublisher,只接受 T-104 成功结果中的显式 screenshot_path 与 UTC captured_at,以及当前 T-303 active claim;不得接收目录、manifest 或任意文件选择。
|
||||
2. 上传元数据固定为 kind=SKU_PANEL_GATE_1、privacy_tier=INTERNAL_RAW;task/attempt 只来自 active claim。post-exit 商品详情截图没有获批 kind,不上传。
|
||||
3. 调用 T-303 的 prepare-or-resume evidence 原子槽:按 (attempt_id, kind) 唯一,首次 HTTP 前已保存 upload_key、完整元数据、文件 identity/大小/SHA-256;本任务不得另建 SQLite。
|
||||
4. 每次调用最多发送一次 HTTP。网络错误、超时、503、截断或非法响应只保留同一槽;进程重启后仅重放同一 key、相同字节和元数据,不在内部循环重试。
|
||||
5. 201 首次成功和 200 稳定重放都必须严格核对 AssetRef。成功后槽继续保存 AssetRef;重复调用只返回原结果,不能清 pending 后生成新 key 或上传第二张。
|
||||
6. 原文件缺失或 identity/大小/hash 变化时零 HTTP、保留恢复事实并转人工;不得改用别的截图、重新截图或枚举目录找替代品。
|
||||
7. 首次上传前 active claim 的 task/attempt/device/session/generation 必须一致;pending 永远绑定原 claim。关闭 claim 后只允许原 key 的可能成功重放,不允许新上传槽。
|
||||
8. 停止轮询只阻止下一领取,不取消在途上传、不清 pending、不删除原图、不 release/abandon。强制退出后只恢复 HTTP,不恢复任何真机动作。
|
||||
9. 原始 PNG 可以包含页面已显示的地址和手机号,不遮罩、不裁剪、不 OCR,也不把它们解析为字段、日志或错误;完整 XML、manifest、本机路径、原文件名、外部支付页和支付凭据永不上传。
|
||||
10. multipart 使用固定安全文件名,只发 T-204 已冻结字段,不增加 claim token/session/path,不扩展 SKU_PANEL_GATE_2 或确认页 kind。
|
||||
1. 唯一公开接口固定为 `publish_gate1(active_claim, *, screenshot_path: Path, captured_at: datetime) -> AssetRef`。调用方不能传入 kind、privacy tier、task id 或 attempt id;接口不接受目录、manifest 或任意文件选择结果。
|
||||
2. Publisher 内部固定 `kind=SKU_PANEL_GATE_1`、`privacy_tier=INTERNAL_RAW`。task/attempt/device/session/generation 归属只取 T-303 的完整 active_claim,不拆散、不重建,也不允许调用方参数覆盖。
|
||||
3. `screenshot_path` 与 UTC-aware `captured_at` 只来自 T-104 成功结果。T-104 只有在规格选择、闸门一读价、原始截图和严格安全退出全部成功后才返回;post-exit 商品详情截图不得冒充 Gate1。
|
||||
4. Publisher 只做窄参数适配并对 T-303 EvidenceSink 调用一次,不直接实现或调用 HTTP、SQLite、UUID、DPAPI、localstate,也不导入 PDD、ADB、uiautomator2。每次 publish 调用最多一次 sink;异常或结果不明不得内部循环重试。
|
||||
5. `lstat`、常规文件与 PNG 判定、文件 identity/大小/SHA-256、固定安全 multipart 文件名、首次 HTTP 前持久化以及 `(attempt_id, evidence_kind)` 唯一 prepare-or-resume 槽全部由 T-303 负责。T-306 不另建缓存、数据库、key 或恢复状态。
|
||||
6. 本地上传槽缺失、损坏或无法与完整 active_claim 核对时必须零 HTTP 失败闭合。不得生成新 upload key、枚举目录、读取 manifest、查询相似资产、改用另一张截图或重新截图;结果不明只能经 T-303 以原 key、原字节和原元数据恢复。
|
||||
7. T-303 对 201 首次成功和 200 稳定重放严格解析 AssetRef;Publisher 再核对 AssetRef 的 task/attempt 与 active_claim 一致,kind/tier 与内部固定值一致。fake sink 返回错误 task、attempt、kind 或 tier 时必须拒绝,不能把不一致引用交给后续事件。
|
||||
8. 成功 AssetRef 继续保存在原槽;重复调用只返回同一结果,不能清 pending/success 后生成新 key。claim 后续关闭时,只允许原 key 对既有成功资产的稳定重放,不允许创建新槽。
|
||||
9. 原始 PNG 字节保持不变,可以包含页面已显示的地址和手机号;不遮罩、不裁剪、不重编码、不 OCR,也不把页面内容解析为字段、日志或异常。完整 XML、manifest、本机路径、原文件名、外部支付页和支付凭据永不上传。
|
||||
10. multipart 只使用 T-204 已冻结的字段,不增加 claim token/session/path,不扩展 SKU_PANEL_GATE_2、确认页或其他 kind;不新增页面判据、PDD 动作、事件、围栏、结果或提交能力。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- T-104 成功产物只调用一次 sink;安全退出失败、claim 不一致、无 active claim或文件变化均零 HTTP。
|
||||
- 首次传输前幂等槽已持久化;结果不明和重启后 key/bytes/metadata 原样重放;重复成功返回同一 AssetRef。
|
||||
- 停止/关闭不取消在途请求、不清 pending、不触发 ADB;拒绝目录、XML、manifest、JPEG、post-exit 截图和未批准 kind。
|
||||
- 原始 PNG 字节保持不变,含地址/手机号不触发遮罩;日志/异常不含 token、页面正文、路径或原文件名。
|
||||
- 静态检查没有目录枚举、uiautomator2、PDD 点击、数量、确认页、围栏、提交订单或付款能力。
|
||||
- 静态签名精确为 `publish_gate1(active_claim, *, screenshot_path: Path, captured_at: datetime) -> AssetRef`;kind/tier/task/attempt 不能成为调用方参数,内部只固定 Gate1/InternalRaw。
|
||||
- T-104 成功产物只调用一次 sink;安全退出失败、无 active claim、claim 归属不一致、UTC 时间非法、文件缺失或变化均零 HTTP,且 Publisher 不做内部重试。
|
||||
- 首次传输前 T-303 唯一槽已持久化;结果不明和重启后 key/bytes/metadata 原样重放,重复成功返回同一 AssetRef,关闭 claim 后只能重放既有成功资产。
|
||||
- 本地槽缺失、损坏或不可验证时零 HTTP,证明不会生成新 key、扫描目录、读取 manifest、寻找替代文件或重新截图。
|
||||
- fake sink 分别返回错误 task、attempt、kind、tier 的 AssetRef 时 Publisher 全部拒绝;正确 AssetRef 原样返回。
|
||||
- 用包含地址/手机号的合成有效 PNG 证明传给 sink 的原始字节完全不变;日志和异常不含页面正文、token、本机路径或原文件名。
|
||||
- 拒绝目录、XML、manifest、JPEG、post-exit 截图和未批准 kind;静态检查没有目录枚举、图像变换/OCR、HTTP/SQLite/UUID、uiautomator2、PDD/ADB、数量、确认页、围栏、提交订单或付款能力。
|
||||
- client 全量 unittest、compileall、wheel metadata、完整 init、Vikunja 导出、上下文校验与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
@@ -60,19 +64,32 @@ T-104 将在规格选择、闸门一读价、原始截图和严格安全退出
|
||||
|
||||
## 边界
|
||||
|
||||
- 唯一输入是 T-104 在规格选择、闸门一读价、规格面板原始截图和严格安全退出全部成功后返回的
|
||||
显式 `screenshot_path` 与 UTC `captured_at`。不得枚举目录、读取 manifest、使用文件选择器,
|
||||
也不得把 post-exit 商品详情截图或其他文件冒充规格面板证据。
|
||||
- 唯一发布接口固定为
|
||||
`publish_gate1(active_claim, *, screenshot_path: Path, captured_at: datetime) -> AssetRef`。调用方不得
|
||||
传入 kind、privacy tier、task id 或 attempt id;`kind=SKU_PANEL_GATE_1` 与
|
||||
`privacy_tier=INTERNAL_RAW` 只能由 Publisher 内部固定,task/attempt/device/session/generation 归属
|
||||
只能取自 T-303 的完整 `active_claim`,不得拆散、重建或由其他参数覆盖。
|
||||
- `screenshot_path` 与 UTC-aware `captured_at` 只能来自 T-104 在规格选择、闸门一读价、规格面板原始
|
||||
截图和严格安全退出全部成功后返回的显式结果。不得枚举目录、读取 manifest、使用文件选择器,也不得
|
||||
把 post-exit 商品详情截图或其他文件冒充规格面板证据。
|
||||
- 只发布一个 `kind=SKU_PANEL_GATE_1`、`privacy_tier=INTERNAL_RAW` 的原始 PNG;task/attempt 只来自
|
||||
当前 active claim。不得增加 gate2/确认页 kind,不得修改 admin schema/API,也不得自行向 multipart
|
||||
增加 claim token、session、路径、原文件名或其他字段。
|
||||
- 上传幂等与恢复只使用 T-303 的 `(attempt_id, evidence_kind)` 唯一原子槽。本任务不得另建 SQLite、
|
||||
生成第二套 upload key 或清除成功 AssetRef;结果不明只能以原 key、原字节、原元数据恢复。
|
||||
- Publisher 每次调用只适配一次 T-303 `EvidenceSink`,不得直接实现或调用 HTTP、SQLite、UUID、DPAPI
|
||||
或本地状态细节,也不得导入 PDD/ADB。`lstat`、常规 PNG 判定、文件 identity/大小/SHA-256、首次发送
|
||||
前原子持久化和 `(attempt_id, evidence_kind)` 唯一恢复槽全部由 T-303 负责。
|
||||
- 服务端当前只保证设备主体与 `upload_key` 幂等,不保证数据库级 `(attempt_id, kind)` 唯一。因此本地
|
||||
上传槽缺失、损坏或无法验证时必须在零 HTTP 下失败闭合;绝不能生成新 key、扫描目录、读取 manifest、
|
||||
查询相似资产或重新截图来“恢复”。结果不明只能经 T-303 以原 key、原字节、原元数据恢复。
|
||||
- T-303 返回的 AssetRef 必须再次匹配当前 active claim 的 task/attempt 以及固定 kind/tier;fake sink
|
||||
返回错 task、attempt、kind 或 tier 时 Publisher 必须拒绝,不能把不一致引用交给后续事件。成功
|
||||
AssetRef 继续保存在原槽,重复调用只能返回同一结果,不能清槽后生成第二个 key。
|
||||
- 每次调用最多一次 HTTP,不在内部循环重试。文件缺失或 identity/大小/hash 变化时必须零 HTTP 并
|
||||
转人工;不得重新截图、替换文件或在目录中寻找“相似”截图。
|
||||
- 停止轮询、Esc、关闭窗口和进程退出不取消在途上传、不清 pending、不删除原图、不 release/abandon
|
||||
claim,也不触发任何 ADB/PDD 动作。重启只可恢复同一 HTTP 请求,不自动恢复设备页面操作。
|
||||
- 原始 PNG 可包含页面已显示的地址和手机号,不遮罩、不裁剪、不 OCR;不得把地址或手机号解析为
|
||||
业务字段、日志或异常文本。完整 XML、manifest、本机路径、页面全文、外部支付页和支付凭据不上传。
|
||||
- 原始 PNG 字节必须保持不变,可以包含页面已显示的地址和手机号;不得遮罩、裁剪、重编码、OCR,
|
||||
也不得把地址或手机号解析为业务字段、日志或异常文本。完整 XML、manifest、本机路径、页面全文、
|
||||
外部支付页和支付凭据不上传。
|
||||
- 不导入或实现 uiautomator2、PDD 页面判据、通用 click、规格/数量/确认页动作、事件、失败上报、
|
||||
提交围栏或结果接口;不编写或引用点击“提交订单”的代码,不编写支付或任何扣款能力。
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
id: T-308
|
||||
title: 客户端提交围栏与结果 sink
|
||||
phase: 3
|
||||
deps: [T-208, T-303, T-307]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 49
|
||||
context_ref: 8600c33
|
||||
work_branch: task/t-308-submission-sink
|
||||
needs_device: false
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-308.md
|
||||
- client/src/cmbuyer_client/submissions/**
|
||||
- client/src/cmbuyer_client/core/**
|
||||
- client/src/cmbuyer_client/remote/**
|
||||
- client/src/cmbuyer_client/localstate/**
|
||||
- client/tests/submissions/**
|
||||
- client/tests/core/**
|
||||
- client/tests/remote/**
|
||||
- client/tests/localstate/**
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=49 synced=2026-08-04T14:51:43Z sha256=ac3851bab830634e65029a7a80985946cb3aa5bf80bff1727e3e7e0fc8c30ad5 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-303 只提供 claim/renew/evidence 的严格 HTTP、本地 DPAPI/SQLite 与恢复底座,T-307 只实现围栏前 events/fail,二者都明确禁止伪造 fence/result。T-208 将冻结服务端 CREATE_ONCE、LOOKUP_ONLY、一次性 result 与同一 submission 调和语义。T-308 补齐客户端提交围栏与结果 sink,并把许可、点击意图和结果恢复事实写入 T-303 的同一 localstate;本任务不连接真机、不接 UI、不执行或暴露任何点击。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-007、F-011、F-017。
|
||||
- 用户故事:US-004、US-005、US-008。
|
||||
- 交互:IX-011;只实现后台契约和恢复状态,不实现界面。
|
||||
- 依赖:T-208、T-303、T-307。
|
||||
- 后续消费者:T-400 离线单次提交安全编排。
|
||||
- API:CREATE_ONCE / LOOKUP_ONLY submission fence 与一次性 submission result;严格消费 T-208 最终 wire。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 定义窄 SubmissionSink:create_submission_fence、lookup_submission_fence、prepare_click_intent、report_submission_result。proof/result 值对象只含 T-208 固定 UUID、claim 归属、规范十进制金额、正整数、三闸门 AssetRef、精确 submit 文本/计数和固定 observation;不接收自由页面文本、XML、路径、selector、坐标或动作脚本。
|
||||
2. HTTP 必须复用 T-303 的精确 loopback、禁代理/重定向、有界请求/响应和严格 JSON 解析。每次方法最多一次传输,不在 transport 或 sink 内隐藏重试。
|
||||
3. 只扩展 T-303 的同一 SQLite/localstate,不建立第二个数据库。首次网络前原子保存 fence_key、operation、完整规范 proof、active claim 绑定和预期状态;DPAPI、SQLite 或归属校验失败时零 HTTP。
|
||||
4. CREATE_ONCE 结果不明时只允许以原 fence_key、原 proof 调用 LOOKUP_ONLY。不得换 key、再次 CREATE_ONCE 或根据本地页面猜测围栏是否存在;LOOKUP_ONLY 找到或未找到都不得产生可点击许可。
|
||||
5. 只有当前进程当前 CREATE_ONCE 调用收到首次、明确、完整且严格核对的 click_permitted=true 响应,并已把 submission 与许可事实 durable 落盘后,才由模块私有工厂返回 opaque SubmissionPermit。响应截断、协议漂移、落盘失败、重放、LOOKUP_ONLY 或 click_permitted=false 均不返回 permit。
|
||||
6. SubmissionPermit 不提供公共构造器,不可 pickle/JSON/复制,不写入 SQLite、日志或异常。SQLite 只保存围栏和恢复审计事实;重启后无论数据库记录什么都不能重建可点击 permit,只能进入同一 submission 的调和路径。
|
||||
7. prepare_click_intent 必须消费当前进程唯一 permit,在返回一次性 ArmedSubmission 前原子写入“即将发出唯一点击”。同 permit 二次调用、并发调用、active claim 漂移或已有点击意图均拒绝;重启只看到 reconcile-only 事实,不能重新 arm。
|
||||
8. result_key、固定 observation 与 submission/attempt/冻结归属在首次 result HTTP 前原子保存。结果不明和重启只能重放原 key、原载荷;明确成功后保存服务端终态。同一 submission 不得创建第二个 observation 或把 UNKNOWN 改写成 SUBMITTED。
|
||||
9. 401、403、409、非法 2xx、本地状态错误按 T-303 分类停止;网络、超时、5xx 只表示原幂等 HTTP 可调和,绝不表示 fence、permit、点击或页面动作可重试。
|
||||
10. 构造和 import 闭包不包含 pdd、device、uiautomator2、UI、execution、app、通用 click、SubmitOrderPort、submit_order_once 或任何 payment 能力。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 覆盖首次 CREATE_ONCE、同键重放、LOOKUP_ONLY 找到/未找到、响应截断/漂移、落盘失败、401/403/409/503;只有首次明确且 durable 成功返回 opaque permit。
|
||||
- 覆盖并发围栏、并发 prepare_click_intent、同 permit 二次消费、进程重启和 SQLite 恢复;任一非首次路径均无法获得或重建 permit/ArmedSubmission。
|
||||
- 证明 fence/result 首次网络前已持久化完整键、载荷与 frozen claim 绑定;不明结果只重放原 HTTP 事实,不产生新 key 或第二 observation。
|
||||
- 证明 SQLite/WAL、日志、异常和 DTO 不含明文 token、页面正文、XML、本机路径、selector、坐标或可点击对象。
|
||||
- 静态 import/AST 检查证明 pdd/device/UI/app/execution、真机 click、submit_order_once 和任何付款能力不可达。
|
||||
- client 全量 unittest、compileall、wheel metadata、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||
- needs_human_review:人工审阅 opaque permit 的构造边界、首次许可与重启不可重建证明;确认前不得标 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只实现 T-208 已冻结的 `CREATE_ONCE`、`LOOKUP_ONLY` 与一次性 result 客户端契约,
|
||||
并复用 T-303 同一 transport/localstate。不得另建 HTTP 客户端、SQLite、DPAPI 封装或第二份
|
||||
权威恢复状态;本地与服务端事实冲突时只能停止并调和。
|
||||
- 只有当前进程当前 `CREATE_ONCE` 调用收到首次、明确、完整且已 durable 落盘的
|
||||
`click_permitted=true` 响应,才可由模块私有工厂产生 opaque `SubmissionPermit`。同键重放、
|
||||
`LOOKUP_ONLY`、响应不明、解析/落盘失败、并发输家和服务重启均不得产生许可。
|
||||
- `SubmissionPermit` 与一次性 `ArmedSubmission` 不得公开构造、序列化、复制、写入 SQLite 或从
|
||||
恢复快照重建。SQLite 只能保存围栏、点击意图与调和事实;进程退出后即使尚不能确认是否点击,
|
||||
也只能进入 `RECONCILE_ONLY`,不能重新 arm。
|
||||
- fence/result 的 key、完整规范载荷和 frozen claim 绑定必须先原子持久化再发送。HTTP 结果不明
|
||||
只能重放原 key、原载荷;不得换 key、再次 `CREATE_ONCE`、覆盖 observation 或把 HTTP 可恢复
|
||||
错误解释为页面动作可重试。
|
||||
- 本任务不得导入或修改 `client/src/cmbuyer_client/pdd/**`、`device/**`、`ui/**`、`execution/**`
|
||||
或 `app.py`,不得连接真机、持有 selector/坐标/节点/点击对象,也不得实现 `SubmitOrderPort`、
|
||||
`submit_order_once()` 或任何通用/具体点击。
|
||||
- 本任务不编写支付、免密支付、先用后付或任何扣款能力。外部支付、安全校验与未知结果只能作为
|
||||
T-208 固定 observation 保存并转调和,不能继续交互、释放围栏、重新授权、重新领取或再次提交。
|
||||
- `needs_human_review: true`:自动验证完成后仍保持 `DOING`,等待人审 opaque permit 构造边界、
|
||||
首次许可唯一性和重启不可重建证明;未经明确确认不得标 `DONE`。
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
id: T-400
|
||||
title: 离线单次提交安全编排
|
||||
phase: 4
|
||||
deps: [T-305, T-308]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 50
|
||||
context_ref: 8600c33
|
||||
work_branch: task/t-400-offline-submission-orchestrator
|
||||
needs_device: false
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-400.md
|
||||
- client/src/cmbuyer_client/execution/submission_orchestrator.py
|
||||
- client/tests/execution/test_submission_orchestrator.py
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=50 synced=2026-08-04T15:04:36Z sha256=4f27bf3ca322f467cb742da1b9159cbb976f6939c0cd804021323a24fa4cc735 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-305 的正常 dry-run 会在同一设备会话完成三闸门与最终控件只读观察,随后执行一次安全 Back,并以固定 reason DRY_RUN_COMPLETED_BEFORE_FENCE 终止 attempt。它只证明围栏前代码和判据契约已验收,绝不产生可供真实提交复用的运行时 ReadyForSubmission。T-308 将提供严格 fence/result sink、当前进程唯一 permit 和不可恢复的点击意图能力。T-400 只用测试 fixture 验证纯 SinglePassSubmissionOrchestrator、四条件、一次性调用和崩溃闭包;不读取或复用 T-305 的对象、截图、证据、journal、缓存或旧 proof,不提供生产 ReadyForSubmission 构造入口,也不接入 app/UI/PDD/真机。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006、F-007、F-011、F-017 的离线提交安全闭包。
|
||||
- 用户故事:US-003、US-005、US-008。
|
||||
- 交互:IX-011 的后台顺序;本任务无生产界面。
|
||||
- 依赖:T-305、T-308。T-305 只提供已验收的代码/判据契约,T-308 提供冻结接口;两者都不向本任务提供可提交的运行时事实。
|
||||
- 后续消费者:T-401 在全新真实任务、全新 attempt 的同一趟内重跑全部三闸门并即时构造生产 ReadyForSubmission。
|
||||
- 生产 app 的 import/call graph 在 T-401 前继续不可达本任务编排、ReadyForSubmission 生产构造和 SubmitOrderPort。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 在单一窄 execution 模块实现纯 SinglePassSubmissionOrchestrator、只读 ReadyForSubmission 协议和抽象 SubmitOrderPort;只消费 T-308 已冻结的 SubmissionSink 接口,不修改 submissions/remote/localstate/core/PDD/UI/app。
|
||||
2. T-305 的 PreFenceDryRunOrchestrator、终止结果、action journal、AssetRef、截图、缓存、页面对象、attempt 或 proof 不能作为 ReadyForSubmission 输入。T-305 已 Back 且终止的 attempt 永远不能申请 fence、取得 permit 或进入提交编排。
|
||||
3. 本任务不提供任何生产 ReadyForSubmission 具体类、公共构造器、factory、adapter 或 from_dry_run/from_snapshot/from_evidence/from_localstate 路径。只有 tests 中的 immutable fixture 实现该只读协议;生产包内不存在把真实或旧事实装配成 ReadyForSubmission 的入口。
|
||||
4. 测试 fixture 必须显式给出一个虚构但内部一致的同一 task/authorization/attempt/generation/App 版本事实:未消费授权快照、Gate2/Gate3 通过、精确规格/数量/金额、三个受控 AssetRef 和提交文本/唯一计数。它只用于纯逻辑测试,不是页面证据或可发布业务事实。
|
||||
5. 编排在申请 fence 前重新校验 fixture 表达的四条件:一次性授权/claim 一致且未过期、Gate2 通过、Gate3 通过、submit 文本精确且唯一计数为一。任一缺失、漂移、金额不规范或版本不一致均零 fence、零 SubmitOrderPort 调用。
|
||||
6. 只有测试注入的 T-308 当前进程首次明确 SubmissionPermit 可继续。先调用 prepare_click_intent durable 写入唯一点击意图并取得一次性 ArmedSubmission;未成功 arm、重放许可、LOOKUP_ONLY、并发进入或重启恢复均零 SubmitOrderPort 调用。
|
||||
7. SubmitOrderPort 只暴露 submit_once(ArmedSubmission, VerifiedSubmitObservationBoundary)。编排层最多调用一次,不提供 retry、循环、回退、第二调用点或可重复 submit 函数;本任务只注入记录次数/故障点的 fake port,没有具体真机适配器。
|
||||
8. 调用前已经进入不可逆 reconcile-only 状态。fake port 返回、抛错、超时、进程停止或结果不明都不能回到可提交状态;异常只形成 UNKNOWN 待调和事实,不再次调用 port。
|
||||
9. fake port observation 只允许 SUBMITTED、EXTERNAL_PAYMENT_HANDOFF、SECURITY_CHALLENGE、UNKNOWN。编排通过 T-308 固定接口只上报一次 result;HTTP 不明只恢复原 key/载荷,不重复页面观察或提交动作。本任务不实现或测试任何基于真实页面的 SUBMITTED 推断。
|
||||
10. 停止轮询、Esc、查看历史、关闭窗口和重启不是编排的取消/重试输入;它们不能清 permit、点击意图、submission 或 result pending,也不能释放、重新授权、重新领取或推进下一任务。
|
||||
11. T-401 前生产 composition/import/call graph 必须同时证明:不可达 SinglePassSubmissionOrchestrator/SubmitOrderPort;不存在生产 ReadyForSubmission factory;T-305 dry-run 结果类型不可流入提交编排。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 参数化负例逐一移除授权/首次许可、Gate2、Gate3 或唯一控件,证明 fake SubmitOrderPort 调用数均为 0。
|
||||
- 首次明确 permit + durable 点击意图的纯测试用例证明 fake port 调用数恰好为 1;并发、二次进入、permit 重放和重复 result 均不能增加计数。
|
||||
- fake port 在调用中或返回解析时抛错/超时,结果均为 reconcile-only/UNKNOWN,调用数仍恰好为 1 且无 retry。
|
||||
- 崩溃点覆盖 fence 响应前后、permit 落盘前后、点击意图前后、fake port 调用前后和 result 上报前后;恢复全部零 SubmitOrderPort 调用。
|
||||
- 固定测试至少包含 test_missing_each_submission_condition_never_calls_port、test_first_permit_calls_port_exactly_once、test_port_exception_never_retries、test_restart_after_click_intent_is_reconcile_only、test_t305_runtime_artifacts_are_not_accepted、test_no_production_ready_for_submission_factory、test_production_app_import_closure_cannot_reach_submission。
|
||||
- AST/import/call-graph 证明生产代码不存在 ReadyForSubmission 构造入口,T-305 已终止结果/截图/缓存/proof 到编排无数据流,app/UI/PDD/device/真实 click/payment 不在本任务闭包。
|
||||
- 任务 diff 只能修改窄 execution 实现/测试;T-308 submissions/remote/localstate/core 接口保持冻结且零 diff。
|
||||
- client 全量 unittest、compileall、wheel metadata、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||
- needs_human_review:人工审阅 T-305 零运行时复用、生产 factory 不存在、一次性 fake port 调用和全部崩溃点;确认前不得标 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- T-305 正常 dry-run 已执行一次安全 Back,并以 `DRY_RUN_COMPLETED_BEFORE_FENCE` 终止 attempt。
|
||||
本任务只能复用其已验收的代码/判据契约;不得接收或复用该次/任何 dry-run 的结果对象、attempt、
|
||||
screenshot、AssetRef、action journal、缓存、页面对象、本地快照或旧 proof 来申请围栏或提交。
|
||||
- 本任务只在单一窄 execution 文件实现纯 `SinglePassSubmissionOrchestrator`、只读
|
||||
`ReadyForSubmission` 协议和抽象 `SubmitOrderPort`;仅测试 fixture 可实现/构造该协议。生产代码
|
||||
不得存在具体 `ReadyForSubmission` 类、公共构造器、factory、adapter、反序列化或
|
||||
`from_dry_run` / `from_snapshot` / `from_evidence` / `from_localstate` 路径。
|
||||
- 本任务只消费 T-308 已冻结接口,不得修改 `submissions/**`、`remote/**`、`localstate/**` 或
|
||||
`core/**`。不得修改或导入 `app.py`、`ui/**`、`pdd/**`、`device/**`,不得连接真机、使用
|
||||
uiautomator2 或实现任何具体页面点击适配器。
|
||||
- 测试 fixture 表达的四条件必须属于同一虚构 task/authorization/attempt/generation 和 App 版本:
|
||||
未消费授权与首次明确围栏许可、Gate2、Gate3、精确唯一提交控件。它只验证纯逻辑,不是可复用的
|
||||
页面或业务事实;缺失、漂移、金额/规格/数量冲突均零 fake `SubmitOrderPort` 调用。
|
||||
- 编排必须先通过 T-308 durable 写入唯一点击意图,再把当前进程的一次性 `ArmedSubmission` 交给
|
||||
fake `SubmitOrderPort.submit_once()`。该方法在纯测试生命周期最多调用一次;不得存在 retry、循环、
|
||||
fallback、第二调用点或异常后重新进入路径。
|
||||
- 点击意图落盘后即进入 `RECONCILE_ONLY`。fake port 返回、抛错、超时、停止、关闭或进程崩溃都不得
|
||||
回到可提交状态;结果不明确只能形成 `UNKNOWN` 并调和同一 submission,不能再次调用 port、重新
|
||||
申请许可、释放围栏、重新授权或重新领取。
|
||||
- 本任务不实现真实页面结果判据。fake fixture/port 的 `SUBMITTED` 只用于协议分支测试,不能成为
|
||||
生产页面事实;不得从 cmroubao/cmpdd、旧版本、页面语义或人工经验推断真实 `SUBMITTED`。
|
||||
- T-401 前 production composition/import/call graph 必须同时不可达本任务编排/`SubmitOrderPort`,
|
||||
不存在生产 `ReadyForSubmission` 构造入口,并证明 T-305 终止结果到提交编排无数据流。应用启动、
|
||||
Esc、停止轮询、查看历史和关闭窗口均保持零提交调用。
|
||||
- T-401 才能领取一条全新已授权真实任务,在新的同一趟 attempt 中重新执行全部实时闸门并即时构造
|
||||
生产 `ReadyForSubmission`。本任务不提供人工提交入口,不编写支付、免密支付、先用后付或扣款能力。
|
||||
- `needs_human_review: true`:自动验证后仍保持 `DOING`,等待人审 T-305 零运行时复用、生产 factory
|
||||
不存在、一次性 fake port 调用和全部崩溃点;未经明确确认不得标 `DONE`。
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
id: T-401
|
||||
title: 首次真实提交与生产接线
|
||||
phase: 4
|
||||
deps: [T-400, T-402]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 52
|
||||
context_ref: 8600c33
|
||||
work_branch: task/t-401-first-real-submission
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-401.md
|
||||
- client/src/cmbuyer_client/pdd/order_submission.py
|
||||
- client/src/cmbuyer_client/execution/**
|
||||
- client/src/cmbuyer_client/ui/**
|
||||
- client/src/cmbuyer_client/app.py
|
||||
- client/tests/pdd/test_order_submission.py
|
||||
- client/tests/execution/**
|
||||
- client/tests/ui/**
|
||||
- client/tests/test_app.py
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=52 synced=2026-08-04T15:06:03Z sha256=4ec2adedf5a6ab06ece40e32e6619992fbf8cc071a95977a80a796b6b1cd9d98 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-107 已把订单确认页、闸门三与精确唯一“提交订单”控件限制为证据/App 版本绑定的只读能力,T-308 将只在当前进程首次明确围栏许可后产生不可恢复 permit,T-400 只用测试 fixture 证明四条件与一次性编排。T-305 的真实 dry-run 已安全 Back 并以 DRY_RUN_COMPLETED_BEFORE_FENCE 终止 attempt,只能提供已验收代码/判据契约,任何运行对象、截图、缓存或 proof 都不能提交。T-401 必须领取一条新的已授权真实任务,在全新 attempt 的同一设备会话中从打开商品开始实时重跑 Gate1、数量/Gate2、确认页/Gate3,并在仍停留于当前确认页时即时构造生产 ReadyForSubmission,随后才可申请围栏和精确点击一次。系统只创建可能的待付款订单,绝不付款;首次提交后页面无本项目证据,因此点击后无条件上报 UNKNOWN 并由 T-403 调和。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-005、F-006、F-007、F-011、F-017。
|
||||
- 用户故事:US-003、US-004、US-005、US-008。
|
||||
- 交互:IX-008、IX-011;执行员没有人工“提交订单”按钮。
|
||||
- 依赖:T-400、T-402;T-400 传递 T-305/T-308 的已验收契约但不传递任何运行时 ReadyForSubmission,T-402 传递 T-403 并保证点击后已有安全调和与待付款收口。
|
||||
- 需要真机和人工安全验收;agent 只能实现、离线验证和准备命令,不得自行触发真实提交或标 DONE。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 生产执行只接受本次新领取的 PENDING + ACTIVE 授权任务,由服务端创建新的 active attempt/generation。已有 DRY_RUN_COMPLETED_BEFORE_FENCE、FAILED、FENCED、ABANDONED 或其它结束 attempt,以及 T-305 的 task/attempt id 都必须在任何页面动作和 fence 前拒绝。
|
||||
2. 在一个不中断的全新设备会话中按固定顺序执行:打开本次任务 canonical 商品 → 受控入口/精确选规格 → Gate1 当前 SKU 单价 → 设置并读回数量 → 重读规格与 Gate2 单价 → 进入订单确认页 → Gate3 规格/数量/总额 → 只读确认精确唯一最终控件。每一步都使用当前 attempt 的实时事实和新证据,不能跳步或使用历史值。
|
||||
3. 生产 ReadyForSubmission 构造入口首次且仅在 T-401 composition 内出现:只有上述当前步骤全部成功、当前 claim/authorization/attempt/generation/App 版本仍一致、三个 AssetRef 均属于本次 attempt,并且仍停留在刚验证的确认页时,才即时构造不可持久化的临时对象交 T-400 编排。
|
||||
4. ReadyForSubmission 不写入 SQLite、文件、缓存、日志或恢复快照,不提供 from_dry_run/from_snapshot/from_evidence/from_localstate。构造后页面 Back/导航、claim/lease/授权变化、App 版本/前台包变化、证据或任务版本漂移都立即使其失效并零 fence/零点击;不能重新加载或跨重启恢复。
|
||||
5. 静态和运行边界显式拒绝 T-305 PreFenceDryRunResult、DRY_RUN_COMPLETED_BEFORE_FENCE attempt、dry-run screenshot/AssetRef、action journal、缓存页面对象和旧 proof。T-305 已 Back 的页面不能“返回去继续”,必须领取新任务从商品打开开始。
|
||||
6. 在生产 composition root 接入 T-400 SinglePassSubmissionOrchestrator、T-308 SubmissionSink 与极窄 PDD SubmitOrderPort。UI 只显示自动阶段和安全状态,不提供执行员可点击的提交、重试、释放围栏或继续按钮。
|
||||
7. 具体 SubmitOrderPort 只接受当前进程一次性 ArmedSubmission 和本次 T-107 判据生成的 VerifiedSubmitObservationBoundary。点击前在当前确认页再次执行精确文本、唯一可点击祖先、前台包与 App 版本检查;不得接受任务下发 selector/坐标、通用 click、相似文案、OCR 或旧项目常量。
|
||||
8. T-308 必须已 durable 写入“即将发出唯一点击”且本地状态进入 RECONCILE_ONLY,才允许调用底层真机点击。低层点击语句在生产调用图中恰好一个调用点、执行恰好一次;不能放在循环、重试器、finally、恢复回调或可重复 signal handler 中。
|
||||
9. 四条件任一缺失、页面实时复核漂移、围栏响应不明、许可不是首次、持久化失败、App/设备/claim 状态变化均零点击并停止。停止轮询、Esc、查看历史、关闭窗口、超时、异常和进程重启都不得重构 ReadyForSubmission、重新 arm、重做页面动作或再次点击。
|
||||
10. 点击调用返回、抛错或超时后不再与页面交互。首次真实运行没有提交后页面判据,客户端必须无条件以固定 UNKNOWN 上报同一 submission,任务进入 RECONCILIATION_REQUIRED;即使人看到待付款页,也不得由客户端推断或改报 SUBMITTED。
|
||||
11. T-401 不采集、持久化或上传点击后的截图/XML,不新增 SUBMISSION_RESULT evidence kind,不把 Gate3 图冒充结果图。尤其不得截取或上传外部支付页、支付凭据、安全校验页面;点击后结果只由管理员在 T-403 调和同一 submission。
|
||||
12. UNKNOWN result HTTP 不明时只由 T-308 用原 result_key、原载荷调和;不得重复观察页面、生成新 key、重新提交、释放围栏、重新授权、重新领取或推进下一任务。
|
||||
13. 首次真实运行必须使用项目所有者专门创建并授权的低风险新任务,在 T-305 dry-run 和全部离线门禁通过后,由项目所有者在执行记录中给出针对本次运行的一次性明确批准。额外批准只是首次发布验收,不新增产品内第二授权按钮。
|
||||
14. 真实点击后由人检查手机和采购服务,只通过 T-403 选择 CONFIRMED_CREATED、CONFIRMED_NOT_CREATED_OR_CANNOT_COMPLETE 或 STILL_UNKNOWN。系统不支付、不自动取消外部订单;人负责说明订单是否产生及如何处置。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 测试证明只有新领取、未结束且当前归属一致的 attempt 能开始;T-305/DRY_RUN_COMPLETED_BEFORE_FENCE、FAILED、FENCED、ABANDONED、旧 generation 和跨 task/authorization 均零页面动作、零 fence、零点击。
|
||||
- 动作/事件序列严格证明本次新 attempt 在同一会话执行商品打开→Gate1→数量/Gate2→确认页/Gate3→即时 ReadyForSubmission→fence→点击;不得出现引用 T-305 截图、缓存、AssetRef、journal 或 proof。
|
||||
- ReadyForSubmission factory 仅 T-401 production composition 可达,输入只来自本次内存实时结果;AST/数据流测试证明无 dry-run/storage/cache/restart factory,Back/导航/版本/claim 漂移后对象失效。
|
||||
- 离线 fake-device/port 测试覆盖四条件缺一、控件 0/2 个、文本/版本/前台包漂移、非首次 permit、点击意图落盘失败、并发进入、停止/关闭/重启,所有负例底层点击数为 0。
|
||||
- 唯一成功调用测试证明点击意图已先落盘、底层点击调用数恰好为 1;底层返回、异常和超时后再次进入/重启的累计点击数仍为 1,没有 retry/fallback。
|
||||
- 点击后的所有离线分支都只生成 UNKNOWN;静态测试禁止 SUBMITTED 推断、提交后截图/XML/evidence 上传,以及页面观察或第二次点击。
|
||||
- AST/import/call graph 证明只有一个生产低层提交点击点,且支付、免密支付、先用后付、扣款控件、人工提交按钮和通用 click 不可达。
|
||||
- 运行 client 全量 unittest、compileall、wheel metadata、完整 init、上下文、Vikunja export 与 diff-check;人工放行前不得运行真机提交命令。
|
||||
- 真机记录必须包含“全新任务/授权/attempt”证明、设备型号、Android、连接方式、拼多多版本、goods_id、task/authorization/attempt/submission id、本次三闸门值/AssetRef、唯一控件计数、fence 首次许可、点击意图先落盘、低层点击恰好一次、UNKNOWN/RECONCILIATION_REQUIRED、T-403 人工结论、订单是否产生/如何处置和“未付款”。不得记录 token、地址/手机号字段、支付凭据或点击后截图/XML。
|
||||
- 真机运行与最终结论只能由项目所有者明确批准并人工确认;确认前保持 DOING,agent 不得自行执行、推断成功或标 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只把 T-107 已取证且绑定拼多多 App 版本的精确唯一最终控件、T-308 当前进程首次明确
|
||||
`SubmissionPermit` / 一次性 `ArmedSubmission` 和 T-400 离线编排接入生产。不得接收服务端
|
||||
selector/坐标/动作脚本,不得使用相似文案、OCR、通用 click、旧项目常量或未取证页面能力。
|
||||
- 每次生产执行必须领取一条新的 `PENDING` + `ACTIVE` 授权真实任务,并由服务端创建新的 active
|
||||
attempt/generation。在一个全新且不中断的同一设备会话内,严格重新执行商品打开 → Gate1 → 数量
|
||||
设置/读回与 Gate2 → 确认页/Gate3 → 当前最终控件只读复核;不能从中间页面或旧 attempt 续跑。
|
||||
- T-305 正常 dry-run 已安全 Back,并以 `DRY_RUN_COMPLETED_BEFORE_FENCE` 终止 attempt。其任何
|
||||
结果对象、task/attempt id、screenshot/AssetRef、action journal、缓存页面对象、本地快照或旧 proof
|
||||
永远不得用于生产 `ReadyForSubmission`、申请 fence 或提交;结束/失败/围栏后的其它 attempt 同样拒绝。
|
||||
- 生产 `ReadyForSubmission` 只能在本次新 attempt 的 Gate3 与唯一控件刚刚实时通过、仍停留当前
|
||||
确认页且 claim/lease/authorization/generation/App 版本全部一致时即时构造。对象不得持久化、缓存、
|
||||
序列化或跨导航/Back/重启恢复;任一事实漂移立即失效并零 fence、零点击。
|
||||
- 只有一次性授权与服务端首次围栏许可、Gate2、Gate3、精确唯一提交控件四条件同时成立,并且
|
||||
T-308 已 durable 写入点击意图和 `RECONCILE_ONLY`,才可调用低层提交点击。生产调用图中低层
|
||||
点击恰好一个调用点、运行恰好一次;不得位于循环、retry、fallback、`finally` 或恢复回调。
|
||||
- 围栏/许可/持久化/页面复核任一失败均零点击。点击调用返回、抛错、超时、停止轮询、Esc、查看
|
||||
历史、关闭窗口或重启都不得重新 arm、重复页面动作或再次点击,也不得释放围栏、重新授权、
|
||||
重新领取或推进下一任务。
|
||||
- 首次真实提交后的页面尚无本项目真机证据。点击后客户端必须无条件上报固定 `UNKNOWN` 并进入
|
||||
`RECONCILIATION_REQUIRED`;不得解析页面、根据人眼结论推断 `SUBMITTED`,也不得把结果改报为
|
||||
其他 observation。订单是否产生只由管理员通过 T-403 调和同一 submission。
|
||||
- 本任务不得采集、持久化或上传点击后的截图/XML,不新增结果 evidence kind,不把 Gate3 图冒充
|
||||
结果图。尤其不得保存或上传外部支付页、支付凭据或安全校验页面。
|
||||
- 采购工具不得提供人工“提交订单”、重试提交、释放围栏或继续执行按钮。UI 只能显示后台自动阶段、
|
||||
submission id 和“可能已创建订单,禁止再次提交,只能人工调和”的安全状态。
|
||||
- 系统不点击支付、免密支付、先用后付或任何扣款控件,不自动取消外部订单。真实订单是否产生、
|
||||
是否付款和如何处置均由人负责;`WAITING_PAYMENT` 不等于成功。
|
||||
- 真实运行除管理员“开始采购”的业务授权外,还必须由项目所有者在任务执行记录中针对本次首次
|
||||
发布验收给出明确一次性批准。agent 不得自行运行真实提交命令、确认真机结论或标 `DONE`;
|
||||
`needs_device` 与 `needs_human_review` 未同时由人确认前必须保持 `DOING`。
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
id: T-402
|
||||
title: 待付款人工收口与付款事实记录
|
||||
phase: 4
|
||||
deps: [T-403]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 51
|
||||
context_ref: 8600c33
|
||||
work_branch: task/t-402-payment-closure
|
||||
needs_device: false
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-402.md
|
||||
- admin/migrations/00009_payment_closure.sql
|
||||
- admin/internal/migrations/migrations_test.go
|
||||
- admin/internal/domain/payment_closure.go
|
||||
- admin/internal/domain/payment_closure_test.go
|
||||
- admin/internal/paymentclosure/**
|
||||
- admin/internal/taskdetail/**
|
||||
- admin/internal/server/mark_paid.go
|
||||
- admin/internal/server/mark_paid_test.go
|
||||
- admin/internal/server/router.go
|
||||
- admin/internal/server/task_detail.go
|
||||
- admin/internal/server/task_detail_test.go
|
||||
- admin/internal/transport/webui/webui.go
|
||||
- admin/internal/transport/webui/templates/task-detail.html
|
||||
- admin/cmd/server/**
|
||||
- admin/README.md
|
||||
- docs/api.md
|
||||
- docs/04-architecture.md
|
||||
- docs/routes.md
|
||||
- docs/08-interaction-checklist.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=51 synced=2026-08-04T15:03:09Z sha256=c9d2806cefd7ce0cbbb96bd1b764eb54ff713cfb25715daa3581933c2f74ec4c -->
|
||||
## 背景
|
||||
|
||||
T-208 已冻结创建待付款订单后的 `WAITING_PAYMENT` 事实:仅当提交记录为 `SUBMITTED`,或管理员调和结论为 `CONFIRMED_CREATED` 时,系统才知道订单已创建。T-403 负责展示这些既有事实。本任务只记录管理员已经在拼多多人工完成真实付款这一外部事实,并把任务收口为成功;系统本身没有付款,也不得验证或自动执行付款。
|
||||
|
||||
## 关联
|
||||
|
||||
- Feature:F011
|
||||
- Story:US006
|
||||
- Integration:IX006
|
||||
- 前置任务:T-403
|
||||
- 后续消费者:T-404
|
||||
|
||||
## 实现方案
|
||||
|
||||
1. 新增唯一迁移 `00009_payment_closure.sql`,建立追加式 `payment_closures`:绑定同一 lineage 的 task、submission、attempt、authorization,以及管理员 actor、服务端 UTC 记录时间、幂等键与规范化请求摘要。唯一约束保证同一任务/提交只能收口一次,并保证同一管理员幂等键不可复用为不同请求;外键/约束必须阻止跨 lineage 事实。不得保存付款方式、账户、卡号、订单凭据、地址、手机号或截图/XML。migration down 只允许在 `payment_closures` 为空时执行;存在任意事实必须在删除表或约束前原子拒绝,禁止丢失付款审计事实。
|
||||
2. 在任务详情的 `WAITING_PAYMENT` 区域明确显示“订单已创建,系统尚未付款”,同时展示商品、规格、数量、金额上限,以及 T-208/T-403 已有提交、调和和三道闸门证据。不得暗示系统已付款。
|
||||
3. 管理员必须先在拼多多外部人工完成真实付款,再完成三个且仅三个确认组:`goods_spec_quantity_confirmed` 表示商品/规格/数量已核对,`amount_within_cap_confirmed` 表示订单金额未超过授权上限,`externally_paid_confirmed` 表示管理员已在拼多多人工完成真实付款。三项 wire 值必须全部严格为 `true`;任一缺失、为 `false` 或无法严格解析均返回 400 且零写入。按钮文案明确为“记录人工付款并标记完成(不执行付款)”。
|
||||
4. `POST /tasks/{id}/mark-paid` 仅接受管理员会话与 CSRF,通过严格表单读取 UUIDv4 幂等键、expected task version、当前 submission id 和上述三个固定布尔确认组,不接受别名或额外确认字段。actor 只能来自会话,时间只能来自服务端 UTC。
|
||||
5. 在单一事务和写锁内重新读取 task、submission、attempt、authorization:四者必须属于同一 lineage;任务必须正好是 `WAITING_PAYMENT`,submission 必须正好是该任务当前唯一提交且关联同一 attempt/authorization,authorization 必须正好是 `CONSUMED`。submission 必须为 `SUBMITTED`,或为已有 T-208 `CONFIRMED_CREATED` 最终调和事实的 `MANUAL_RESOLVED`。旧 task version、错误/过期 submission 固定返回 409 且零写入;任一 lineage、authorization、submission 或 task 状态冲突也必须返回 409、零写入,并让详情进入只读安全核查,绝不能推进为 `SUCCEEDED`。
|
||||
6. 所有检查通过后,同一事务追加 payment closure,并将任务从 `WAITING_PAYMENT` 单向推进到 `SUCCEEDED`、version + 1。submission、attempt、authorization 和已消费的提交围栏永久不可变,不得重开、释放、重置、重新授权或重新提交。
|
||||
7. 幂等性必须先查事实:同 key、同规范化 payload 在响应丢失和进程重启后稳定重放同一结果;同 key、不同 payload 返回 409;不同 key 在任务已收口后返回 `payment_already_recorded` 409。规范化 payload 必须包含 task、submission、expected version 和三个固定确认组。不得仅因任务已是 `SUCCEEDED` 就伪造成功响应。
|
||||
8. 成功页/详情页显示记录 actor、服务端时间和“仅记录人工付款事实,系统未执行付款”。失败保留用户输入并给出可操作提示,焦点回到错误摘要;事实冲突时只显示安全核查,不提供重试提交、释放或重新授权动作。
|
||||
9. 自动化测试只使用本地数据库夹具构造 `WAITING_PAYMENT` 和既有提交事实,覆盖事务、冲突、幂等、重启与 UI;不得连接拼多多、请求真实资金、模拟付款控件或要求测试人员实际付款。`needs_human_review` 只验收语义、权限和交互,不代表测试中发生真实付款。
|
||||
|
||||
## 验收
|
||||
|
||||
- 三个且仅三个 wire 确认组固定为 `goods_spec_quantity_confirmed`、`amount_within_cap_confirmed`、`externally_paid_confirmed`;三项全部严格为 `true` 才能继续,逐项覆盖缺失、`false`、非法值和额外别名,均返回 400 且零写入。
|
||||
- 只有管理员、有效 CSRF、精确 task version、精确当前 submission、同 lineage 的 task/submission/attempt/authorization 和 `CONSUMED` authorization 能写入;旧版本和错误/过期 submission 稳定返回 409 且零写入。
|
||||
- 覆盖 `SUBMITTED` 与 `CONFIRMED_CREATED` 两条允许路径,以及跨 task/attempt/authorization、authorization 非 `CONSUMED`、其它任务/提交状态和事实冲突;冲突均返回 409、零写入、进入只读安全核查且绝不 `SUCCEEDED`。
|
||||
- 覆盖并发双击、同键同载荷重放、同键异载荷冲突、不同键重复收口和进程重启;事实表和任务状态在同一事务提交或回滚。
|
||||
- migration 覆盖 up、重开、外键/唯一/lineage/append-only 约束、空表 down 成功、存在任意 payment closure 时 down 在删表前原子拒绝,以及 `foreign_key_check`。
|
||||
- 页面显著显示“系统尚未付款”和“不执行付款”,键盘操作、焦点、错误摘要、安全核查态及移动宽度可用。
|
||||
- 自动测试不需要真实付款,不访问手机或拼多多;代码不得包含任何支付、免密、先用后付或扣款控件操作。
|
||||
- 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、前端交互检查、根目录初始化门禁、上下文校验、导出校验和 `git diff --check`。
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- `mark-paid` 只记录管理员已经在拼多多外部人工完成真实付款的事实;系统没有执行付款,也不得发起、
|
||||
验证、模拟或自动化支付,不得读取或保存付款方式、账户、卡号、订单凭据、地址或手机号。
|
||||
- 三个且仅三个确认组固定为 `goods_spec_quantity_confirmed`、`amount_within_cap_confirmed`、
|
||||
`externally_paid_confirmed`;三项 wire 值必须全部严格为 `true`。任一缺失、为 `false`、非法值或
|
||||
别名返回 400 且零写入;旧 task version 与错误/过期 submission 固定返回 409。
|
||||
- 只允许精确处于 `WAITING_PAYMENT` 的任务和精确当前 submission 收口。事务必须确认 task、
|
||||
submission、attempt、authorization 属于同一 lineage,authorization 正好为 `CONSUMED`;
|
||||
submission 必须是 `SUBMITTED`,或已有 T-208 `CONFIRMED_CREATED` 最终调和事实。任一归属、
|
||||
状态或事实冲突都返回 409、零写入并进入只读安全核查,绝不能推进为 `SUCCEEDED`。
|
||||
- actor 只能取自已认证管理员会话,记录时间只能取服务端 UTC。幂等事实必须在任何状态短路前检查:
|
||||
同 key 同规范化载荷稳定重放,同 key 异载荷返回 409;不同 key 重复收口返回
|
||||
`payment_already_recorded` 409,不得仅因任务已是 `SUCCEEDED` 就伪造成功响应。
|
||||
- payment closure 事实与 task `WAITING_PAYMENT → SUCCEEDED`、version + 1 必须在同一事务内原子
|
||||
提交或回滚。submission、authorization 和已消费围栏永久不可变,不得重开、释放、重置、重新授权、
|
||||
重新领取或重新提交。
|
||||
- 本任务唯一新增 migration 是 `00009_payment_closure.sql`;事实表只保存同一 lineage 的 task、
|
||||
submission、attempt、authorization、actor、服务端时间、幂等键和请求摘要,不保存截图、XML、
|
||||
支付凭据或新的 evidence kind。`payment_closures` 为空时才允许 migration down;存在任意事实时
|
||||
必须在删表或约束前原子拒绝,禁止丢失付款审计事实。
|
||||
- 自动化测试只使用本地数据库夹具,不访问手机或拼多多,不请求真实资金,也不要求测试人员真实付款。
|
||||
`needs_human_review` 只验收管理 UI 的未付款语义、权限、确认和无障碍交互。
|
||||
- 本任务不修改 `client/` 或拼多多页面判据,不点击支付、免密支付、先用后付或任何扣款控件。
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
id: T-403
|
||||
title: 失败分类、证据归档与管理员调和视图
|
||||
phase: 4
|
||||
deps: [T-208]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 48
|
||||
context_ref: 8600c33
|
||||
work_branch: task/t-403-failure-reconciliation-view
|
||||
needs_device: false
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-403.md
|
||||
- admin/internal/taskdetail/**
|
||||
- admin/internal/server/task_detail.go
|
||||
- admin/internal/server/task_detail_test.go
|
||||
- admin/internal/server/task_failure_view_test.go
|
||||
- admin/internal/transport/webui/webui.go
|
||||
- admin/internal/transport/webui/templates/task-detail.html
|
||||
- admin/README.md
|
||||
- docs/routes.md
|
||||
- docs/08-interaction-checklist.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=48 synced=2026-08-04T14:51:01Z sha256=f3ddd60f4b212c01b488f92a91148bfcb299c20cc8f8084f198971c18b3a4b49 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-208 将冻结三闸门、submission fence、一次性 result 和管理员调和事务,T-206 提供状态详情底座。采购管理员仍需要在同一任务详情中看懂围栏前固定失败、围栏后结果不明、已有证据和人工调和历史,并只调用 T-208 已有的同一 submission 调和接口。T-403 只完成失败分类/证据归档事实的展示和调和 UI,不新增失败码、数据库事实或第二套调和事务。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-007、F-011、F-017 的管理展示与人工调和入口。
|
||||
- 用户故事:US-002、US-004、US-008。
|
||||
- 交互:IX-004、IX-009、IX-011。
|
||||
- 依赖:T-208;由其传递 T-205 固定事件/失败码、T-210 三类证据、T-206 状态详情和 T-207 恢复边界。
|
||||
- API:只消费 T-208 既有 POST /order-submissions/{sid}/reconcile;不新增或重复实现 reconcile store/事务。
|
||||
- 后续消费者:T-402 待付款人工收口、T-404 MVP 验收。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 扩展任务详情只读模型,按同一 task/authorization/attempt/submission 展示 T-205 已保存的固定 failure code、失败阶段、最后安全步骤、事件时间线、执行摘要及 Gate1/Gate2/Gate3 内部证据;不复制、删除、重命名或重新归类原始事实。
|
||||
2. failure code 只使用 T-205/T-208 已有版本化 allowlist。UI 可为现有码提供固定中文解释和安全下一步,但必须同时显示原始 code;未知 code 显示“未知失败码/停止处理”,不得猜测、合并、改写或映射成另一个码。
|
||||
3. 围栏前 NEEDS_MANUAL 只展示失败原因、证据和 T-207 已计算的安全恢复资格;T-403 不实现新的重置/取消事务,也不让查看证据触发恢复。
|
||||
4. 围栏后以 order_submission 为唯一主线,显示 submission id、三闸门 proof、首次许可事实、result observation、append-only reconciliation records 和当前结论。task.status 与 submission 冲突时以 submission 围栏事实优先并进入安全核查。
|
||||
5. RECONCILIATION_REQUIRED 页面只渲染 T-208 已冻结的 CONFIRMED_CREATED、CONFIRMED_NOT_CREATED_OR_CANNOT_COMPLETE、STILL_UNKNOWN 三种结论,并把管理员 session、CSRF、submission id、expected task version 和幂等键提交到既有 reconcile handler。
|
||||
6. T-403 不解析或重放 T-208 事务结果,不在 UI 层直接改 task/authorization/attempt/submission。响应不明时冻结原幂等键和原表单,只允许重放既有 reconcile 请求;不生成新结论或乐观更新终态。
|
||||
7. 围栏后任何状态都持续显示“可能已创建订单,只能调和同一提交,禁止再次提交”,并彻底隐藏重试下单、重置、取消、释放、重新授权、重新领取和设备继续动作。
|
||||
8. 内部 evidence 继续通过受保护 no-store 端点延迟加载;只展示已批准的 Gate1/Gate2/Gate3 PNG。没有 SUBMISSION_RESULT kind,不展示或上传外部支付页、支付凭据、完整 XML、路径或页面全文。
|
||||
9. 完整页与抽屉共享同一模板/事实;调和表单具备可见 label、错误关联、焦点恢复、44px 目标、aria-live、响应不明提示和 reduced-motion。只读终态不渲染表单。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 覆盖每个既有固定 failure code、未知 code、最后安全步骤、三类 evidence、空证据和关联冲突;测试只断言显示,不改写原始码。
|
||||
- 覆盖 NEEDS_MANUAL、RECONCILIATION_REQUIRED、WAITING_PAYMENT、FAILED 及 submission/task 状态冲突;任何 submission 存在时均无围栏前恢复或再次提交入口。
|
||||
- 三种调和结论只调用注入的 T-208 既有 handler/store 一次;T-403 没有第二套 SQL、migration、状态机或直接数据库写入。
|
||||
- 同幂等键响应不明重放保持原载荷;异载荷、旧版本、错 submission 的错误按 T-208 原结果展示,不乐观改变页面状态。
|
||||
- 完整页/抽屉、键盘、焦点、加载/失败/空态、权限、CSRF、HTML escaping、no-store evidence 和移动宽度通过测试与人工 UI 评审。
|
||||
- 静态检查确认没有新 failure/reason 常量、migration、reconcile transaction、client/PDD、提交点击、付款或扣款能力。
|
||||
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、相关前端静态测试、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只展示 T-205/T-208 已有失败、事件、三闸门 evidence、submission 与 reconciliation 事实,
|
||||
并把管理员表单提交到 T-208 既有 reconcile handler/store;不得新增或改写 failure/reason code、
|
||||
migration、状态机、调和事务或直接数据库写入。
|
||||
- 原始 failure code 必须始终可见;固定中文解释只能附加在已有 allowlist 上。未知 code 必须安全失败并
|
||||
显示“未知失败码/停止处理”,不得猜测、合并或映射为另一个 code。
|
||||
- 任一 submission 的存在都优先于 task.status。围栏后不得显示或提供重试下单、重置、取消、释放、
|
||||
重新授权、重新领取、再次提交或设备继续动作;只能查看和调和同一 submission。
|
||||
- evidence 只读取 T-210 已批准的 Gate1/Gate2/Gate3 内部 PNG,不上传、删除、重命名或重新归类资产,
|
||||
不新增结果截图 kind,不展示外部支付页、支付凭据、完整 XML、文件路径或页面全文。
|
||||
- 调和结论只能是 T-208 已冻结的 `CONFIRMED_CREATED`、
|
||||
`CONFIRMED_NOT_CREATED_OR_CANNOT_COMPLETE`、`STILL_UNKNOWN`。响应不明时只允许用原幂等键和
|
||||
原载荷重放既有请求,不得生成新结论或乐观更新终态。
|
||||
- 本任务不修改 `client/` 或拼多多页面判据,不连接真机,不点击“提交订单”,不编写支付、免密支付、
|
||||
先用后付或任何扣款能力。
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
id: T-404
|
||||
title: 完整 MVP 只读验收
|
||||
phase: 4
|
||||
deps: [T-401, T-402, T-403, T-405]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 54
|
||||
context_ref: 8600c33
|
||||
work_branch: task/t-404-mvp-acceptance
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-404.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=54 synced=2026-08-04T15:16:26Z sha256=3d2e1900e156174e8389af5ef372af9f94be7f719c39432eaf2ea42f4a38d311 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-401 提供唯一一次真实提交与人工调和证据,T-402/T-403 提供待付款人工收口、失败归档和同一 submission 调和,T-405 提供可核验 Windows 交付包。T-404 是 MVP 的最终只读验收任务:对同一候选 commit、服务端、客户端、真机记录和实际 ZIP 做完整 P0 复核,不承载任何修复。发现缺陷必须退回对应任务修正并重新形成候选版本,不能在验收任务中顺手改代码、契约、安全边界或共享文档。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:全部 MVP P0,重点 F-001、F-004、F-005、F-006、F-007、F-008、F-010、F-011、F-013、F-017、F-018。
|
||||
- 用户故事:US-001~US-008、US-010。
|
||||
- 交互:IX-001~IX-012 中适用于 MVP 的全部条目。
|
||||
- 依赖:T-401、T-402、T-403、T-405。
|
||||
- 本任务 needs_device=true 且 needs_human_review=true;agent 只能整理和运行可逆的只读/测试检查,最终结论和 DONE 只能由人确认。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 冻结候选 git commit、admin/client 版本、数据库 migration 版本、T-405 ZIP 路径/SHA-256/完整依赖 manifest 和测试环境。验证期间源码、依赖集合或产物 hash 变化即作废本轮结论,回原任务修复后从头验收。
|
||||
2. 建立 P0 trace matrix,把每个功能、用户故事和交互映射到自动化测试、人工 UI 检查、T-101~T-107/T-305 真机证据或 T-401 首次真实提交记录。缺项不能用“代码已完成”代替。
|
||||
3. 采购服务检查登录/CSRF、DRAFT 创建与倒序列表、筛选/批量选择、开始采购一次性授权、设备身份隔离、claim/lease、events/evidence、三闸门围栏、UNKNOWN result、同一 submission 调和、WAITING_PAYMENT 和人工付款事实记录。围栏后任何页面均无重试/释放/重新授权。
|
||||
4. 采购工具检查固定双 Tab、配置/DPAPI/单实例、轮询停止边界、当前任务/日志/历史详情、同一趟规格/数量/三闸门、严格 HTTP/localstate、一次性 permit/点击意图和 T-400 编排。静态调用图必须只有 T-401 一个低层提交点击点且无付款能力。
|
||||
5. 复核 T-401 人工记录:项目所有者一次性批准、设备/Android/连接方式/拼多多版本/goods_id、task/authorization/attempt/submission、三闸门、首次 fence permit、点击意图先落盘、低层点击恰好一次、无重试、无点击后截图/XML、UNKNOWN/RECONCILIATION_REQUIRED、T-403 结论、订单处置和未付款。
|
||||
6. T-404 不再创建第二笔真实订单、不再次点击“提交订单”。真机部分只复核既有 T-401 原始记录。T-405 包的无设备验收只允许启动/关闭、静态配置检查,以及向精确 loopback 采购服务调用已经由服务端实现并证明无业务副作用的 GET /healthz;该请求不得携带设备 Bearer、管理 session 或 CSRF,也不得读取/修改任务、授权、attempt、evidence 或 submission。
|
||||
7. 严禁把 claim-next、lease renew、evidence、events、attempt fail、submission fence、submission result、管理员任务动作或任何其它业务 API 当作“连接健康检查”。即使使用测试 token、空任务或声称只看响应,也不得从 T-405 包调用这些端点探测,因为它们可能创建幂等事实、领取任务或推进状态。
|
||||
8. 无设备包验收不得运行 adb.exe、adb devices、uiautomator2、设备连接检查、serial 探测或任何 Android/PDD 命令。ADB 路径/serial 保持未配置,进程/日志必须证明零 ADB、零 claim、零真机动作;需要设备的事实只复核 T-401 已有人工记录。
|
||||
9. 复核 T-402 时自动化只用本地 fixture;没有人在真实订单上实际付款时,不得为了验收把生产任务标为 SUCCEEDED。WAITING_PAYMENT 是正常未付款状态,不等于失败,也不等于采购完成。
|
||||
10. 用 T-405 实际 ZIP 在脱离源码、无 Python 的 Windows 环境验证 SHA-256、commit、完整直接依赖版本、解压、启动、配置、运行数据目录、升级/卸载说明和无设备安全失败;确认包内没有 token、env、本地状态、日志、证据、tests、XML 或仓库元数据。
|
||||
11. 运行完整双端门禁:Go test/race/vet/build、client unittest/compileall/wheel metadata、打包专用校验、根 init、agent-context、Vikunja export check、git diff check。业务 API 只由隔离数据库/fixture 的自动测试覆盖,不通过实际打包客户端做“探测”;测试不连接支付或外部资金路径。
|
||||
12. 验收执行记录只写测试命令、退出码、P0 matrix 结论、证据路径/hash 和人工确认,不复制 token、地址/手机号、支付凭据、完整 XML 或原始截图。任何失败均记录 owning task 和复现命令,T-404 保持 DOING/TODO,等待原任务修复。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 所有依赖均按 git frontmatter 为 DONE;T-401/T-405 的人工记录、真实 ZIP、完整依赖 manifest 与候选 commit/hash 完整一致。
|
||||
- P0 功能/故事/交互全部有自动化或明确人工证据;不存在 WAITING_CONFIRMATION、第二趟授权、AI/V2 能力或未取证页面判据混入 MVP。
|
||||
- 三道价格闸门、提交四条件、首次许可、点击意图先落盘、底层点击恰好一次、异常后零重试和围栏后只调和均有测试与 T-401 人工证据。
|
||||
- 静态检查证明支付、免密支付、先用后付、扣款控件、人工提交按钮、通用远程 selector/坐标和第二提交调用点不存在。
|
||||
- 采购服务 375/768/1024/1440 px 与采购工具 compact/medium/wide、键盘、焦点、错误/空态/离线/结果不明、深浅色/高对比人工验收通过。
|
||||
- T-405 ZIP 在运营 Windows 环境按文档运行,SHA-256/commit/依赖版本匹配,包内容和运行目录安全。无设备验收最多调用 GET /healthz,服务端任务/授权/attempt/evidence/submission 事实前后不变;没有业务 API 请求、ADB 进程或设备连接。
|
||||
- 完整门禁全部 exit 0,仓库无本任务造成的代码/共享文档改动、无秘密/真机证据进入 Git。若有缺陷,必须回对应任务修复并重新跑全套。
|
||||
- 最终 DONE 只能由项目所有者在人审 P0 matrix、T-401 真机记录、T-405 产物和无付款证明后明确给出;agent 不得自行 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务是最终只读验收,不实现或修复任何生产代码、测试、迁移、契约、页面判据、UI、构建脚本
|
||||
或共享文档;唯一允许写入的仓库文件是本任务执行记录/status。发现缺陷必须退回 owning task 修复,
|
||||
重新冻结候选 commit/产物并从头验收,不能在 T-404 顺手修改。
|
||||
- 验收只针对一个精确候选 git commit、数据库 migration 集、admin/client 版本和 T-405 ZIP SHA-256。
|
||||
源码、依赖、任务定义或产物 hash 任一变化都使本轮结论失效,不能拼接不同版本的测试证据。
|
||||
- T-404 不再创建第二笔真实订单、不再次点击“提交订单”。真机提交证据只复核 T-401 已由项目
|
||||
所有者批准的唯一真实运行;T-405 包验收不得重新执行围栏前 dry-run 或任何设备流程。
|
||||
- T-405 包的无设备验收只允许启动/关闭、静态配置检查,以及向精确 loopback 采购服务调用已经由
|
||||
服务端实现并证明无业务副作用的 `GET /healthz`。该请求不得携带设备 Bearer、管理员 session 或
|
||||
CSRF,且服务端 task/authorization/attempt/evidence/submission 事实前后必须完全不变。
|
||||
- 严禁使用 `claim-next`、lease renew、evidence、events、attempt fail、submission fence、submission
|
||||
result、管理员任务动作或任何其它业务 API 做“连接健康检查”;测试 token、空任务或只看响应都不
|
||||
构成例外,因为这些接口可能创建幂等事实、领取任务或推进状态。
|
||||
- 无设备包验收不得运行 `adb.exe`、`adb devices`、uiautomator2、serial/设备连接检查或任何
|
||||
Android/PDD 命令。ADB 路径和 serial 保持未配置,并以进程/日志证明零 ADB、零 claim、零真机动作;
|
||||
needs_device 事实只复核 T-401 已有人工记录。
|
||||
- 不得为了测试 T-402 而要求或模拟真实付款;自动化只用隔离 fixture。没有人实际付款时不得把生产
|
||||
任务标为 `SUCCEEDED`,`WAITING_PAYMENT` 是正常未付款状态,不等于失败或采购完成。
|
||||
- P0 验收必须逐项关联功能、用户故事、交互与自动/人工证据。缺项、失败、版本漂移、未知页面判据、
|
||||
真机记录不全、ZIP/hash 不一致或付款边界无法证明时均不得通过,也不能用“代码已完成”替代证据。
|
||||
- 完整门禁和静态调用链必须证明三道价格闸门、提交四条件、首次 permit、点击意图先落盘、低层点击
|
||||
恰好一次、异常后零重试、围栏后只调和,以及支付/免密/先用后付/扣款能力不存在。
|
||||
- 执行记录不得复制 token、地址/手机号、支付凭据、完整 XML、原始截图或本机秘密;只记录命令、
|
||||
退出码、非敏感证据路径/hash、P0 结论和人工确认。真实证据仍留受控本地/服务端位置。
|
||||
- `needs_device: true` 与 `needs_human_review: true`:最终 `DONE` 只能由项目所有者在人审 P0 matrix、
|
||||
T-401 真机记录、T-405 产物和无付款证明后明确给出;agent 不得自行确认或标 `DONE`。
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
id: T-405
|
||||
title: 采购工具 Windows 打包与运行文档
|
||||
phase: 4
|
||||
deps: [T-401]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 53
|
||||
context_ref: 8600c33
|
||||
work_branch: task/t-405-windows-package
|
||||
needs_device: false
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-405.md
|
||||
- client/pyproject.toml
|
||||
- client/requirements-build.txt
|
||||
- client/cmbuyer-client.spec
|
||||
- client/scripts/build_windows.ps1
|
||||
- client/scripts/verify_windows_bundle.py
|
||||
- client/tests/test_windows_packaging.py
|
||||
- client/README.md
|
||||
- docs/03-tech-stack.md
|
||||
- .gitignore
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=53 synced=2026-08-04T15:16:24Z sha256=31032aac3c37c9992ba7ce04a5bb3cac406b2e8cef54106eeb9213d2c7b75e91 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-401 完成生产接线和首次真实提交人工验收后,采购工具仍需转换为运营电脑可解压运行、可追溯到准确源码、构建环境实际依赖和可检查内容的 Windows 交付包。T-405 使用 PyInstaller 生成 onedir 目录并封装 ZIP,提供构建、运行时依赖清单、校验、运行、升级与卸载文档;打包和验收不连接 Android 设备,不执行采购、提交或付款。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-005、F-013 的 Windows 交付运行部分。
|
||||
- 用户故事:US-003、US-007、US-008。
|
||||
- 交互:IX-007、IX-008;保持“采购工具”名称、固定双 Tab 和安全退出语义。
|
||||
- 依赖:T-401;只打包已经完成人工安全验收的生产 composition。
|
||||
- 后续消费者:T-404 完整 MVP 验收。
|
||||
- needs_device=false;构建、内容检查和无设备启动测试不得连接 ADB 或拼多多。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 新增独立 build requirements,固定兼容的 PyInstaller 版本;运行时依赖继续只由 requirements.txt / wheel metadata 管理,不把 PyInstaller 作为运营时依赖或在应用启动时下载组件。
|
||||
2. 维护显式 PyInstaller spec,生成 windowed onedir 包。入口唯一为 cmbuyer_client.app:main,只收集运行必需的 PySide6/uiautomator2/adbutils/Pillow 模块、Qt/Shiboken 运行库和已知资源;不使用任意目录收集、隐藏执行脚本或下载器。
|
||||
3. PowerShell 构建脚本从仓库根解析并校验精确 client 目录、Python 3.11+ venv、干净的显式输出目录和当前 commit;只清理已验证位于 client 构建输出下的目录,不对仓库根、用户目录、LOCALAPPDATA 或未解析变量执行递归删除。
|
||||
4. 构建前从已安装 wheel metadata/requirements 解析全部直接运行时依赖的规范名称和实际版本,不能只读取未锁定的需求表达式。至少显式探测 PySide6、Qt runtime、Shiboken6、uiautomator2、adbutils、Pillow;Qt 版本必须来自实际 Qt runtime,其他版本来自当前构建环境中实际解析的 distribution/runtime,而不是手写常量。
|
||||
5. 构建产物为版本化 onedir 目录和其 ZIP;生成不含秘密的 manifest,记录产品/版本、git commit、Python/PyInstaller 版本、构建 UTC、入口、ZIP SHA-256,以及上述全部直接运行时依赖和 Qt/Shiboken 组件的规范名称、实际版本与探测来源。未锁依赖因此仍可追溯,但不得声称字节级可复现。
|
||||
6. PyInstaller spec 必须保留校验所需的 distribution metadata/Qt runtime 事实。校验器分别从构建环境和实际 onedir/ZIP 内解析依赖集合与版本,要求和 manifest 精确一致;缺依赖、漏记直接依赖、版本不一致、无法探测或只相信 manifest 自报都必须失败,不发布 ZIP。
|
||||
7. bundle 内容校验显式拒绝 vikunja.env、任何 .env/token、client-state.sqlite3/WAL/SHM、LOCALAPPDATA 状态、日志、artifacts、截图、XML、业务 evidence manifest、tests/fixture、__pycache__、源码仓库元数据和支付凭据。交付依赖 manifest 是允许的专用构建元数据,不能混入业务证据。
|
||||
8. 校验器检查 ZIP 防路径穿越、文件清单、入口 EXE、关键依赖、重复/绝对路径、最大体积边界、SHA-256 和依赖版本;不得仅以 PyInstaller 退出码 0、requirements 约束或版本文件存在声称可交付。
|
||||
9. 运行文档固定“解压整个目录后运行”,说明采购服务精确 loopback、ADB 路径/serial 人工配置、设备 token 安全录入、运行数据位于 %LOCALAPPDATA%/cmbuyer、日志/状态位置和常见错误。不得要求复制 vikunja.env、源码、Python 或开发 venv。
|
||||
10. 升级只替换应用目录,先退出应用并保留 %LOCALAPPDATA%/cmbuyer;卸载只删除明确的应用解压目录,不自动删除状态、日志或证据。需要清理本地数据时必须另行人工确认精确路径。
|
||||
11. 在脱离源码目录、未安装 Python 的 Windows 测试环境解压并启动,检查产品名、双 Tab、配置/错误提示和安全关闭;测试使用空闲/无设备配置,零 claim、零 ADB、零真机动作、零提交。
|
||||
12. 不在 MVP 内实现安装器、自动更新、代码签名服务或后台常驻。若产物未签名,文档如实说明 Windows 提示与人工核验 SHA-256、commit 和依赖版本 manifest 的步骤,不能诱导绕过安全软件。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 构建脚本在合规 Windows venv 生成唯一 onedir ZIP 和 manifest;manifest 枚举 wheel metadata/requirements 中全部直接运行时依赖的实际版本,并至少包含 PySide6、Qt、Shiboken6、uiautomator2、adbutils、Pillow、Python、PyInstaller 与 git commit。
|
||||
- 构建环境探测结果、实际 onedir/ZIP 内 distribution metadata/Qt runtime 和 manifest 三方逐项一致;漏项、未知项、版本漂移、无法从 bundle 复核或伪造 manifest 均使校验失败。
|
||||
- 重复构建记录各自 commit、完整实际依赖版本集合和产物 SHA-256;依赖未锁时仍能追溯本次解析结果,但不声称字节级可复现。
|
||||
- 内容负例向 staging 注入 env/token、SQLite/WAL、日志、PNG/XML、tests/fixture、绝对/穿越路径时校验均失败且不发布 ZIP。
|
||||
- ZIP 解压后目录外无写入;应用所有状态仍进入 %LOCALAPPDATA%/cmbuyer,不污染安装目录或源码树。
|
||||
- 在无源码、无 Python 的 Windows 环境启动成功;产品名/双 Tab/配置错误/关闭路径可用,且无设备时零 claim、零 ADB、零提交、零付款。
|
||||
- 文档覆盖构建、SHA-256/commit/依赖版本核验、解压运行、配置、升级、卸载、日志和故障排查;明确系统只创建待付款订单且不付款。
|
||||
- 运行 client unittest、compileall、wheel metadata、打包专用测试、完整 init、上下文、Vikunja export 与 diff-check;记录产物绝对路径与 SHA-256,但不把 ZIP、manifest 中的本机秘密或构建目录提交 Git。
|
||||
- needs_human_review:运营电脑人工检查解压、启动、文档、Windows 安全提示、SHA-256 和依赖版本 manifest;确认前保持 DOING。
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 只交付 PyInstaller `windowed onedir` 目录及其版本化 ZIP,不在本任务引入安装器、自动更新、后台
|
||||
常驻、下载器或代码签名服务。未签名时必须如实说明 Windows 提示和 SHA-256 人工核验步骤,
|
||||
不得指导绕过安全软件。
|
||||
- PyInstaller 只作为固定版本的构建依赖,不能进入运营时依赖或由应用启动时联网安装。运行时依赖
|
||||
仍以 `requirements.txt` / wheel metadata 为唯一声明来源,不维护第二份手写依赖清单;manifest
|
||||
必须记录本次构建环境实际解析并打入 bundle 的版本,不能只复制未锁定的版本约束。
|
||||
- bundle 和 ZIP 绝不能包含 `vikunja.env`、任何 `.env`/token、设备/claim 凭据、本地 SQLite/
|
||||
WAL/SHM、配置状态、日志、artifacts、截图、XML、证据 manifest、tests/fixture、源码仓库元数据、
|
||||
支付凭据或本机绝对路径。发现拒绝项必须失败且不发布产物。
|
||||
- 构建脚本只能清理已经解析并验证位于 `client` 构建输出目录下的精确路径;不得对仓库根、用户目录、
|
||||
`%LOCALAPPDATA%`、空变量、通配目标或未经验证的计算路径执行递归删除。
|
||||
- 交付 manifest 必须记录产品/版本、git commit、Python/PyInstaller、UTC、入口、产物 SHA-256,
|
||||
以及 wheel metadata/requirements 中**全部直接运行时依赖**的规范名称、实际解析版本和探测来源;
|
||||
至少显式包含 `PySide6`、实际 Qt runtime、`Shiboken6`、`uiautomator2`、`adbutils`、`Pillow`。
|
||||
Qt 必须从实际 runtime 探测,其余必须来自当前构建 distribution/runtime,不能使用手写常量。
|
||||
- 校验器必须分别从构建环境和实际 onedir/ZIP 内的 distribution metadata、Qt runtime/组件文件核对
|
||||
完整依赖集合与版本,并要求与 manifest 精确一致。漏项、版本漂移、无法从 bundle 复核或只信任
|
||||
manifest 自报都必须失败且不发布 ZIP;PyInstaller spec 必须保留完成该核对所需的最小 metadata。
|
||||
- manifest 不记录 token、设备 serial、用户名、业务数据或证据路径。依赖未锁时,实际版本集合用于
|
||||
追溯本次构建,但仍不得宣称字节级可复现;版本号或文件名不能替代 SHA-256/commit 证明。
|
||||
- 运行、升级和卸载不得自动删除 `%LOCALAPPDATA%/cmbuyer`。升级只替换明确的应用目录;清理状态、
|
||||
日志或证据必须另行由人确认精确路径,不能作为卸载脚本副作用。
|
||||
- 构建与验收 `needs_device: false`:不得连接 ADB、启动拼多多、领取真实任务、执行页面动作、提交
|
||||
订单或付款。无源码/无 Python 环境的启动验收使用空闲配置,保持零 claim、零真机动作。
|
||||
- 本任务不修改 T-401 提交安全逻辑,不增加支付、免密支付、先用后付或任何扣款能力。
|
||||
`needs_human_review: true`,运营电脑人工核验 ZIP、文档、启动和 SHA-256 前不得标 `DONE`。
|
||||
Reference in New Issue
Block a user