Compare commits

..
Author SHA1 Message Date
QiuSW b0b71d5a29 feat(client): add T-106 confirmation evidence capture 2026-08-06 10:21:11 +08:00
QiuSW acc9f1d964 docs(task): claim T-106 confirmation evidence 2026-08-06 10:12:43 +08:00
QiuSW db2b781c8c docs(task): complete T-105 quantity Gate2 2026-08-06 09:54:48 +08:00
QiuSW 3f09fbff66 docs(task): prepare T-105 production acceptance 2026-08-06 09:29:16 +08:00
QiuSW 21628a4dc5 feat(client): verify T-105 quantity Gate2 2026-08-06 09:28:00 +08:00
QiuSW 1d726dd364 docs: align Gate2 with quantity total evidence 2026-08-06 09:13:13 +08:00
QiuSW e18f7983fd fix(client): read Android 16 foreground exactly 2026-08-06 08:52:42 +08:00
QiuSW ad80a7f23c docs(task): prepare T-105 quantity evidence 2026-08-05 18:09:31 +08:00
QiuSW 994b6c8054 feat(client): capture T-105 quantity states 2026-08-05 18:08:11 +08:00
QiuSW 16b2487a2a docs(task): claim T-105 quantity evidence 2026-08-05 17:59:18 +08:00
QiuSW 67b427864a docs(task): complete T-104 safe exit 2026-08-05 17:58:03 +08:00
QiuSW 1bb54927e1 feat(client): verify same-product safe exit 2026-08-05 17:56:16 +08:00
QiuSW 0afe9a53b3 docs(task): accept T-104 phase A device evidence 2026-08-05 17:47:29 +08:00
QiuSW 63bd873c26 fix(client): accept evidenced rolled target color 2026-08-05 17:39:12 +08:00
QiuSW 2e303f3829 docs(task): record T-104 rolled color evidence 2026-08-05 17:38:32 +08:00
QiuSW f97dca95ae docs(task): record T-104 phase A readiness 2026-08-05 17:27:59 +08:00
QiuSW 43fd8ce668 feat(client): capture T-104 safe exit evidence 2026-08-05 17:25:27 +08:00
QiuSW 44a207df7f docs(task): claim T-104 safe exit evidence 2026-08-05 17:04:46 +08:00
QiuSW 29fb528823 docs(task): complete T-103 device acceptance 2026-08-05 17:03:16 +08:00
QiuSW 537159445e docs(task): record production sku device run 2026-08-05 16:59:15 +08:00
QiuSW 91071d4013 docs(task): record production sku flow review 2026-08-05 16:55:52 +08:00
QiuSW 60cf05b08b feat(client): productionize proven sku reveal flow 2026-08-05 16:55:10 +08:00
QiuSW fec21ae857 docs(task): bind target M transition 2026-08-05 16:20:52 +08:00
QiuSW 3984f399b0 docs(task): accept reveal human review 2026-08-05 16:09:37 +08:00
QiuSW 251f3d92dd docs(task): record accepted reveal evidence 2026-08-05 15:57:12 +08:00
QiuSW 5fa8ac6ff2 fix(client): bind revealed current-only price layout 2026-08-05 15:54:00 +08:00
QiuSW a8570f59af docs(task): accept reveal action evidence 2026-08-05 15:31:51 +08:00
QiuSW ee56249014 docs(task): record reveal candidate evidence 2026-08-05 15:29:56 +08:00
QiuSW e552e2e2ca docs(task): record reveal precondition stop 2026-08-05 15:24:30 +08:00
QiuSW e1e534c5d3 fix(client): accept exact current-only sku state 2026-08-05 15:20:09 +08:00
QiuSW 5a4633fb64 docs(safety): bind current-only SKU price evidence 2026-08-05 14:50:38 +08:00
QiuSW 5d0fc81891 fix(client): report safe T-103 reveal stages 2026-08-05 14:19:37 +08:00
QiuSW 230cfdc697 test(client): close T-103 reveal failure gaps 2026-08-05 12:56:56 +08:00
QiuSW b4ff9096c3 test(client): tighten polling test synchronization 2026-08-05 12:47:34 +08:00
QiuSW ae4b9ef08b test(client): accelerate T-103 feedback loop 2026-08-05 12:47:22 +08:00
QiuSW 1bf3acda70 docs(state): record one-shot sku reveal readiness 2026-08-05 12:37:28 +08:00
QiuSW e8ca738b33 feat(client): capture one-shot sku reveal evidence 2026-08-05 12:34:59 +08:00
QiuSW 429c2d33db docs(safety): define one-shot sku reveal evidence 2026-08-05 11:45:58 +08:00
QiuSW a15b6c0911 docs(safety): bind T-103 live panel states 2026-08-05 11:37:19 +08:00
QiuSW 29d1540f7c docs(tasks): record corrected T-103 entry 2026-08-05 10:57:55 +08:00
QiuSW 2f1380d084 fix(client): target verified bottom sku entry 2026-08-05 10:56:55 +08:00
QiuSW 81a44caeef docs(safety): correct verified sku entry boundary 2026-08-05 10:45:28 +08:00
QiuSW 757eca39fd docs(tasks): record T-103 entry substage 2026-08-05 10:34:12 +08:00
QiuSW b754cc9ca9 fix(client): identify sku entry failure substage 2026-08-05 10:32:35 +08:00
QiuSW e45d26fe93 docs(tasks): record T-103 entry evidence 2026-08-05 10:16:05 +08:00
QiuSW a09c58beef fix(client): stabilize verified sku entry 2026-08-05 10:10:56 +08:00
QiuSW 42eac57824 docs(tasks): record safe T-103 diagnostics 2026-08-05 09:24:06 +08:00
QiuSW c9b39a4e05 test(client): remove polling stop race 2026-08-05 09:14:02 +08:00
QiuSW 870e5f861d fix(client): expose safe T-103 failure stages 2026-08-05 09:07:30 +08:00
QiuSW 025edaf273 docs(tasks): record T-304 code-complete review 2026-08-05 02:03:02 +08:00
QiuSW 584c5601c5 task(T-304): record code-complete verification 2026-08-05 01:59:35 +08:00
QiuSW 3a27225977 fix(client): fail closed on polling configuration drift 2026-08-05 01:59:35 +08:00
QiuSW dfd88c3336 feat(client): add safe polling session UI 2026-08-05 01:59:35 +08:00
QiuSW 20112dbd61 feat(client): expose secret-free profile summary 2026-08-05 01:59:35 +08:00
QiuSW 404d0d3ca4 task(T-304): add metadata and stop-latch contract 2026-08-05 01:59:35 +08:00
QiuSW 85e5f1e56e task(T-304): begin polling session UI 2026-08-05 01:59:35 +08:00
QiuSW 6145fc4468 docs(tasks): complete T-211 claim wire bounds 2026-08-05 01:52:49 +08:00
QiuSW f6cd65208d fix(api): align claim bounds across runtime snapshots 2026-08-05 01:39:35 +08:00
QiuSW 7ea1c5349f fix(api): bound claim wire fields end to end 2026-08-05 01:25:23 +08:00
QiuSW 57a5b2e91c chore(tasks): include T-211 claim handler boundary 2026-08-05 01:18:23 +08:00
QiuSW 63c2c59b41 chore(tasks): start T-211 claim wire bounds 2026-08-05 01:11:21 +08:00
QiuSW cfb238d022 docs(tasks): complete T-303 durable client state 2026-08-05 01:04:35 +08:00
QiuSW 31f07ac245 feat(client): add durable HTTP task state 2026-08-05 00:59:32 +08:00
QiuSW 488005ac93 chore(tasks): start T-303 client HTTP state 2026-08-05 00:59:32 +08:00
QiuSW 4186f1315a docs(tasks): tighten exit and evidence contracts 2026-08-04 23:36:01 +08:00
QiuSW 3a0a41db00 docs(tasks): complete MVP safety task graph 2026-08-04 23:20:28 +08:00
113 changed files with 18520 additions and 696 deletions
+14 -2
View File
@@ -14,7 +14,10 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
const maxClaimJSONBytes = 4096 const (
maxClaimJSONBytes = 4096
maxClaimResponseJSONBytes = 32 * 1024
)
func claimNext(options Options) gin.HandlerFunc { func claimNext(options Options) gin.HandlerFunc {
return func(context *gin.Context) { return func(context *gin.Context) {
@@ -35,7 +38,16 @@ func claimNext(options Options) gin.HandlerFunc {
context.Status(http.StatusNoContent) context.Status(http.StatusNoContent)
return return
} }
context.JSON(http.StatusOK, response) if !taskclaim.ValidClaimResponse(response) {
context.Status(http.StatusServiceUnavailable)
return
}
encoded, err := json.Marshal(response)
if err != nil || len(encoded) > maxClaimResponseJSONBytes {
context.Status(http.StatusServiceUnavailable)
return
}
context.Data(http.StatusOK, "application/json; charset=utf-8", encoded)
} }
} }
+49
View File
@@ -3,8 +3,10 @@ package server_test
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"io" "io"
"math"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -98,6 +100,53 @@ func TestClaimNextStrictJSONSuccessEmptyAndErrors(t *testing.T) {
} }
} }
func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsClosed(t *testing.T) {
goodsID := strings.Repeat("1", 32)
worst := taskclaim.ClaimResponse{
Task: taskclaim.ClaimedTask{
ID: claimTaskID, Version: math.MaxInt, Title: strings.Repeat("<", 120),
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID,
GoodsID: goodsID, SKUColor: strings.Repeat("<", 80), SKUSize: strings.Repeat("<", 80),
Quantity: 9_223_372_036_854_775_807, MaxTotalPrice: strings.Repeat("9", 29) + ".00",
},
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: math.MaxInt - 1, ExpiresAt: "9999-12-31T23:59:59.999999999Z"},
Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 9_223_372_036_854_775_807, LeaseExpiresAt: "9999-12-31T23:59:59.999999999Z"},
}
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
service := &fakeTaskClaimService{claimResponse: worst, claimFound: true}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
request := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json")
if response.Code != http.StatusOK || !json.Valid(response.Body.Bytes()) || response.Body.Len() >= 32*1024 {
t.Fatalf("worst legal response = status %d, bytes %d, valid JSON %v", response.Code, response.Body.Len(), json.Valid(response.Body.Bytes()))
}
mutations := map[string]func(*taskclaim.ClaimResponse){
"invalid utf8 title": func(response *taskclaim.ClaimResponse) { response.Task.Title = string([]byte{0xff}) },
"c0 separator title": func(response *taskclaim.ClaimResponse) { response.Task.Title = "visible\u001dhidden" },
"overlong title": func(response *taskclaim.ClaimResponse) { response.Task.Title += "<" },
"overlong goods id": func(response *taskclaim.ClaimResponse) {
response.Task.GoodsID += "1"
response.Task.ProductURL += "1"
},
"overlong color": func(response *taskclaim.ClaimResponse) { response.Task.SKUColor += "<" },
"overlong size": func(response *taskclaim.ClaimResponse) { response.Task.SKUSize += "<" },
"overlong money": func(response *taskclaim.ClaimResponse) { response.Task.MaxTotalPrice = strings.Repeat("9", 30) + ".00" },
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
invalid := worst
mutate(&invalid)
service := &fakeTaskClaimService{claimResponse: invalid, claimFound: true}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json")
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 {
t.Fatalf("invalid service response = %d %q", response.Code, response.Body.String())
}
})
}
}
func TestRenewStrictBindingResponseAndFixedErrors(t *testing.T) { func TestRenewStrictBindingResponseAndFixedErrors(t *testing.T) {
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}} authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}} service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}}
+41 -48
View File
@@ -10,12 +10,12 @@ import (
"fmt" "fmt"
"io" "io"
"math" "math"
"math/big"
"strings" "strings"
"sync" "sync"
"time" "time"
"cmbuyer/admin/internal/deviceauth" "cmbuyer/admin/internal/deviceauth"
taskmodel "cmbuyer/admin/internal/tasks"
) )
const writeTimeout = 2 * time.Second const writeTimeout = 2 * time.Second
@@ -80,7 +80,7 @@ func (store *Store) validateStoredClaims(ctx context.Context) error {
rows, err := store.database.QueryContext(ctx, `SELECT claims.claimed_by_device_id, claims.task_id, claims.authorization_id, rows, err := store.database.QueryContext(ctx, `SELECT claims.claimed_by_device_id, claims.task_id, claims.authorization_id,
claims.attempt_id, claims.claim_generation, claims.claim_nonce, typeof(claims.claim_nonce), length(claims.claim_nonce), claims.attempt_id, claims.claim_generation, claims.claim_nonce, typeof(claims.claim_nonce), length(claims.claim_nonce),
claims.claim_token_sha256, typeof(claims.claim_token_sha256), length(claims.claim_token_sha256), claims.claim_token_sha256, typeof(claims.claim_token_sha256), length(claims.claim_token_sha256),
claims.authorization_task_version, claims.goods_id, claims.sku_color, claims.sku_size, claims.task_title, claims.authorization_task_version, claims.goods_id, claims.sku_color, claims.sku_size,
claims.quantity, claims.total_price_cap, claims.authorization_expires_at, claims.closed_at, claims.quantity, claims.total_price_cap, claims.authorization_expires_at, claims.closed_at,
attempts.claim_generation, attempts.status, authorizations.status, tasks.status attempts.claim_generation, attempts.status, authorizations.status, tasks.status
FROM purchase_attempt_claims AS claims FROM purchase_attempt_claims AS claims
@@ -96,21 +96,21 @@ func (store *Store) validateStoredClaims(ctx context.Context) error {
var deviceID, taskID, authorizationID, attemptID string var deviceID, taskID, authorizationID, attemptID string
var generation, authorizationTaskVersion, quantity int var generation, authorizationTaskVersion, quantity int
var nonce, storedHash []byte var nonce, storedHash []byte
var nonceType, hashType, goodsID, color, size, price, expires string var nonceType, hashType, title, goodsID, color, size, price, expires string
var nonceLength, hashLength int var nonceLength, hashLength int
var closed, attemptStatus, authorizationStatus, taskStatus sql.NullString var closed, attemptStatus, authorizationStatus, taskStatus sql.NullString
var attemptGeneration sql.NullInt64 var attemptGeneration sql.NullInt64
if err := rows.Scan(&deviceID, &taskID, &authorizationID, &attemptID, &generation, if err := rows.Scan(&deviceID, &taskID, &authorizationID, &attemptID, &generation,
&nonce, &nonceType, &nonceLength, &storedHash, &hashType, &hashLength, &nonce, &nonceType, &nonceLength, &storedHash, &hashType, &hashLength,
&authorizationTaskVersion, &goodsID, &color, &size, &quantity, &price, &expires, &closed, &title, &authorizationTaskVersion, &goodsID, &color, &size, &quantity, &price, &expires, &closed,
&attemptGeneration, &attemptStatus, &authorizationStatus, &taskStatus); err != nil { &attemptGeneration, &attemptStatus, &authorizationStatus, &taskStatus); err != nil {
return errors.New("validate stored task claims") return errors.New("validate stored task claims")
} }
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(taskID) || !validUUID(authorizationID) || !validUUID(attemptID) || if !deviceauth.ValidDeviceID(deviceID) || !validUUID(taskID) || !validUUID(authorizationID) || !validUUID(attemptID) ||
generation <= 0 || nonceType != "blob" || nonceLength != sha256.Size || len(nonce) != sha256.Size || generation <= 0 || nonceType != "blob" || nonceLength != sha256.Size || len(nonce) != sha256.Size ||
hashType != "blob" || hashLength != sha256.Size || len(storedHash) != sha256.Size || hashType != "blob" || hashLength != sha256.Size || len(storedHash) != sha256.Size ||
authorizationTaskVersion <= 0 || !digitsOnly(goodsID) || color == "" || size == "" || quantity <= 0 || authorizationTaskVersion <= 0 || !taskmodel.ValidTaskWireFields(title, goodsID, color, size, price) || quantity <= 0 ||
!canonicalMoney(price) || !validCanonicalTime(expires) || (closed.Valid && !validCanonicalTime(closed.String)) || !validCanonicalTime(expires) || (closed.Valid && !validCanonicalTime(closed.String)) ||
!attemptGeneration.Valid || attemptGeneration.Int64 != int64(generation) || !attemptGeneration.Valid || attemptGeneration.Int64 != int64(generation) ||
!validAttemptStatus(attemptStatus) || !validAuthorizationStatus(authorizationStatus) || !validTaskStatus(taskStatus) { !validAttemptStatus(attemptStatus) || !validAuthorizationStatus(authorizationStatus) || !validTaskStatus(taskStatus) {
return errors.New("stored task claim metadata is invalid") return errors.New("stored task claim metadata is invalid")
@@ -577,9 +577,9 @@ func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) {
if !validUUID(record.AttemptID) || !validUUID(record.TaskID) || !validUUID(record.AuthorizationID) || if !validUUID(record.AttemptID) || !validUUID(record.TaskID) || !validUUID(record.AuthorizationID) ||
!deviceauth.ValidDeviceID(record.DeviceID) || !validUUID(record.SessionID) || record.Generation <= 0 || !deviceauth.ValidDeviceID(record.DeviceID) || !validUUID(record.SessionID) || record.Generation <= 0 ||
record.CurrentAttemptGeneration != record.Generation || record.CurrentAttemptGeneration != record.Generation ||
record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 || strings.TrimSpace(record.TaskTitle) == "" || record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 ||
!digitsOnly(record.GoodsID) || record.SKUColor == "" || record.SKUSize == "" || record.Quantity <= 0 || !taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) ||
!canonicalMoney(record.TotalPriceCap) || len(record.Nonce) != sha256.Size || len(record.TokenHash) != sha256.Size { record.Quantity <= 0 || len(record.Nonce) != sha256.Size || len(record.TokenHash) != sha256.Size {
return claimRecord{}, false, errors.New("stored task claim metadata is invalid") return claimRecord{}, false, errors.New("stored task claim metadata is invalid")
} }
record.LeaseExpiresAt, err = parseCanonicalTime(record.LeaseExpiresText) record.LeaseExpiresAt, err = parseCanonicalTime(record.LeaseExpiresText)
@@ -602,7 +602,11 @@ func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) {
} }
func (record claimRecord) authorizationConsistent() bool { func (record claimRecord) authorizationConsistent() bool {
return record.AuthorizationTaskVersion == record.CurrentAuthorizationTaskVersion && return taskmodel.ValidAuthorizationFields(record.CurrentGoodsID, record.CurrentSKUColor,
record.CurrentSKUSize, record.CurrentTotalPriceCap) &&
taskmodel.ValidTaskWireFields(record.CurrentTaskTitle, record.CurrentTaskGoodsID,
record.CurrentTaskSKUColor, record.CurrentTaskSKUSize, record.CurrentTaskMaxTotalPrice) &&
record.AuthorizationTaskVersion == record.CurrentAuthorizationTaskVersion &&
record.GoodsID == record.CurrentGoodsID && record.SKUColor == record.CurrentSKUColor && record.GoodsID == record.CurrentGoodsID && record.SKUColor == record.CurrentSKUColor &&
record.SKUSize == record.CurrentSKUSize && record.Quantity == record.CurrentQuantity && record.SKUSize == record.CurrentSKUSize && record.Quantity == record.CurrentQuantity &&
record.TotalPriceCap == record.CurrentTotalPriceCap && record.TotalPriceCap == record.CurrentTotalPriceCap &&
@@ -623,8 +627,12 @@ func (record claimRecord) recoverableBusinessState() bool {
} }
func (store *Store) responseFor(record claimRecord, responseLease string) (ClaimResponse, error) { func (store *Store) responseFor(record claimRecord, responseLease string) (ClaimResponse, error) {
if !validCanonicalTime(responseLease) { // Exact idempotent replay is allowed to ignore later source-row drift, but the
return ClaimResponse{}, errors.New("stored claim response lease is invalid") // immutable response snapshot itself must still satisfy the current wire bounds.
if !validCanonicalTime(responseLease) ||
!taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) ||
record.Quantity <= 0 {
return ClaimResponse{}, errors.New("stored claim response snapshot is invalid")
} }
token := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce) token := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce)
return ClaimResponse{ return ClaimResponse{
@@ -639,6 +647,9 @@ func (store *Store) responseFor(record claimRecord, responseLease string) (Claim
type candidate struct { type candidate struct {
AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string
TaskVersion, Quantity int TaskVersion, Quantity int
AuthorizationTaskVersion, AuthorizationQuantity int
AuthorizationGoodsID, AuthorizationSKUColor, AuthorizationSKUSize string
AuthorizationTotalPriceCap string
AuthorizationExpiresText string AuthorizationExpiresText string
AuthorizationExpiresAt time.Time AuthorizationExpiresAt time.Time
} }
@@ -646,16 +657,12 @@ type candidate struct {
func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (candidate, bool, error) { func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (candidate, bool, error) {
rows, err := transaction.QueryContext(ctx, `SELECT authorizations.id, tasks.id, tasks.version, rows, err := transaction.QueryContext(ctx, `SELECT authorizations.id, tasks.id, tasks.version,
tasks.title, tasks.goods_id, tasks.sku_color, tasks.sku_size, tasks.quantity, tasks.title, tasks.goods_id, tasks.sku_color, tasks.sku_size, tasks.quantity,
tasks.max_total_price, authorizations.expires_at tasks.max_total_price, authorizations.task_version, authorizations.goods_id,
authorizations.sku_color, authorizations.sku_size, authorizations.quantity,
authorizations.total_price_cap, authorizations.expires_at
FROM order_authorizations AS authorizations FROM order_authorizations AS authorizations
JOIN tasks ON tasks.id = authorizations.task_id JOIN tasks ON tasks.id = authorizations.task_id
WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING' WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING'
AND authorizations.task_version = tasks.version
AND authorizations.goods_id = tasks.goods_id
AND authorizations.sku_color = tasks.sku_color
AND authorizations.sku_size = tasks.sku_size
AND authorizations.quantity = tasks.quantity
AND authorizations.total_price_cap = tasks.max_total_price
ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`) ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`)
if err != nil { if err != nil {
return candidate{}, false, err return candidate{}, false, err
@@ -665,6 +672,8 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
var item candidate var item candidate
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title, if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title,
&item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.TotalPriceCap, &item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.TotalPriceCap,
&item.AuthorizationTaskVersion, &item.AuthorizationGoodsID, &item.AuthorizationSKUColor,
&item.AuthorizationSKUSize, &item.AuthorizationQuantity, &item.AuthorizationTotalPriceCap,
&item.AuthorizationExpiresText); err != nil { &item.AuthorizationExpiresText); err != nil {
return candidate{}, false, err return candidate{}, false, err
} }
@@ -675,6 +684,9 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
if !validCandidate(item) { if !validCandidate(item) {
return candidate{}, false, errors.New("stored claim candidate is invalid") return candidate{}, false, errors.New("stored claim candidate is invalid")
} }
if !candidateSnapshotMatches(item) {
continue
}
if item.AuthorizationExpiresAt.After(now) { if item.AuthorizationExpiresAt.After(now) {
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return candidate{}, false, err return candidate{}, false, err
@@ -690,8 +702,16 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
func validCandidate(item candidate) bool { func validCandidate(item candidate) bool {
return validUUID(item.AuthorizationID) && validUUID(item.TaskID) && item.TaskVersion > 0 && item.TaskVersion < math.MaxInt && return validUUID(item.AuthorizationID) && validUUID(item.TaskID) && item.TaskVersion > 0 && item.TaskVersion < math.MaxInt &&
strings.TrimSpace(item.Title) != "" && digitsOnly(item.GoodsID) && item.SKUColor != "" && item.SKUSize != "" && taskmodel.ValidTaskWireFields(item.Title, item.GoodsID, item.SKUColor, item.SKUSize, item.TotalPriceCap) &&
item.Quantity > 0 && canonicalMoney(item.TotalPriceCap) item.Quantity > 0 && item.AuthorizationTaskVersion > 0 && item.AuthorizationTaskVersion < math.MaxInt &&
taskmodel.ValidAuthorizationFields(item.AuthorizationGoodsID, item.AuthorizationSKUColor,
item.AuthorizationSKUSize, item.AuthorizationTotalPriceCap) && item.AuthorizationQuantity > 0
}
func candidateSnapshotMatches(item candidate) bool {
return item.AuthorizationTaskVersion == item.TaskVersion && item.AuthorizationGoodsID == item.GoodsID &&
item.AuthorizationSKUColor == item.SKUColor && item.AuthorizationSKUSize == item.SKUSize &&
item.AuthorizationQuantity == item.Quantity && item.AuthorizationTotalPriceCap == item.TotalPriceCap
} }
func validAttemptStatus(value sql.NullString) bool { func validAttemptStatus(value sql.NullString) bool {
@@ -798,33 +818,6 @@ func validUUID(value string) bool {
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b') return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
} }
func digitsOnly(value string) bool {
if value == "" {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func canonicalMoney(value string) bool {
parts := strings.Split(value, ".")
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
return false
}
for _, part := range parts {
if !digitsOnly(part) {
return false
}
}
cents := new(big.Int)
_, ok := cents.SetString(parts[0]+parts[1], 10)
return ok && cents.Sign() > 0
}
func productURL(goodsID string) string { func productURL(goodsID string) string {
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
} }
+105
View File
@@ -193,6 +193,104 @@ func TestClaimRollsBackEveryBusinessMutationOnLateFailure(t *testing.T) {
} }
} }
func TestClaimRejectsOutOfBoundsCandidatesWithoutBusinessMutation(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB){
"task invalid utf8 title": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), testTaskA)
},
"task overlong title": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", 121), testTaskA)
},
"task overlong goods id": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET goods_id=? WHERE id=?`, strings.Repeat("1", 33), testTaskA)
},
"task invalid utf8 color": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, string([]byte{0xff}), testTaskA)
},
"task overlong color": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", 81), testTaskA)
},
"task overlong size": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", 81), testTaskA)
},
"task overlong money": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET max_total_price=? WHERE id=?`, strings.Repeat("1", 30)+".00", testTaskA)
},
"authorization overlong goods id": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE id=?`, strings.Repeat("1", 33), testAuthA)
},
"authorization invalid utf8 color": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, string([]byte{0xff}), testAuthA)
},
"authorization overlong color": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, strings.Repeat("色", 81), testAuthA)
},
"authorization overlong size": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE id=?`, strings.Repeat("码", 81), testAuthA)
},
"authorization overlong money": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE id=?`, strings.Repeat("1", 30)+".00", testAuthA)
},
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := openClaimTestDatabase(t)
insertDevice(t, database, testDeviceA, []byte("device-a"))
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true)
mutate(t, database)
store := mustStore(t, database, bytes.Repeat([]byte{0x32}, 32), 30*time.Second)
store.now = func() time.Time { return testNow }
if _, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}); err == nil || found {
t.Fatalf("ClaimNext = found %v, err %v; want closed failure", found, err)
}
assertClaimState(t, database, 0, "PENDING", "ACTIVE")
for _, table := range []string{"purchase_attempts", "task_claim_requests"} {
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 0 {
t.Fatalf("%s rows after invalid candidate = %d, err %v", table, count, err)
}
}
})
}
}
func TestClaimReplayRevalidatesImmutableResponseSnapshot(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB){
"title": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET task_title=? WHERE task_id=?`, strings.Repeat("😀", 121), testTaskA)
},
"goods id": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET goods_id=? WHERE task_id=?`, strings.Repeat("1", 33), testTaskA)
},
"color": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET sku_color=? WHERE task_id=?`, strings.Repeat("色", 81), testTaskA)
},
"size": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET sku_size=? WHERE task_id=?`, strings.Repeat("码", 81), testTaskA)
},
"money": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET total_price_cap=? WHERE task_id=?`, strings.Repeat("1", 30)+".00", testTaskA)
},
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := openClaimTestDatabase(t)
insertDevice(t, database, testDeviceA, []byte("device-a"))
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true)
store := mustStore(t, database, bytes.Repeat([]byte{0x34}, 32), 30*time.Second)
store.now = func() time.Time { return testNow }
command := ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}
if _, found, err := store.ClaimNext(context.Background(), testDeviceA, command); err != nil || !found {
t.Fatalf("initial ClaimNext = found %v, err %v", found, err)
}
mutate(t, database)
if _, found, err := store.ClaimNext(context.Background(), testDeviceA, command); err == nil || found {
t.Fatalf("replay = found %v, err %v; want invalid snapshot", found, err)
}
})
}
}
func TestClaimEligibilityStableOrderAndConcurrentUniqueness(t *testing.T) { func TestClaimEligibilityStableOrderAndConcurrentUniqueness(t *testing.T) {
database := openClaimTestDatabase(t) database := openClaimTestDatabase(t)
insertDevice(t, database, testDeviceA, []byte("device-a")) insertDevice(t, database, testDeviceA, []byte("device-a"))
@@ -849,6 +947,13 @@ func insertCandidate(t *testing.T, database *sql.DB, taskID, authorizationID str
} }
} }
func execClaimSQL(t *testing.T, database *sql.DB, statement string, arguments ...any) {
t.Helper()
if _, err := database.Exec(statement, arguments...); err != nil {
t.Fatalf("execute claim test SQL: %v", err)
}
}
func assertClaimState(t *testing.T, database *sql.DB, wantClaims int, wantTaskStatus, wantAuthorizationStatus string) { func assertClaimState(t *testing.T, database *sql.DB, wantClaims int, wantTaskStatus, wantAuthorizationStatus string) {
t.Helper() t.Helper()
var count int var count int
+26
View File
@@ -5,6 +5,9 @@ package taskclaim
import ( import (
"context" "context"
"errors" "errors"
"math"
taskmodel "cmbuyer/admin/internal/tasks"
) )
var ( var (
@@ -61,6 +64,29 @@ type ClaimResponse struct {
Attempt ClaimedAttempt `json:"attempt"` Attempt ClaimedAttempt `json:"attempt"`
} }
// ValidClaimResponse closes the service-to-HTTP boundary as well as the SQLite
// boundary. A fake or future Service implementation cannot bypass the same field
// limits enforced while creating and claiming the task.
func ValidClaimResponse(response ClaimResponse) bool {
authorizationExpires, authorizationErr := parseCanonicalTime(response.Authorization.ExpiresAt)
leaseExpires, leaseErr := parseCanonicalTime(response.Attempt.LeaseExpiresAt)
return validUUID(response.Task.ID) && response.Task.Version > 0 &&
response.Authorization.TaskVersion > 0 && response.Authorization.TaskVersion < math.MaxInt &&
response.Task.Version == response.Authorization.TaskVersion+1 &&
taskmodel.ValidTaskWireFields(response.Task.Title, response.Task.GoodsID,
response.Task.SKUColor, response.Task.SKUSize, response.Task.MaxTotalPrice) &&
response.Task.ProductURL == productURL(response.Task.GoodsID) && response.Task.Quantity > 0 &&
validUUID(response.Authorization.ID) && authorizationErr == nil &&
validUUID(response.Attempt.ID) && response.Attempt.ClaimGeneration > 0 &&
len(response.Attempt.ClaimToken) == 64 && tokenTextValid(response.Attempt.ClaimToken) &&
leaseErr == nil && !leaseExpires.After(authorizationExpires)
}
func tokenTextValid(value string) bool {
_, ok := decodeToken(value)
return ok
}
type RenewResponse struct { type RenewResponse struct {
TaskID string `json:"task_id"` TaskID string `json:"task_id"`
AttemptID string `json:"attempt_id"` AttemptID string `json:"attempt_id"`
+6 -1
View File
@@ -50,7 +50,7 @@ type TaskRow struct {
} }
func normalizeCents(value string) (string, *big.Int, bool) { func normalizeCents(value string) (string, *big.Int, bool) {
if value == "" || strings.TrimSpace(value) != value { if value == "" || len(value) > MaxMoneyASCIICharacters || strings.TrimSpace(value) != value {
return "", nil, false return "", nil, false
} }
parts := strings.Split(value, ".") parts := strings.Split(value, ".")
@@ -71,6 +71,11 @@ func normalizeCents(value string) (string, *big.Int, bool) {
return value, cents, true return value, cents, true
} }
func ValidCanonicalMoney(value string) bool {
canonical, _, ok := normalizeCents(value)
return ok && canonical == value
}
func startItems(command StartCommand) ([]StartItem, error) { func startItems(command StartCommand) ([]StartItem, error) {
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems { if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
return nil, ErrInvalidStart return nil, ErrInvalidStart
@@ -8,6 +8,7 @@ import (
"math" "math"
"reflect" "reflect"
"sort" "sort"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -164,12 +165,27 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
"nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { "nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='937x' WHERE id=?`, id) execTestSQL(t, store.database, `UPDATE tasks SET goods_id='937x' WHERE id=?`, id)
}, },
"overlong goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id=? WHERE id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
},
"invalid utf8 title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), id)
},
"overlong title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
},
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { "empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id) execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
}, },
"overlong color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
},
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { "empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id) execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
}, },
"overlong size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
},
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) { "quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxQuantity = 1 store.policy.MaxQuantity = 1
}, },
@@ -179,6 +195,9 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
"noncanonical leading zero": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { "noncanonical leading zero": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='012.80' WHERE id=?`, id) execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='012.80' WHERE id=?`, id)
}, },
"overlong canonical price": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price=? WHERE id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
},
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) { "price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxTotalPrice = "12.79" store.policy.MaxTotalPrice = "12.79"
}, },
@@ -199,6 +218,43 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
} }
} }
func TestStartPurchasesReplayRejectsMalformedAuthorizationOrTaskSnapshot(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB, string){
"authorization goods id": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE task_id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
},
"authorization color": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE task_id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
},
"authorization size": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE task_id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
},
"authorization money": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE task_id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
},
"task title": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
},
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}
if _, err := store.StartPurchases(context.Background(), command, "admin"); err != nil {
t.Fatal(err)
}
mutate(t, database, id)
if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("replay error = %v, want ErrStartConflict", err)
}
assertAuthorizationCount(t, database, 1)
})
}
}
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) { func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string
+25 -15
View File
@@ -142,7 +142,9 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
} }
return StartResult{}, err return StartResult{}, err
} }
if status != "DRAFT" || version != item.ExpectedTaskVersion || !goodsIDValid(goods) || color == "" || size == "" || quantity < 1 || quantity > store.policy.MaxQuantity { if status != "DRAFT" || version != item.ExpectedTaskVersion ||
!ValidTaskWireFields(title, goods, color, size, price) ||
quantity < 1 || quantity > store.policy.MaxQuantity {
return StartResult{}, ErrStartConflict return StartResult{}, ErrStartConflict
} }
canonical, cents, ok := normalizeCents(price) canonical, cents, ok := normalizeCents(price)
@@ -179,20 +181,15 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
return result, nil return result, nil
} }
func goodsIDValid(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) { func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) {
rows, err := tx.QueryContext(ctx, "SELECT id,task_id,task_version,expires_at FROM order_authorizations WHERE start_key=? ORDER BY task_id", startKey) rows, err := tx.QueryContext(ctx, `SELECT authorizations.id,authorizations.task_id,
authorizations.task_version,authorizations.expires_at,authorizations.goods_id,
authorizations.sku_color,authorizations.sku_size,authorizations.quantity,
authorizations.total_price_cap,tasks.title,tasks.goods_id,tasks.sku_color,
tasks.sku_size,tasks.quantity,tasks.max_total_price
FROM order_authorizations AS authorizations
JOIN tasks ON tasks.id = authorizations.task_id
WHERE authorizations.start_key=? ORDER BY authorizations.task_id`, startKey)
if err != nil { if err != nil {
return StartResult{}, false, err return StartResult{}, false, err
} }
@@ -201,9 +198,22 @@ func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []Start
for rows.Next() { for rows.Next() {
var item AuthorizedTask var item AuthorizedTask
var expires string var expires string
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires); err != nil { var authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice string
var taskTitle, taskGoodsID, taskColor, taskSize, taskPrice string
var authorizationQuantity, taskQuantity int
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires,
&authorizationGoodsID, &authorizationColor, &authorizationSize, &authorizationQuantity,
&authorizationPrice, &taskTitle, &taskGoodsID, &taskColor, &taskSize, &taskQuantity,
&taskPrice); err != nil {
return StartResult{}, false, err return StartResult{}, false, err
} }
if !ValidAuthorizationFields(authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice) ||
authorizationQuantity <= 0 ||
!ValidTaskWireFields(taskTitle, taskGoodsID, taskColor, taskSize, taskPrice) || taskQuantity <= 0 ||
authorizationGoodsID != taskGoodsID || authorizationColor != taskColor ||
authorizationSize != taskSize || authorizationQuantity != taskQuantity || authorizationPrice != taskPrice {
return StartResult{}, false, ErrStartConflict
}
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires) item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
if err != nil { if err != nil {
return StartResult{}, false, err return StartResult{}, false, err
+5
View File
@@ -37,6 +37,11 @@ func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
} }
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) { func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
// Validate again at the persistence boundary. HTTP form validation is not the only
// caller, and a malformed row here would later make an authorized claim unencodable.
if !validUUID(draft.ID) || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) || draft.Quantity <= 0 {
return Draft{}, ErrInvalidDraft
}
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout) writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
defer cancel() defer cancel()
// SQLite permits one writer at a time. Serializing this store's short create // SQLite permits one writer at a time. Serializing this store's short create
+66 -7
View File
@@ -9,14 +9,21 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
) )
const ( const (
maxTitleLength = 120 MaxTitleCodePoints = 120
maxSKUText = 80 MaxSKUTextCodePoints = 80
MaxGoodsIDCharacters = 32
MaxMoneyASCIICharacters = 32
maxSKUText = MaxSKUTextCodePoints
) )
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task") var (
ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
ErrInvalidDraft = errors.New("invalid draft")
)
type Draft struct { type Draft struct {
ID string ID string
@@ -41,13 +48,13 @@ func Validate(form Form) (Draft, Errors) {
if !validUUID(draft.ID) { if !validUUID(draft.ID) {
errors["create_key"] = "创建请求已过期,请重新打开表单。" errors["create_key"] = "创建请求已过期,请重新打开表单。"
} }
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength { if !validBoundedText(draft.Title, MaxTitleCodePoints) {
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。" errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
} }
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText { if !validBoundedText(draft.SKUColor, MaxSKUTextCodePoints) {
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。" errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
} }
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText { if !validBoundedText(draft.SKUSize, MaxSKUTextCodePoints) {
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。" errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
} }
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL)) goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
@@ -93,6 +100,9 @@ func CanonicalGoodsID(value string) (string, bool) {
return "", false return "", false
} }
} }
if !ValidGoodsID(goodsIDs[0]) {
return "", false
}
return goodsIDs[0], true return goodsIDs[0], true
} }
@@ -155,5 +165,54 @@ func normalizeMoney(value string) (string, bool) {
if whole == "0" && strings.Trim(fraction, "0") == "" { if whole == "0" && strings.Trim(fraction, "0") == "" {
return "", false return "", false
} }
return whole + "." + (fraction + "00")[:2], true canonical := whole + "." + (fraction + "00")[:2]
if len(canonical) > MaxMoneyASCIICharacters {
return "", false
}
return canonical, true
}
// ValidTaskWireFields is shared by creation, authorization and claim. Keeping one
// bounded domain prevents a database row from being valid in one stage but impossible
// to encode inside the fixed claim response budget in another stage.
func ValidTaskWireFields(title, goodsID, skuColor, skuSize, maxTotalPrice string) bool {
return validBoundedText(title, MaxTitleCodePoints) &&
ValidAuthorizationFields(goodsID, skuColor, skuSize, maxTotalPrice)
}
func ValidAuthorizationFields(goodsID, skuColor, skuSize, totalPriceCap string) bool {
return ValidGoodsID(goodsID) &&
validBoundedText(skuColor, MaxSKUTextCodePoints) &&
validBoundedText(skuSize, MaxSKUTextCodePoints) &&
ValidCanonicalMoney(totalPriceCap)
}
func ValidGoodsID(value string) bool {
if value == "" || len(value) > MaxGoodsIDCharacters {
return false
}
for index := 0; index < len(value); index++ {
if value[index] < '0' || value[index] > '9' {
return false
}
}
return true
}
func validBoundedText(value string, maximum int) bool {
// RuneCountInString replaces malformed byte sequences with RuneError. Validate first
// so corrupt SQLite text cannot consume the code-point budget as if it were legitimate.
if !utf8.ValidString(value) || value == "" || strings.TrimSpace(value) != value ||
utf8.RuneCountInString(value) > maximum {
return false
}
for _, character := range value {
// Python str.strip treats these four C0 separators as whitespace while Go
// TrimSpace does not. Reject them everywhere so both wire models have one
// explicit persisted-text domain instead of runtime-dependent trimming.
if character >= '\u001c' && character <= '\u001f' {
return false
}
}
return true
} }
+81 -2
View File
@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -39,7 +40,10 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"} base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
for name, update := range map[string]func(*Form){ for name, update := range map[string]func(*Form){
"empty title": func(form *Form) { form.Title = " " }, "empty title": func(form *Form) { form.Title = " " },
"invalid utf8 title": func(form *Form) { form.Title = string([]byte{0xff}) },
"long title": func(form *Form) { form.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) }, "long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
"invalid utf8 size": func(form *Form) { form.SKUSize = string([]byte{0xff}) },
"fraction quantity": func(form *Form) { form.Quantity = "1.5" }, "fraction quantity": func(form *Form) { form.Quantity = "1.5" },
"zero quantity": func(form *Form) { form.Quantity = "0" }, "zero quantity": func(form *Form) { form.Quantity = "0" },
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" }, "too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
@@ -67,6 +71,7 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2", "https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad", "https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
"https://mobile.yangkeduo.com/other.html?goods_id=1", "https://mobile.yangkeduo.com/other.html?goods_id=1",
"https://mobile.yangkeduo.com/goods.html?goods_id=" + strings.Repeat("1", MaxGoodsIDCharacters+1),
} { } {
if _, ok := CanonicalGoodsID(value); ok { if _, ok := CanonicalGoodsID(value); ok {
t.Fatalf("CanonicalGoodsID accepted %q", value) t.Fatalf("CanonicalGoodsID accepted %q", value)
@@ -75,19 +80,62 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
} }
func TestNormalizeMoneyBoundaries(t *testing.T) { func TestNormalizeMoneyBoundaries(t *testing.T) {
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} { maximum := strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00"
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00", maximum: maximum} {
got, ok := normalizeMoney(value) got, ok := normalizeMoney(value)
if !ok || got != want { if !ok || got != want {
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want) t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
} }
} }
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1"} { for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1", strings.Repeat("9", MaxMoneyASCIICharacters-2) + ".00"} {
if got, ok := normalizeMoney(value); ok { if got, ok := normalizeMoney(value); ok {
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got) t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
} }
} }
} }
func TestValidateAcceptsWorstLegalUnicodeFieldBounds(t *testing.T) {
goodsID := strings.Repeat("1", MaxGoodsIDCharacters)
draft, validation := Validate(Form{
CreateKey: testKey, Title: strings.Repeat("😀", MaxTitleCodePoints),
ProductURL: CanonicalURL(goodsID), SKUColor: strings.Repeat("色", MaxSKUTextCodePoints),
SKUSize: strings.Repeat("码", MaxSKUTextCodePoints), Quantity: "1",
MaxTotalPrice: strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00",
})
if !validation.Valid() || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) {
t.Fatalf("worst legal draft = %#v, validation = %#v", draft, validation)
}
}
func TestPersistedTextHasRuntimeIndependentC0AndNBSPDomain(t *testing.T) {
for name, invalid := range map[string]string{
"c0 prefix": "\u001cvalue",
"c0 suffix": "value\u001f",
"c0 interior": "value\u001dinside",
"nbsp prefix": "\u00a0value",
"nbsp suffix": "value\u00a0",
} {
t.Run(name, func(t *testing.T) {
if validBoundedText(invalid, MaxTitleCodePoints) {
t.Fatalf("validBoundedText(%q) accepted runtime-dependent text", invalid)
}
})
}
if !validBoundedText("left\u00a0right", MaxTitleCodePoints) {
t.Fatal("interior NBSP must remain a valid Unicode code point")
}
// Manual form input is normalized with Go TrimSpace before persistence.
draft, validation := Validate(Form{
CreateKey: testKey, Title: "\u00a0title\u00a0",
ProductURL: CanonicalURL("1"), SKUColor: "\u00a0black\u00a0",
SKUSize: "\u00a0M\u00a0", Quantity: "1", MaxTotalPrice: "1",
})
if !validation.Valid() || draft.Title != "title" || draft.SKUColor != "black" || draft.SKUSize != "M" {
t.Fatalf("NBSP form normalization = %#v, errors = %#v", draft, validation)
}
}
func TestNewCreateKeyIsUUIDv4(t *testing.T) { func TestNewCreateKeyIsUUIDv4(t *testing.T) {
key, err := NewCreateKey() key, err := NewCreateKey()
if err != nil { if err != nil {
@@ -157,6 +205,37 @@ func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
} }
} }
func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) {
mutations := map[string]func(*Draft){
"untrimmed title": func(draft *Draft) { draft.Title = " title" },
"c0 interior title": func(draft *Draft) { draft.Title = "title\u001dhidden" },
"invalid utf8 title": func(draft *Draft) { draft.Title = string([]byte{0xff}) },
"long title": func(draft *Draft) { draft.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
"long color": func(draft *Draft) { draft.SKUColor = strings.Repeat("色", MaxSKUTextCodePoints+1) },
"long size": func(draft *Draft) { draft.SKUSize = strings.Repeat("码", MaxSKUTextCodePoints+1) },
"long goods id": func(draft *Draft) { draft.GoodsID = strings.Repeat("1", MaxGoodsIDCharacters+1) },
"long money": func(draft *Draft) { draft.MaxTotalPrice = strings.Repeat("1", MaxMoneyASCIICharacters-2) + ".00" },
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
draft := testDraft(testKey, "title")
mutate(&draft)
if _, err := store.CreateDraft(context.Background(), draft); !errors.Is(err, ErrInvalidDraft) {
t.Fatalf("CreateDraft error = %v, want ErrInvalidDraft", err)
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM tasks").Scan(&count); err != nil || count != 0 {
t.Fatalf("tasks after invalid create = %d, err %v", count, err)
}
})
}
}
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) { func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
database := migratedDatabase(t) database := migratedDatabase(t)
store, err := NewSQLiteStore(database) store, err := NewSQLiteStore(database)
@@ -0,0 +1,112 @@
"""采集 T-106 人工准备的确认页四态证据;不执行页面操作。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.order_confirm_spike import (
Android16ForegroundReader,
DECLARED_STATES,
EXPECTED_GOODS_ID,
OrderConfirmEvidenceCapturer,
OrderConfirmEvidenceError,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集 T-106 人工准备的确认页四态本机证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True, help="T-106 已批准的目标商品标识。")
parser.add_argument(
"--state",
required=True,
choices=DECLARED_STATES,
help=(
"人工声明状态:Gate2 后导航来源、确认页 Gate3、最终控件可见、"
"人工一次返回后的安全页。"
),
)
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与只读设备 RPC 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if (
type(arguments.serial) is not str
or not arguments.serial.strip()
or arguments.serial != arguments.serial.strip()
):
raise ValueError("必须显式提供非空 --serial。")
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--goods-id 不是 T-106 已批准目标。")
if type(arguments.state) is not str or arguments.state not in DECLARED_STATES:
raise ValueError("--state 必须是批准的人工声明状态。")
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
raise ValueError("--output-dir 必须是明确的全新目录。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
adb_runner = SubprocessAdbRunner(arguments.adb)
capturer = OrderConfirmEvidenceCapturer(
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
Android16ForegroundReader(adb_runner, arguments.timeout),
timeout_seconds=arguments.timeout,
)
try:
capturer.capture(
arguments.serial,
arguments.goods_id,
arguments.state,
arguments.output_dir,
)
except (DeviceConnectionError, OrderConfirmEvidenceError):
# 第三方异常可能带设备、页面正文或本机目录,命令行只输出固定摘要。
print("确认页四态只读取证失败:已停止,未发布本机证据目录。", file=sys.stderr)
return 1
except OSError:
print("确认页四态只读取证失败:无法发布本机证据目录。", file=sys.stderr)
return 1
print("确认页四态只读取证完成。")
print("人工复核:请在指定目录检查截图、XML、应用摘要和 manifest。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,120 @@
"""采集 T-105 人工准备的数量 1/2 两态证据;不执行页面操作。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.quantity_gate2_spike import (
Android16TopResumedForegroundReader,
DECLARED_QUANTITIES,
EXPECTED_GOODS_ID,
QuantityGate2EvidenceCapturer,
QuantityGate2EvidenceError,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集 T-105 人工准备的数量两态本机证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True, help="T-105 已批准的目标商品标识。")
parser.add_argument(
"--state",
required=True,
choices=sorted(DECLARED_QUANTITIES),
help="人工声明状态:initial=数量1,target=数量2。",
)
parser.add_argument(
"--declared-quantity",
required=True,
type=int,
help="人工看到的数量;必须与状态一致。",
)
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与只读设备 RPC 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if (
type(arguments.serial) is not str
or not arguments.serial.strip()
or arguments.serial != arguments.serial.strip()
):
raise ValueError("必须显式提供非空 --serial。")
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--goods-id 不是 T-105 已批准目标。")
if type(arguments.state) is not str or arguments.state not in DECLARED_QUANTITIES:
raise ValueError("--state 必须是批准的人工声明状态。")
if type(arguments.declared_quantity) is not int or (
arguments.declared_quantity != DECLARED_QUANTITIES[arguments.state]
):
raise ValueError("--declared-quantity 必须与人工声明状态一致。")
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
raise ValueError("--output-dir 必须是明确的全新目录。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
adb_runner = SubprocessAdbRunner(arguments.adb)
capturer = QuantityGate2EvidenceCapturer(
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
Android16TopResumedForegroundReader(adb_runner, arguments.timeout),
timeout_seconds=arguments.timeout,
)
try:
capturer.capture(
arguments.serial,
arguments.goods_id,
arguments.state,
arguments.declared_quantity,
arguments.output_dir,
)
except (DeviceConnectionError, QuantityGate2EvidenceError):
# 第三方异常可能含 serial、Activity、本机路径或页面正文,CLI 只输出固定摘要。
print("数量两态只读取证失败:已停止,未发布本机证据目录。", file=sys.stderr)
return 1
except OSError:
print("数量两态只读取证失败:无法发布本机证据目录。", file=sys.stderr)
return 1
print("数量两态只读取证完成。")
print("人工复核:请在指定目录检查截图、XML、应用摘要和 manifest。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+85
View File
@@ -0,0 +1,85 @@
"""采集 T-104 阶段 A 的一次 Back 后本机证据。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.sku_selection import SkuSelectionError
from cmbuyer_client.pdd.sku_selection_runner import (
SkuExitSpikeCapturer,
SkuSelectionRunError,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集 T-104 阶段 A 的一次 Back 后本机证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与设备 RPC 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if type(arguments.serial) is not str or not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
raise ValueError("--output-dir 必须是明确的全新目录。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
capturer = SkuExitSpikeCapturer(
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
timeout_seconds=arguments.timeout,
)
try:
capturer.capture(arguments.serial, arguments.output_dir)
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError):
# 不回显第三方异常、serial、页面正文或本机路径。
print("规格安全退出取证失败:已停止,未发布本地证据目录。", file=sys.stderr)
return 1
except OSError:
print("规格安全退出取证失败:无法发布本地证据目录。", file=sys.stderr)
return 1
print("规格安全退出取证完成。")
print("人工复核:请在指定目录检查退出后截图、XML、应用摘要和 manifest。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+108
View File
@@ -0,0 +1,108 @@
"""采集 T-103 一次性尺码 reveal 的 before/after 本机证据。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.sku_reveal_spike import (
safe_reveal_failure_stage,
SkuRevealSpikeCapturer,
SkuRevealSpikeError,
)
from cmbuyer_client.pdd.sku_selection import (
EXPECTED_GOODS_ID,
_safe_sku_entry_failure_stage,
SkuSelectionError,
)
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集 T-103 一次性尺码 reveal 的本机证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True, help="固定 T-103 已取证 goods_id。")
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
parser.add_argument("--timeout", type=float, default=30.0, help="整趟动作与只读调和时限(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if type(arguments.serial) is not str or not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--goods-id 不是 T-103 已取证商品。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
def _safe_capture_failure_stage(error: BaseException) -> str:
# 入口与 reveal 各有不可伪造的正式 marker;入口优先,不能被外层 reveal 标注覆盖。
entry_stage = _safe_sku_entry_failure_stage(error)
if entry_stage is not None:
return entry_stage
return safe_reveal_failure_stage(error) or "unknown"
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
capturer = SkuRevealSpikeCapturer(
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
timeout_seconds=arguments.timeout,
)
try:
result = capturer.capture(
arguments.serial,
arguments.goods_id,
arguments.output_dir,
)
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError) as error:
# 不回显页面正文、节点、serial、坐标、路径或第三方异常。
print(
f"规格 reveal 取证失败:stage={_safe_capture_failure_stage(error)};已停止,未发布本地证据目录。",
file=sys.stderr,
)
return 1
except OSError:
print("规格 reveal 取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
return 1
print(f"规格 reveal 取证完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
print("人工复核:请保持手机不动,确认颜色仍选中、S/M 均未选且未进入提交页。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5 -2
View File
@@ -15,7 +15,7 @@ from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, Subproce
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError, TASK_TO_UI_SELECTION from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError, TASK_TO_UI_SELECTION
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner, safe_failure_stage
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
@@ -66,7 +66,10 @@ def main(argv: list[str] | None = None) -> int:
result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir) result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir)
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error: except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
# Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。 # Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
print("规格恢复失败:已停止,未发布本地证据目录。", file=sys.stderr) print(
f"规格恢复失败:stage={safe_failure_stage(error)};已停止,未发布本地证据目录。",
file=sys.stderr,
)
return 1 return 1
except OSError: except OSError:
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr) print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
+132
View File
@@ -0,0 +1,132 @@
"""运行 T-105 数量 1→2、Gate2 截图与一次安全退出。"""
from __future__ import annotations
import argparse
from datetime import datetime
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.quantity_gate2 import (
EXPECTED_GATE1_UNIT_PRICE,
EXPECTED_GOODS_ID,
TASK_COLOR,
TASK_SIZE,
Gate1Observation,
QuantityGate2Error,
)
from cmbuyer_client.pdd.quantity_gate2_runner import QuantityGate2Runner
from cmbuyer_client.pdd.quantity_gate2_spike import Android16TopResumedForegroundReader
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="运行 T-105 已取证数量与 Gate2 闭环。")
parser.add_argument("--serial", required=True, help="显式 ADB serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True)
parser.add_argument("--color", required=True)
parser.add_argument("--size", required=True)
parser.add_argument("--target-quantity", required=True, type=int)
parser.add_argument("--gate1-unit-price", required=True)
parser.add_argument("--gate1-screenshot", required=True, type=Path)
parser.add_argument("--gate1-captured-at", required=True)
parser.add_argument("--max-total-price", required=True)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--timeout", type=float, default=10.0)
parser.add_argument("--adb", default="adb")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> datetime:
if type(arguments.serial) is not str or not arguments.serial.strip() or arguments.serial != arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if arguments.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--goods-id 不是 T-105 已批准目标。")
if arguments.color != TASK_COLOR or arguments.size != TASK_SIZE:
raise ValueError("颜色或尺码不是 T-105 已批准目标。")
if type(arguments.target_quantity) is not int or arguments.target_quantity != 2:
raise ValueError("本次真机验收只批准 --target-quantity 2。")
if arguments.gate1_unit_price != EXPECTED_GATE1_UNIT_PRICE:
raise ValueError("--gate1-unit-price 与已确认 Gate1 不一致。")
if not isinstance(arguments.gate1_screenshot, Path) or not arguments.gate1_screenshot.is_file():
raise ValueError("--gate1-screenshot 必须是现有显式文件。")
try:
captured_at = datetime.fromisoformat(arguments.gate1_captured_at)
except (TypeError, ValueError) as error:
raise ValueError("--gate1-captured-at 必须是带时区 ISO 时间。") from error
if captured_at.utcoffset() is None:
raise ValueError("--gate1-captured-at 必须带时区。")
if arguments.max_total_price != "40.00":
raise ValueError("本次真机验收固定 --max-total-price 40.00。")
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name or arguments.output_dir.exists():
raise ValueError("--output-dir 必须是不存在的明确新目录。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
return captured_at
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
captured_at = validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少采购工具真机依赖。", file=sys.stderr)
return 2
adb_runner = SubprocessAdbRunner(arguments.adb)
runner = QuantityGate2Runner(
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
Android16TopResumedForegroundReader(adb_runner, arguments.timeout),
timeout_seconds=arguments.timeout,
)
gate1 = Gate1Observation(
color=arguments.color,
size=arguments.size,
quantity=1,
gate1_unit_price=arguments.gate1_unit_price,
screenshot_path=arguments.gate1_screenshot,
captured_at=captured_at,
)
try:
runner.run(
arguments.serial,
arguments.goods_id,
gate1,
arguments.target_quantity,
arguments.max_total_price,
arguments.output_dir,
)
except (DeviceConnectionError, QuantityGate2Error, OSError):
# 不回显第三方异常、serial、本机路径或页面正文。
print("T-105 数量/Gate2 运行失败:已停止,未发布证据目录。", file=sys.stderr)
return 1
print("T-105 数量/Gate2 运行完成。")
print("人工复核:数量 2、目标规格、12.88/32.76、原始截图和一次安全退出。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+43 -15
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
import sys import sys
from collections.abc import Sequence from collections.abc import Sequence
from .core.errors import StateError
from .logging_policy import configure_application_logger from .logging_policy import configure_application_logger
from .runtime import RuntimePaths from .runtime import LocalStateRuntime, RuntimePaths
def select_application_argv(argv: Sequence[str] | None) -> list[str]: def select_application_argv(argv: Sequence[str] | None) -> list[str]:
@@ -30,8 +31,7 @@ def main(argv: Sequence[str] | None = None) -> int:
return 1 return 1
try: try:
from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
except ImportError: except ImportError:
logger.error("缺少 PySide6,无法启动桌面界面。") logger.error("缺少 PySide6,无法启动桌面界面。")
print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr) print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr)
@@ -39,19 +39,47 @@ def main(argv: Sequence[str] | None = None) -> int:
application = QApplication.instance() or QApplication(select_application_argv(argv)) application = QApplication.instance() or QApplication(select_application_argv(argv))
application.setApplicationName("采购工具") application.setApplicationName("采购工具")
runtime: LocalStateRuntime | None = None
coordinator = None
try:
runtime = LocalStateRuntime.open(paths)
try:
summary = runtime.store.load_profile_summary("default")
except StateError as error:
if error.reason != "profile_not_found":
raise
summary = None
window = QMainWindow() from .polling.coordinator import PollingCoordinator
window.setWindowTitle("采购工具") from .ui.main_window import PurchaseToolWindow
window.setAccessibleName("采购工具")
window.setMinimumSize(420, 240)
window.resize(560, 320)
message = QLabel("应用骨架已初始化。\n采购执行功能尚未启用。") settings = None if summary is None else summary.settings
message.setAlignment(Qt.AlignmentFlag.AlignCenter) has_token = False if summary is None else summary.has_stored_device_token
message.setWordWrap(True) coordinator = PollingCoordinator(
message.setAccessibleName("当前状态") profile_id="default",
window.setCentralWidget(message) store=runtime.store,
gateway_factory=None,
logger.info("应用已启动;采购执行功能尚未启用。") consumer=None,
profile_settings=settings,
poll_interval_seconds=15 if settings is None else settings.poll_interval_seconds,
failure_threshold=3 if settings is None else settings.failure_threshold,
)
window = PurchaseToolWindow(
store=runtime.store,
coordinator=coordinator,
profile_settings=settings,
has_stored_device_token=has_token,
)
logger.info("应用已启动;单趟执行能力尚未接入,真实领取保持禁用。")
window.show() window.show()
return application.exec() return application.exec()
except (OSError, RuntimeError, StateError):
logger.error("无法打开采购工具本地安全状态。")
print("无法启动采购工具:本地安全状态不可用。", file=sys.stderr)
return 3
finally:
worker_stopped = True
if coordinator is not None:
worker_stopped = coordinator.shutdown()
if runtime is not None and worker_stopped:
runtime.close()
@@ -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",
]
+47
View File
@@ -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):
"""服务端要求人工处理的确定性冲突。"""
+328
View File
@@ -0,0 +1,328 @@
"""任务领取、续租和单张证据上传的不可变值对象。"""
from __future__ import annotations
from dataclasses import dataclass, field
import hashlib
from pathlib import Path
from .errors import ValidationError
from .validation import (
MAX_SKU_TEXT_CODE_POINTS,
MAX_TITLE_CODE_POINTS,
canonical_product_url,
require_exact_fields,
require_goods_id,
require_lower_hex_64,
require_money,
require_persisted_text,
require_positive_int,
require_rfc3339_z,
rfc3339_z_nanoseconds,
require_string,
require_uuid4,
)
EVIDENCE_KIND = "SKU_PANEL_GATE_1"
PRIVACY_TIER = "INTERNAL_RAW"
@dataclass(frozen=True, repr=False)
class SecretToken:
"""64 位小写 token;repr 永不暴露明文。"""
value: str
def __post_init__(self) -> None:
require_lower_hex_64(self.value, "invalid_token")
def __repr__(self) -> str:
return "SecretToken([已隐藏])"
def __str__(self) -> str:
return "[已隐藏]"
@dataclass(frozen=True, repr=False)
class DeviceCredentials:
device_id: str
token: SecretToken
def __post_init__(self) -> None:
require_uuid4(self.device_id, "invalid_device_id")
if not isinstance(self.token, SecretToken):
raise ValidationError("invalid_device_token")
def __repr__(self) -> str:
return f"DeviceCredentials(device_id={self.device_id!r}, token=[已隐藏])"
@dataclass(frozen=True)
class ClaimRequest:
session_id: str
claim_request_id: str
def __post_init__(self) -> None:
require_uuid4(self.session_id, "invalid_session_id")
require_uuid4(self.claim_request_id, "invalid_claim_request_id")
def to_wire(self) -> dict[str, object]:
return {"session_id": self.session_id, "claim_request_id": self.claim_request_id}
@dataclass(frozen=True)
class PurchaseTask:
id: str
version: int
title: str
product_url: str
goods_id: str
sku_color: str
sku_size: str
quantity: int
max_total_price: str
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_task_id")
require_positive_int(self.version, "invalid_task_version")
require_persisted_text(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS)
require_goods_id(self.goods_id)
if self.product_url != canonical_product_url(self.goods_id):
raise ValidationError("invalid_product_url")
require_persisted_text(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS)
require_persisted_text(self.sku_size, "invalid_sku_size", maximum=MAX_SKU_TEXT_CODE_POINTS)
require_positive_int(self.quantity, "invalid_quantity")
require_money(self.max_total_price, "invalid_max_total_price")
@classmethod
def from_wire(cls, value: object) -> "PurchaseTask":
data = require_exact_fields(
value,
("id", "version", "title", "product_url", "goods_id", "sku_color", "sku_size", "quantity", "max_total_price"),
)
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True)
class AuthorizationSnapshot:
id: str
task_version: int
expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_authorization_id")
require_positive_int(self.task_version, "invalid_authorization_task_version")
require_rfc3339_z(self.expires_at, "invalid_authorization_expiry")
@classmethod
def from_wire(cls, value: object) -> "AuthorizationSnapshot":
data = require_exact_fields(value, ("id", "task_version", "expires_at"))
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True)
class AttemptSnapshot:
id: str
claim_token: SecretToken
claim_generation: int
lease_expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_attempt_id")
if not isinstance(self.claim_token, SecretToken):
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
require_positive_int(self.claim_generation, "invalid_claim_generation")
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
@classmethod
def from_wire(cls, value: object) -> "AttemptSnapshot":
data = require_exact_fields(value, ("id", "claim_token", "claim_generation", "lease_expires_at"))
return cls(
id=data["id"], # type: ignore[arg-type]
claim_token=SecretToken(data["claim_token"]), # type: ignore[arg-type]
claim_generation=data["claim_generation"], # type: ignore[arg-type]
lease_expires_at=data["lease_expires_at"], # type: ignore[arg-type]
)
@dataclass(frozen=True)
class ClaimedTask:
task: PurchaseTask
authorization: AuthorizationSnapshot
attempt: AttemptSnapshot = field(repr=False)
def __post_init__(self) -> None:
if self.task.version != self.authorization.task_version + 1:
raise ValidationError("task_authorization_version_mismatch")
if rfc3339_z_nanoseconds(self.attempt.lease_expires_at) > rfc3339_z_nanoseconds(self.authorization.expires_at):
raise ValidationError("claim_lease_exceeds_authorization")
@classmethod
def from_wire(cls, value: object) -> "ClaimedTask":
data = require_exact_fields(value, ("task", "authorization", "attempt"))
return cls(
task=PurchaseTask.from_wire(data["task"]),
authorization=AuthorizationSnapshot.from_wire(data["authorization"]),
attempt=AttemptSnapshot.from_wire(data["attempt"]),
)
@dataclass(frozen=True, repr=False)
class RenewRequest:
task_id: str
renew_request_id: str
session_id: str
attempt_id: str
claim_generation: int
claim_token: SecretToken
expected_lease_expires_at: str
authorization_expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.renew_request_id, "invalid_renew_request_id")
require_uuid4(self.session_id, "invalid_session_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_positive_int(self.claim_generation, "invalid_claim_generation")
if not isinstance(self.claim_token, SecretToken):
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
require_rfc3339_z(self.expected_lease_expires_at, "invalid_expected_lease_expiry")
require_rfc3339_z(self.authorization_expires_at, "invalid_authorization_expiry")
def __repr__(self) -> str:
return (
f"RenewRequest(task_id={self.task_id!r}, renew_request_id={self.renew_request_id!r}, "
"claim_token=[已隐藏])"
)
def to_wire(self) -> dict[str, object]:
return {
"renew_request_id": self.renew_request_id,
"session_id": self.session_id,
"attempt_id": self.attempt_id,
"claim_generation": self.claim_generation,
"claim_token": self.claim_token.value,
"expected_lease_expires_at": self.expected_lease_expires_at,
}
@dataclass(frozen=True)
class RenewResult:
task_id: str
attempt_id: str
claim_generation: int
lease_expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_positive_int(self.claim_generation, "invalid_claim_generation")
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
@classmethod
def from_wire(cls, value: object) -> "RenewResult":
data = require_exact_fields(value, ("task_id", "attempt_id", "claim_generation", "lease_expires_at"))
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True, repr=False)
class EvidenceUpload:
task_id: str
upload_key: str
attempt_id: str
sha256: str
captured_at: str
content: bytes = field(repr=False)
kind: str = EVIDENCE_KIND
privacy_tier: str = PRIVACY_TIER
width_px: int = field(init=False)
height_px: int = field(init=False)
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.upload_key, "invalid_upload_key")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_lower_hex_64(self.sha256, "invalid_evidence_sha256")
require_rfc3339_z(self.captured_at, "invalid_captured_at")
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
raise ValidationError("invalid_evidence_metadata")
if not isinstance(self.content, bytes) or not self.content or len(self.content) > 10 * 1024 * 1024:
raise ValidationError("invalid_evidence_size")
if len(self.content) < 24 or not self.content.startswith(b"\x89PNG\r\n\x1a\n") or self.content[12:16] != b"IHDR":
raise ValidationError("invalid_evidence_png")
width = int.from_bytes(self.content[16:20], "big")
height = int.from_bytes(self.content[20:24], "big")
if width <= 0 or height <= 0 or width > 8192 or height > 8192 or width * height > 16_777_216:
raise ValidationError("invalid_evidence_dimensions")
object.__setattr__(self, "width_px", width)
object.__setattr__(self, "height_px", height)
if hashlib.sha256(self.content).hexdigest() != self.sha256:
raise ValidationError("evidence_hash_mismatch")
def __repr__(self) -> str:
return (
f"EvidenceUpload(task_id={self.task_id!r}, upload_key={self.upload_key!r}, "
f"attempt_id={self.attempt_id!r}, byte_size={len(self.content)})"
)
@dataclass(frozen=True)
class AssetReceipt:
asset_id: str
task_id: str
attempt_id: str
kind: str
privacy_tier: str
sha256: str
byte_size: int
content_type: str
width_px: int
height_px: int
captured_at: str
def __post_init__(self) -> None:
require_uuid4(self.asset_id, "invalid_asset_id")
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
raise ValidationError("invalid_asset_metadata")
require_lower_hex_64(self.sha256, "invalid_asset_sha256")
require_positive_int(self.byte_size, "invalid_asset_byte_size")
if self.byte_size > 10 * 1024 * 1024 or self.content_type != "image/png":
raise ValidationError("invalid_asset_content")
width = require_positive_int(self.width_px, "invalid_asset_width")
height = require_positive_int(self.height_px, "invalid_asset_height")
if width > 8192 or height > 8192 or width * height > 16_777_216:
raise ValidationError("invalid_asset_dimensions")
require_rfc3339_z(self.captured_at, "invalid_captured_at")
@classmethod
def from_wire(cls, value: object) -> "AssetReceipt":
data = require_exact_fields(
value,
("asset_id", "task_id", "attempt_id", "kind", "privacy_tier", "sha256", "byte_size", "content_type", "width_px", "height_px", "captured_at"),
)
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True, repr=False)
class ScreenshotAsset:
"""调用方显式选择的唯一 PNG;路径不会进入 repr 或 HTTP。"""
path: Path = field(repr=False)
task_id: str
attempt_id: str
captured_at: str
kind: str = EVIDENCE_KIND
privacy_tier: str = PRIVACY_TIER
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_rfc3339_z(self.captured_at, "invalid_captured_at")
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
raise ValidationError("invalid_evidence_metadata")
def __repr__(self) -> str:
return f"ScreenshotAsset(task_id={self.task_id!r}, attempt_id={self.attempt_id!r}, path=[已隐藏])"
+17
View File
@@ -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,197 @@
"""客户端与服务端共享 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]+")
MAX_TITLE_CODE_POINTS = 120
MAX_SKU_TEXT_CODE_POINTS = 80
MAX_GOODS_ID_ASCII_CHARACTERS = 32
MAX_MONEY_ASCII_CHARACTERS = 32
# Go strings.TrimSpace uses Unicode White_Space plus the six ASCII space
# characters below, but unlike Python str.strip it does not include U+001C--
# U+001F. Keep the wire contract independent of either runtime's defaults.
GO_UNICODE_WHITE_SPACE = "\t\n\v\f\r \u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"
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_persisted_text(value: object, reason: str, *, maximum: int) -> str:
"""Validate text stored by Go after TrimSpace, without Python trim drift."""
text = require_string(value, reason, maximum=maximum)
if text.strip(GO_UNICODE_WHITE_SPACE) != text:
raise ValidationError(reason)
# Python str.strip treats these C0 separators as whitespace while Go does
# not. Reject them anywhere on both ends instead of assigning them two
# runtime-dependent meanings.
if any(0x1C <= ord(character) <= 0x1F for character in text):
raise ValidationError(reason)
return text
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=MAX_MONEY_ASCII_CHARACTERS)
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=MAX_GOODS_ID_ASCII_CHARACTERS)
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,98 @@
"""供 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 ProfileSummary:
"""不解密、不返回任何 token 数据的配置页只读摘要。"""
settings: ProfileSettings
has_stored_device_token: bool
def __post_init__(self) -> None:
if type(self.has_stored_device_token) is not bool:
raise ValueError("invalid_token_presence")
@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
+16 -2
View File
@@ -23,6 +23,8 @@ _KEY_VALUE_PATTERN = re.compile(
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)") _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: 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: def replace_key_value(match: re.Match[str]) -> str:
return f"{match.group('key')}{match.group('separator')}{REDACTED}" 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) return _PHONE_PATTERN.sub(REDACTED, redacted)
@@ -47,6 +51,16 @@ class SensitiveDataFilter(logging.Filter):
return True 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: def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。""" """配置唯一的 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 = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
handler.addFilter(SensitiveDataFilter()) 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) logger.addHandler(handler)
return logger return logger
@@ -0,0 +1,345 @@
"""T-106 确认页四态的纯只读取证;页面对应性只由人工确认。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from hashlib import sha256
import json
from math import isfinite
import os
from pathlib import Path
import re
import shutil
from typing import Any, Protocol
from uuid import uuid4
from adbutils.errors import AdbTimeout
from PIL import Image, UnidentifiedImageError
from uiautomator2.exceptions import HTTPTimeoutError
from ..device.adb import AdbClient, CommandRunner, DeviceConnectionError, DeviceInspection
from ..device.baseline import (
HIERARCHY_PARAMS,
PDD_PACKAGE,
SCREENSHOT_PARAMS,
_save_base64_screenshot,
_sha256_file,
_validate_hierarchy,
)
from .product_open import EXPECTED_PDD_VERSION
from .product_url import parse_product_url
EXPECTED_GOODS_ID = "937122477375"
EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREEN_SIZE = (1080, 2376)
DECLARED_STATES = (
"gate2-navigation-source",
"confirm-gate3",
"submit-control-visible",
"returned-safe-page",
)
class OrderConfirmEvidenceError(RuntimeError):
"""T-106 四态证据未形成完整原子产物。"""
class OrderConfirmEvidenceTimeoutError(OrderConfirmEvidenceError):
"""只读取证设备调用超时。"""
class OrderConfirmReadDevice(Protocol):
"""T-106 唯一设备边界;故意只有读取能力。"""
def app_info(self, package_name: str) -> dict[str, Any]: ...
def window_size(self) -> tuple[int, int]: ...
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: ...
class OrderConfirmForegroundReader(Protocol):
"""读取 Android 前台摘要,不暴露通用命令执行。"""
def read(self, serial: str) -> dict[str, str]: ...
class Android16ForegroundReader:
"""读取 Android 16 的唯一 top-resumed Activity。"""
_TOP_RESUMED_PATTERN = re.compile(
r"(?m)^\s*topResumedActivity=ActivityRecord\{[^\r\n}]*?\s+u\d+\s+"
r"(?P<package>[^/\s]+)/(?P<activity>[^\s}]+)\s+t\d+\}\s*$"
)
def __init__(self, runner: CommandRunner, timeout_seconds: float) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._runner = runner
self._timeout_seconds = timeout_seconds
def read(self, serial: str) -> dict[str, str]:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise OrderConfirmEvidenceError("必须显式提供非空设备通道。")
result = self._runner.run(
("-s", serial, "shell", "dumpsys", "activity", "activities"),
self._timeout_seconds,
)
if result.returncode != 0:
raise OrderConfirmEvidenceError("Android 前台摘要读取失败,未发布证据。")
matches = list(self._TOP_RESUMED_PATTERN.finditer(result.stdout))
if len(matches) != 1:
raise OrderConfirmEvidenceError("Android 前台摘要不唯一,未发布证据。")
match = matches[0]
return {"package": match.group("package"), "activity": match.group("activity")}
@dataclass(frozen=True)
class OrderConfirmEvidenceResult:
output_directory: Path
manifest_path: Path
screenshot_path: Path
hierarchy_path: Path
app_path: Path
class OrderConfirmEvidenceCapturer:
"""采集人工准备的单个稳定状态,不解析页面业务字段。"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], OrderConfirmReadDevice],
foreground_reader: OrderConfirmForegroundReader,
timeout_seconds: float,
) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._adb_client = adb_client
self._connector = connector
self._foreground_reader = foreground_reader
self._timeout_seconds = timeout_seconds
self._started = False
def capture(
self,
serial: str,
goods_id: str,
human_declared_state: str,
output_directory: Path,
) -> OrderConfirmEvidenceResult:
if self._started:
raise OrderConfirmEvidenceError("同一取证器不可重复调用。")
self._started = True
_validate_inputs(serial, goods_id, human_declared_state)
target = Path(output_directory)
_validate_new_target(target)
staging: Path | None = None
try:
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
device = self._connector(serial)
initial_app = _require_read_precondition(
device,
self._foreground_reader.read(serial),
)
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
screenshot_path = staging / "screenshot.png"
screenshot_payload = _read_rpc(
device,
"takeScreenshot",
SCREENSHOT_PARAMS,
self._timeout_seconds,
)
if not isinstance(screenshot_payload, str):
raise OrderConfirmEvidenceError("页面截图无效,未发布证据。")
_save_base64_screenshot(screenshot_payload, screenshot_path)
_require_screenshot(screenshot_path)
hierarchy = _read_rpc(
device,
"dumpWindowHierarchy",
HIERARCHY_PARAMS,
self._timeout_seconds,
)
_validate_hierarchy(hierarchy)
hierarchy_path = staging / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
final_app = _require_read_precondition(
device,
self._foreground_reader.read(serial),
)
if final_app != initial_app:
raise OrderConfirmEvidenceError("取证期间前台页面漂移,未发布证据。")
app_path = staging / "app.json"
app_path.write_text(
json.dumps(final_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_manifest(
inspection,
serial,
goods_id,
human_declared_state,
screenshot_path,
hierarchy_path,
app_path,
),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.rename(staging, target)
staging = None
except (DeviceConnectionError, OrderConfirmEvidenceError):
_clean_staging(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
raise OrderConfirmEvidenceTimeoutError("确认页四态只读取证超时,未发布证据。") from error
except (OSError, UnidentifiedImageError, ValueError) as error:
_clean_staging(staging)
raise OrderConfirmEvidenceError("确认页四态证据无法原子发布,未发布证据。") from error
except Exception as error:
_clean_staging(staging)
raise OrderConfirmEvidenceError("确认页四态只读取证未完成,未发布证据。") from error
return OrderConfirmEvidenceResult(
output_directory=target,
manifest_path=target / "manifest.json",
screenshot_path=target / "screenshot.png",
hierarchy_path=target / "hierarchy.xml",
app_path=target / "app.json",
)
def _is_positive_finite(value: object) -> bool:
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and value > 0
and isfinite(value)
)
def _validate_inputs(serial: object, goods_id: object, state: object) -> None:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise OrderConfirmEvidenceError("必须显式提供非空设备通道。")
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise OrderConfirmEvidenceError("商品不是 T-106 已批准取证目标。")
parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}")
if type(state) is not str or state not in DECLARED_STATES:
raise OrderConfirmEvidenceError("人工声明状态无效。")
def _validate_new_target(target: Path) -> None:
if target.exists() or not target.name:
raise OrderConfirmEvidenceError("输出目录必须是不存在的明确新目录。")
def _require_expected_device(inspection: DeviceInspection) -> None:
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
raise OrderConfirmEvidenceError("设备不是已批准取证组合。")
def _require_read_precondition(
device: OrderConfirmReadDevice,
current: object,
) -> dict[str, str]:
info = device.app_info(PDD_PACKAGE)
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
if version != EXPECTED_PDD_VERSION:
raise OrderConfirmEvidenceError("拼多多版本不是已批准取证版本。")
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
raise OrderConfirmEvidenceError("拼多多不在前台。")
activity = current.get("activity")
if not isinstance(activity, str) or not activity.strip():
raise OrderConfirmEvidenceError("前台应用摘要不完整。")
if device.window_size() != EXPECTED_SCREEN_SIZE:
raise OrderConfirmEvidenceError("屏幕坐标空间不是已批准尺寸。")
return {"package": PDD_PACKAGE, "activity": activity, "pdd_version": EXPECTED_PDD_VERSION}
def _read_rpc(
device: OrderConfirmReadDevice,
method: str,
params: object,
timeout_seconds: float,
) -> object:
return device.jsonrpc_call(method, params, timeout=timeout_seconds)
def _require_screenshot(path: Path) -> None:
with Image.open(path) as image:
image.load()
if image.size != EXPECTED_SCREEN_SIZE or image.format != "PNG":
raise OrderConfirmEvidenceError("页面截图格式或尺寸无效。")
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
shutil.rmtree(staging)
def _manifest(
inspection: DeviceInspection,
serial: str,
goods_id: str,
state: str,
screenshot_path: Path,
hierarchy_path: Path,
app_path: Path,
) -> dict[str, Any]:
return {
"schema_version": 1,
"operation": "t106-order-confirm-readonly-evidence",
"captured_at": datetime.now(UTC).isoformat(),
"product": {
"goods_id": goods_id,
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
},
"human_declared_state": state,
"review_status": "human_review_required",
"channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": {
"model": inspection.model,
"android_version": inspection.android_version,
"pdd_package": PDD_PACKAGE,
"pdd_version": EXPECTED_PDD_VERSION,
},
"artifacts": [
{
"path": screenshot_path.name,
"role": "human_prepared_state_raw_screenshot",
"sha256": _sha256_file(screenshot_path),
},
{
"path": hierarchy_path.name,
"role": "human_prepared_state_raw_hierarchy_local_only",
"sha256": _sha256_file(hierarchy_path),
},
{
"path": app_path.name,
"role": "human_prepared_state_app_identity",
"sha256": _sha256_file(app_path),
},
],
}
@@ -0,0 +1,605 @@
"""T-105:证据绑定的数量读回与 Gate2 面板总额。
本模块只允许数量 1 保持不变,或从数量 1 对唯一加号点击一次到数量 2。
它不包含确认页、提交围栏、提交订单或付款能力。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from functools import partial
from math import isfinite
from pathlib import Path
import re
from time import monotonic, sleep
from typing import Any, Callable, Protocol
from xml.etree import ElementTree
from ..core.errors import ValidationError
from ..core.validation import require_money
from ..device.baseline import PDD_PACKAGE
from .product_open import EXPECTED_PDD_VERSION
from .sku_selection import SkuSelectionError, _parse_nodes as _parse_sku_nodes
from .sku_selection import _product_exit_projection
EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREEN_SIZE = (1080, 2376)
EXPECTED_GOODS_ID = "937122477375"
TASK_COLOR = "黑色CHA(纯棉)"
TASK_SIZE = "M(建议100-115)"
UI_COLOR = "黑色 CHA (纯棉)"
UI_SIZE = "M(建议100-115)"
EXPECTED_GATE1_UNIT_PRICE = "12.88"
_PDD_ID = "com.xunmeng.pinduoduo:id/pdd"
_QUANTITY_CONTAINER_ID = "com.xunmeng.pinduoduo:id/gnl"
_COLOR_ID = "com.xunmeng.pinduoduo:id/tv_content"
_TARGET_SUMMARY = f"已选: {UI_COLOR} {UI_SIZE}"
_AMOUNT_TEXT = re.compile(r"^快卖完 ¥(?P<amount>(?:0|[1-9]\d*)\.\d{2})$")
_BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
class QuantityGate2Error(RuntimeError):
"""数量/Gate2 判据不成立时的脱敏安全停止。"""
class QuantityGate2TimeoutError(QuantityGate2Error):
"""设备调用或后置条件等待超时。"""
class QuantityGate2OverCapError(QuantityGate2Error):
"""目标数量面板总额超过管理员授权上限。"""
class QuantityGate2Device(Protocol):
"""T-105 的窄设备能力;没有通用页面动作。"""
def app_info(self, package_name: str) -> dict[str, Any]: ...
def current_foreground(self) -> dict[str, str]: ...
def display_size(self) -> tuple[int, int]: ...
def dump_window_hierarchy(self) -> str: ...
def increment_quantity_once(self, bounds: str) -> None: ...
def capture_screenshot(self) -> str: ...
def leave_sku_panel_once(self) -> None: ...
@dataclass(frozen=True)
class Gate1Observation:
color: str
size: str
quantity: int
gate1_unit_price: str
screenshot_path: Path
captured_at: datetime
def __post_init__(self) -> None:
if self.color != TASK_COLOR or self.size != TASK_SIZE or type(self.quantity) is not int or self.quantity != 1:
raise QuantityGate2Error("Gate1 规格或数量不是已取证前置,已停止操作。")
if _money(self.gate1_unit_price) != EXPECTED_GATE1_UNIT_PRICE:
raise QuantityGate2Error("Gate1 单价不是已取证值,已停止操作。")
if not isinstance(self.screenshot_path, Path) or not self.screenshot_path.name:
raise QuantityGate2Error("Gate1 截图路径无效,已停止操作。")
if not isinstance(self.captured_at, datetime) or self.captured_at.utcoffset() is None:
raise QuantityGate2Error("Gate1 采集时间必须带时区,已停止操作。")
@dataclass(frozen=True)
class Gate2Observation:
requested_color: str
requested_size: str
actual_color: str
actual_size: str
requested_quantity: int
quantity_read: int
gate1_unit_price: str
gate2_panel_total_price: str
max_total_price: str
screenshot_path: Path
captured_at: datetime
@dataclass(frozen=True)
class _PanelProfile:
quantity: int
panel_bounds: str
price_row_bounds: str
price_bounds: str
price_text: str
summary_bounds: str
quantity_bounds: str
minus_bounds: str
value_bounds: str
plus_bounds: str
color_bounds: str
_INITIAL = _PanelProfile(
1,
"[0,474][1080,863]",
"[396,498][895,570]",
"[396,503][712,570]",
"快卖完 ¥12.88",
"[396,654][1053,716]",
"[396,752][645,827]",
"[396,752][474,827]",
"[480,752][561,827]",
"[567,752][645,827]",
"[126,1000][438,1024]",
)
_TARGET = _PanelProfile(
2,
"[0,474][1080,861]",
"[396,498][740,570]",
"[396,503][722,570]",
"快卖完 ¥32.76",
"[396,582][1053,644]",
"[396,750][645,825]",
"[396,750][474,825]",
"[480,750][561,825]",
"[567,750][645,825]",
"[126,998][438,1024]",
)
_PROFILES = {1: _INITIAL, 2: _TARGET}
@dataclass(frozen=True)
class _Node:
element: ElementTree.Element
parent: "_Node | None"
@property
def text(self) -> str:
return self.element.get("text", "")
@property
def desc(self) -> str:
return self.element.get("content-desc", "")
@property
def bounds(self) -> str:
return self.element.get("bounds", "")
@dataclass(frozen=True)
class _VerifiedPanel:
quantity: int
panel_total_price: str
plus_bounds: str
projection: tuple[object, ...]
class QuantityGate2Flow:
"""从已确认数量 1 面板推进至获准数量,并安全退出同一商品。"""
def __init__(
self,
device: QuantityGate2Device,
wait_timeout_seconds: float = 1.0,
poll_interval_seconds: float = 0.2,
monotonic_clock: Callable[[], float] = monotonic,
sleep_function: Callable[[float], None] = sleep,
) -> None:
if not _positive_finite(wait_timeout_seconds) or not _positive_finite(poll_interval_seconds):
raise ValueError("等待参数必须是大于 0 的有限数值。")
self._device = device
self._timeout = float(wait_timeout_seconds)
self._poll = float(poll_interval_seconds)
self._clock = monotonic_clock
self._sleep = sleep_function
self._increment_attempted = False
self._pending: tuple[str, Callable[[list[_Node]], _VerifiedPanel]] | None = None
self._terminal = False
self._verified_quantity: int | None = None
self._verified_total: str | None = None
self._exited = False
@property
def increment_attempts(self) -> int:
return int(self._increment_attempted)
@property
def exited(self) -> bool:
return self._exited
@property
def can_exit_safely(self) -> bool:
return self._pending is None and self._verified_quantity in _PROFILES and not self._exited
def set_quantity_and_verify(
self,
gate1: Gate1Observation,
target_quantity: int,
max_total_price: str,
) -> _VerifiedPanel:
self._require_active()
_validate_request(gate1, target_quantity, max_total_price)
initial = self._read_verified(_INITIAL)
if initial.panel_total_price != gate1.gate1_unit_price:
raise QuantityGate2Error("Gate1 当前面板事实已漂移,已停止操作。")
if target_quantity == 1:
verified = initial
else:
# 动作前 fresh 读取,不能使用上一次节点或缓存 bounds。
before = self._read_hierarchy()
precondition = _verified_panel(_parse_nodes(before), _INITIAL)
_require_unique_action_occupants(_parse_nodes(before), precondition.plus_bounds)
self._pending = (before, partial(_verified_panel, profile=_TARGET))
self._increment_attempted = True
try:
self._device.increment_quantity_once(precondition.plus_bounds)
verified = self._wait_for_pending()
except BaseException:
# 点击超时可能已送达;封存后不允许本 Flow 重试或继续。
self._terminal = True
raise
self._verified_quantity = verified.quantity
self._verified_total = verified.panel_total_price
if Decimal(verified.panel_total_price) > Decimal(max_total_price):
raise QuantityGate2OverCapError("Gate2 面板总额超过授权最高总价,已停止操作。")
return verified
def build_observation(
self,
gate1: Gate1Observation,
target_quantity: int,
max_total_price: str,
screenshot_path: Path,
captured_at: datetime,
) -> Gate2Observation:
self._require_active()
_validate_request(gate1, target_quantity, max_total_price)
if not isinstance(screenshot_path, Path) or not screenshot_path.name:
raise QuantityGate2Error("Gate2 截图路径无效,已停止操作。")
if not isinstance(captured_at, datetime) or captured_at.utcoffset() is None:
raise QuantityGate2Error("Gate2 采集时间必须带时区,已停止操作。")
verified = self._read_verified(_PROFILES[target_quantity])
if (
self._verified_quantity != verified.quantity
or self._verified_total != verified.panel_total_price
or Decimal(verified.panel_total_price) > Decimal(max_total_price)
):
raise QuantityGate2Error("截图后 Gate2 事实漂移,已停止操作。")
return Gate2Observation(
requested_color=gate1.color,
requested_size=gate1.size,
actual_color=TASK_COLOR,
actual_size=TASK_SIZE,
requested_quantity=target_quantity,
quantity_read=verified.quantity,
gate1_unit_price=gate1.gate1_unit_price,
gate2_panel_total_price=verified.panel_total_price,
max_total_price=max_total_price,
screenshot_path=screenshot_path,
captured_at=captured_at,
)
def exit_sku_panel_safely(self) -> None:
self._require_active()
if self._verified_quantity not in _PROFILES:
raise QuantityGate2Error("没有可用于安全退出的数量事实,已停止操作。")
current = self._read_verified(_PROFILES[self._verified_quantity])
if current.panel_total_price != self._verified_total:
raise QuantityGate2Error("安全退出前 Gate2 事实漂移,已停止操作。")
try:
self._device.leave_sku_panel_once()
except BaseException:
self._terminal = True
raise
deadline = self._clock() + self._timeout
stable: tuple[object, ...] | None = None
try:
while True:
self._require_environment()
raw = self._read_hierarchy()
try:
projection = _product_exit_projection(_parse_sku_nodes(raw))
except SkuSelectionError:
stable = None
else:
self._require_environment()
if stable == projection:
self._exited = True
self._terminal = True
return
stable = projection
remaining = deadline - self._clock()
if remaining <= 0:
raise QuantityGate2TimeoutError("安全退出未达到连续稳定同商品判据,未重试返回。")
self._sleep(min(self._poll, remaining))
except BaseException:
self._terminal = True
raise
def reconcile_pending_action(self) -> _VerifiedPanel | None:
"""结果不明时只读一次待定后置条件;绝不重发加号。"""
if self._pending is None:
return None
return self._wait_for_pending()
def _wait_for_pending(self) -> _VerifiedPanel:
if self._pending is None:
raise QuantityGate2Error("没有可调和的数量动作。")
previous, condition = self._pending
deadline = self._clock() + self._timeout
while True:
self._require_environment()
raw = self._read_hierarchy()
if raw != previous:
try:
verified = condition(_parse_nodes(raw))
except QuantityGate2Error:
pass
else:
self._pending = None
return verified
remaining = deadline - self._clock()
if remaining <= 0:
raise QuantityGate2TimeoutError("数量动作后置条件未确认,未重试加号。")
self._sleep(min(self._poll, remaining))
def _read_verified(self, profile: _PanelProfile) -> _VerifiedPanel:
self._require_environment()
return _verified_panel(_parse_nodes(self._read_hierarchy()), profile)
def _require_environment(self) -> None:
info = self._device.app_info(PDD_PACKAGE)
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
if version != EXPECTED_PDD_VERSION:
raise QuantityGate2Error("拼多多版本与 T-105 证据不一致,已停止操作。")
foreground = self._device.current_foreground()
if (
not isinstance(foreground, dict)
or foreground.get("package") != PDD_PACKAGE
or not isinstance(foreground.get("activity"), str)
or not foreground["activity"].strip()
):
raise QuantityGate2Error("拼多多不是唯一前台应用,已停止操作。")
if self._device.display_size() != EXPECTED_SCREEN_SIZE:
raise QuantityGate2Error("屏幕坐标空间与 T-105 证据不一致,已停止操作。")
def _read_hierarchy(self) -> str:
value = self._device.dump_window_hierarchy()
if not isinstance(value, str) or not value:
raise QuantityGate2Error("节点树读取失败,已停止操作。")
return value
def _require_active(self) -> None:
if self._terminal:
raise QuantityGate2Error("数量流程已进入不可重入终止态。")
def _positive_finite(value: object) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
def _validate_request(gate1: object, target_quantity: object, max_total_price: object) -> None:
if not isinstance(gate1, Gate1Observation):
raise QuantityGate2Error("缺少可信 Gate1Observation,已停止操作。")
if type(target_quantity) is not int or target_quantity not in _PROFILES:
raise QuantityGate2Error("目标数量没有本项目真机证据,已停止操作。")
_money(max_total_price)
def _money(value: object) -> str:
try:
return require_money(value, "invalid_money")
except ValidationError as error:
raise QuantityGate2Error("金额不是规范十进制字符串,已停止操作。") from error
def _parse_nodes(raw: str) -> list[_Node]:
try:
root = ElementTree.fromstring(raw)
except ElementTree.ParseError as error:
raise QuantityGate2Error("节点树格式无效,已停止操作。") from error
if root.tag != "hierarchy":
raise QuantityGate2Error("节点树根节点无效,已停止操作。")
result: list[_Node] = []
def visit(element: ElementTree.Element, parent: _Node | None) -> None:
node = _Node(element, parent)
result.append(node)
for child in element:
visit(child, node)
visit(root, None)
return result
def _verified_panel(nodes: list[_Node], profile: _PanelProfile) -> _VerifiedPanel:
panel = _one(
node for node in nodes
if _exact(node, "android.view.ViewGroup", profile.panel_bounds, resource_id=_PDD_ID, clickable="false")
)
price_frame = _one(
node for node in nodes
if node.parent is panel and _exact(node, "android.widget.FrameLayout", "[396,498][1053,570]", resource_id=_PDD_ID, clickable="false")
)
price_row = _one(
node for node in nodes
if node.parent is price_frame and _exact(node, "android.widget.LinearLayout", profile.price_row_bounds, resource_id=_PDD_ID, clickable="false")
)
price = _one(
node for node in nodes
if node.parent is price_row
and _exact(node, "android.widget.TextView", profile.price_bounds, resource_id=_PDD_ID, clickable="false")
and node.text == profile.price_text
and not node.desc
)
match = _AMOUNT_TEXT.fullmatch(price.text)
if match is None:
raise QuantityGate2Error("Gate2 面板总额角色不可读。")
amount = _money(match.group("amount"))
_one(
node for node in nodes
if node.parent is panel
and _exact(node, "android.widget.TextView", profile.summary_bounds, resource_id=_PDD_ID, clickable="false")
and node.text == _TARGET_SUMMARY
and not node.desc
)
quantity_outer = _one(
node for node in nodes
if node.parent is panel
and _exact(node, "android.widget.LinearLayout", profile.quantity_bounds, resource_id=_QUANTITY_CONTAINER_ID, clickable="false")
)
quantity_inner = _one(
node for node in nodes
if node.parent is quantity_outer
and _exact(node, "android.widget.LinearLayout", profile.quantity_bounds, resource_id="", clickable="false")
)
_one(
node for node in nodes
if node.parent is quantity_inner
and _exact(node, "android.widget.ImageView", profile.minus_bounds, resource_id=_PDD_ID, clickable="true")
and node.desc == "减少数量"
and not node.text
)
quantity = _one(
node for node in nodes
if node.parent is quantity_inner
and _exact(node, "android.widget.EditText", profile.value_bounds, resource_id=_PDD_ID, clickable="true")
and node.text == str(profile.quantity)
and not node.desc
)
plus = _one(
node for node in nodes
if node.parent is quantity_inner
and _exact(node, "android.widget.ImageView", profile.plus_bounds, resource_id=_PDD_ID, clickable="true")
and node.desc == "增加数量"
and not node.text
)
if quantity.element.get("selected") != "false" or plus.element.get("selected") != "false":
raise QuantityGate2Error("数量控件选中属性漂移。")
_require_selected_target(nodes, profile)
projection = (
"quantity_gate2_8_17_0",
profile.quantity,
amount,
tuple(_projection(node) for node in (panel, price_frame, price_row, price, quantity_outer, quantity_inner, quantity, plus)),
)
return _VerifiedPanel(profile.quantity, amount, plus.bounds, projection)
def _require_selected_target(nodes: list[_Node], profile: _PanelProfile) -> None:
color = _one(
node for node in nodes
if _exact(node, "android.widget.TextView", profile.color_bounds, resource_id=_COLOR_ID, clickable="true", selected="true")
and node.text == UI_COLOR
and not node.desc
)
if color.parent is None or color.parent.element.get("selected") != "true":
raise QuantityGate2Error("目标颜色没有精确选中。")
size = _one(
node for node in nodes
if _exact(node, "android.widget.TextView", "[439,1582][831,1667]", resource_id=_PDD_ID, clickable="true", selected="true")
and node.text == UI_SIZE
and not node.desc
)
if size.parent is None or size.parent.element.get("clickable") != "true":
raise QuantityGate2Error("目标尺码结构漂移。")
def _exact(
node: _Node,
class_name: str,
bounds: str,
*,
resource_id: str,
clickable: str,
selected: str = "false",
) -> bool:
element = node.element
return (
element.get("package") == PDD_PACKAGE
and element.get("class") == class_name
and node.bounds == bounds
and element.get("resource-id", "") == resource_id
and element.get("clickable") == clickable
and element.get("selected") == selected
and element.get("enabled") == "true"
and element.get("visible-to-user") == "true"
and element.get("scrollable") == "false"
)
def _one(values: Any) -> _Node:
matches = list(values)
if len(matches) != 1:
raise QuantityGate2Error("T-105 页面证据角色缺失或不唯一。")
return matches[0]
def _projection(node: _Node) -> tuple[str, ...]:
return (
node.element.tag,
node.element.get("package", ""),
node.element.get("class", ""),
node.bounds,
node.element.get("resource-id", ""),
node.element.get("clickable", ""),
node.element.get("selected", ""),
node.text,
node.desc,
)
def _bounds_center(bounds: str) -> tuple[int, int]:
match = _BOUNDS.fullmatch(bounds)
if match is None:
raise QuantityGate2Error("数量控件坐标无效。")
left, top, right, bottom = (int(value) for value in match.groups())
if not (0 <= left < right <= EXPECTED_SCREEN_SIZE[0] and 0 <= top < bottom <= EXPECTED_SCREEN_SIZE[1]):
raise QuantityGate2Error("数量控件坐标超出已取证屏幕。")
return left + (right - left) // 2, top + (bottom - top) // 2
def _require_unique_action_occupants(nodes: list[_Node], bounds: str) -> None:
target = _one(node for node in nodes if node.bounds == bounds and node.desc == "增加数量")
x, y = _bounds_center(bounds)
occupants = [
node for node in nodes
if node.element.get("clickable") == "true"
and node.element.get("enabled") == "true"
and node.element.get("visible-to-user") == "true"
and _contains(node.bounds, x, y)
]
if not occupants or any(not _same_branch(node, target) for node in occupants):
raise QuantityGate2Error("数量加号中心存在未知可点击覆盖层,已停止操作。")
def _contains(bounds: str, x: int, y: int) -> bool:
match = _BOUNDS.fullmatch(bounds)
if match is None:
return False
left, top, right, bottom = (int(value) for value in match.groups())
return left <= x < right and top <= y < bottom
def _same_branch(candidate: _Node, target: _Node) -> bool:
current: _Node | None = target
while current is not None:
if current.element is candidate.element:
return True
current = current.parent
current = candidate
while current is not None:
if current.element is target.element:
return True
current = current.parent
return False
@@ -0,0 +1,397 @@
"""T-105 真机运行边界:一次数量加号、Gate2 原图与一次安全退出。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from hashlib import sha256
import json
from math import isfinite
import os
from pathlib import Path
import shutil
from time import monotonic
from typing import Any, Protocol
from uuid import uuid4
from adbutils.errors import AdbTimeout
from PIL import Image, UnidentifiedImageError
from uiautomator2.exceptions import HTTPTimeoutError
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
from .product_open import EXPECTED_PDD_VERSION
from .quantity_gate2 import (
EXPECTED_ANDROID_VERSION,
EXPECTED_DEVICE_MODEL,
EXPECTED_GOODS_ID,
EXPECTED_SCREEN_SIZE,
Gate1Observation,
Gate2Observation,
QuantityGate2Device,
QuantityGate2Error,
QuantityGate2Flow,
QuantityGate2TimeoutError,
_bounds_center,
_money,
)
class ForegroundReader(Protocol):
def read(self, serial: str) -> dict[str, str]: ...
class QuantityGate2AdapterError(QuantityGate2Error):
"""第三方设备接口失败后的脱敏映射。"""
@dataclass(frozen=True)
class QuantityGate2RunResult:
output_directory: Path
screenshot_path: Path
manifest_path: Path
observation: Gate2Observation
class UiautomatorQuantityGate2Adapter(QuantityGate2Device):
"""只暴露 T-105 已批准的一个加号和一个 Back。"""
def __init__(
self,
device: Any,
foreground_reader: ForegroundReader,
serial: str,
timeout_seconds: float,
) -> None:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise ValueError("serial 必须显式且非空。")
if not _positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值。")
self._device = device
self._foreground_reader = foreground_reader
self._serial = serial
self._timeout = float(timeout_seconds)
self._increment_attempted = False
self._increment_bounds: str | None = None
self._increment_outcome = "not_attempted"
self._back_attempted = False
self._back_outcome = "not_attempted"
@property
def increment_attempts(self) -> int:
return int(self._increment_attempted)
@property
def increment_bounds(self) -> str | None:
return self._increment_bounds
@property
def increment_outcome(self) -> str:
return self._increment_outcome
@property
def back_attempts(self) -> int:
return int(self._back_attempted)
@property
def back_outcome(self) -> str:
return self._back_outcome
def app_info(self, package_name: str) -> dict[str, Any]:
value = self._call("app_info", package_name)
if not isinstance(value, dict):
raise QuantityGate2AdapterError("无法读取应用版本,已停止操作。")
return value
def current_foreground(self) -> dict[str, str]:
try:
value = self._foreground_reader.read(self._serial)
except QuantityGate2Error:
raise
except Exception as error:
raise QuantityGate2AdapterError("无法读取 Android 前台摘要,已停止操作。") from error
if not isinstance(value, dict):
raise QuantityGate2AdapterError("Android 前台摘要无效,已停止操作。")
return value
def display_size(self) -> tuple[int, int]:
value = self._call("window_size")
if not isinstance(value, tuple) or len(value) != 2 or any(type(item) is not int for item in value):
raise QuantityGate2AdapterError("无法读取屏幕坐标空间,已停止操作。")
return value
def dump_window_hierarchy(self) -> str:
value = self._call("jsonrpc_call", "dumpWindowHierarchy", [False, 50], timeout=self._timeout)
if not isinstance(value, str):
raise QuantityGate2AdapterError("节点树读取失败,已停止操作。")
return value
def increment_quantity_once(self, bounds: str) -> None:
if self._increment_attempted:
raise QuantityGate2AdapterError("数量加号已尝试过,拒绝重试。")
center_x, center_y = _bounds_center(bounds)
# RPC 超时无法证明事件未送达,动作机会必须先持久在内存审计状态中。
self._increment_attempted = True
self._increment_bounds = bounds
self._increment_outcome = "ambiguous"
self._call("jsonrpc_call", "click", [center_x, center_y], timeout=self._timeout)
self._increment_outcome = "completed"
def capture_screenshot(self) -> str:
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout)
if not isinstance(value, str):
raise QuantityGate2AdapterError("Gate2 原始截图读取失败,已停止操作。")
return value
def leave_sku_panel_once(self) -> None:
if self._back_attempted:
raise QuantityGate2AdapterError("安全返回已尝试过,拒绝重试。")
self._back_attempted = True
self._back_outcome = "ambiguous"
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout)
self._back_outcome = "completed"
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
try:
return getattr(self._device, method)(*args, **kwargs)
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
raise QuantityGate2TimeoutError("T-105 设备调用超时,已停止操作。") from error
except QuantityGate2Error:
raise
except Exception as error:
raise QuantityGate2AdapterError("T-105 设备调用失败,已停止操作。") from error
class QuantityGate2Runner:
"""从人工停驻的数量 1 目标面板执行 T-105 已取证闭环。"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], Any],
foreground_reader: ForegroundReader,
timeout_seconds: float,
monotonic_clock: Callable[[], float] = monotonic,
) -> None:
if not _positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值。")
self._adb_client = adb_client
self._connector = connector
self._foreground_reader = foreground_reader
self._timeout = float(timeout_seconds)
self._clock = monotonic_clock
def run(
self,
serial: str,
goods_id: str,
gate1: Gate1Observation,
target_quantity: int,
max_total_price: str,
output_directory: Path,
) -> QuantityGate2RunResult:
target = Path(output_directory)
staging: Path | None = None
adapter: UiautomatorQuantityGate2Adapter | None = None
flow: QuantityGate2Flow | None = None
try:
_validate_preflight(serial, goods_id, gate1, target_quantity, max_total_price, target)
staging = _prepare_staging(target)
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
adapter = UiautomatorQuantityGate2Adapter(
self._connector(serial),
self._foreground_reader,
serial,
self._timeout,
)
flow = QuantityGate2Flow(
adapter,
wait_timeout_seconds=self._timeout,
monotonic_clock=self._clock,
)
flow.set_quantity_and_verify(gate1, target_quantity, max_total_price)
screenshot_path = staging / "gate2_screenshot.png"
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
captured_at = datetime.now(UTC)
_require_screenshot_size(screenshot_path)
observation = flow.build_observation(
gate1,
target_quantity,
max_total_price,
screenshot_path,
captured_at,
)
flow.exit_sku_panel_safely()
_require_action_audit(adapter, target_quantity)
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_manifest(inspection, serial, gate1, observation, screenshot_path, adapter),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.rename(staging, target)
staging = None
except (DeviceConnectionError, QuantityGate2Error):
_attempt_known_safe_exit(flow)
_clean_staging(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_attempt_known_safe_exit(flow)
_clean_staging(staging)
raise QuantityGate2TimeoutError("T-105 真机运行超时,未发布证据。") from error
except OSError as error:
_attempt_known_safe_exit(flow)
_clean_staging(staging)
raise QuantityGate2Error("T-105 证据无法原子发布。") from error
except Exception as error:
_attempt_known_safe_exit(flow)
_clean_staging(staging)
raise QuantityGate2Error("T-105 真机运行未完成。") from error
published_observation = replace(
observation,
screenshot_path=target / "gate2_screenshot.png",
)
return QuantityGate2RunResult(
output_directory=target,
screenshot_path=target / "gate2_screenshot.png",
manifest_path=target / "manifest.json",
observation=published_observation,
)
def _positive_finite(value: object) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
def _validate_preflight(
serial: object,
goods_id: object,
gate1: object,
target_quantity: object,
max_total_price: object,
target: Path,
) -> None:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise QuantityGate2Error("必须显式提供非空设备通道。")
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise QuantityGate2Error("商品不是 T-105 已批准目标。")
if not isinstance(gate1, Gate1Observation) or not gate1.screenshot_path.is_file():
raise QuantityGate2Error("Gate1 原始截图不存在,已停止操作。")
if type(target_quantity) is not int or target_quantity not in {1, 2}:
raise QuantityGate2Error("目标数量没有 T-105 真机证据。")
_money(max_total_price)
if target.exists() or not target.name:
raise QuantityGate2Error("输出目录必须是不存在的明确新目录。")
def _prepare_staging(target: Path) -> Path:
staging: Path | None = None
try:
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
return staging
except OSError as error:
_clean_staging(staging)
raise QuantityGate2Error("输出目录不可写,已停止操作。") from error
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
shutil.rmtree(staging)
def _require_expected_device(inspection: DeviceInspection) -> None:
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
raise QuantityGate2Error("设备型号或 Android 版本与 T-105 证据不一致。")
def _require_screenshot_size(path: Path) -> None:
try:
with Image.open(path) as image:
image.load()
if image.size != EXPECTED_SCREEN_SIZE:
raise QuantityGate2Error("Gate2 截图尺寸与 T-105 证据不一致。")
except QuantityGate2Error:
raise
except (OSError, UnidentifiedImageError) as error:
raise QuantityGate2Error("Gate2 截图不是有效图像。") from error
def _require_action_audit(adapter: UiautomatorQuantityGate2Adapter, target_quantity: int) -> None:
expected_increment = int(target_quantity == 2)
if (
adapter.increment_attempts != expected_increment
or (expected_increment and adapter.increment_outcome != "completed")
or (expected_increment and adapter.increment_bounds != "[567,752][645,827]")
or (not expected_increment and adapter.increment_outcome != "not_attempted")
or adapter.back_attempts != 1
or adapter.back_outcome != "completed"
):
raise QuantityGate2Error("T-105 动作审计链不完整,拒绝发布。")
def _attempt_known_safe_exit(flow: QuantityGate2Flow | None) -> None:
if flow is None or not flow.can_exit_safely:
return
try:
flow.exit_sku_panel_safely()
except Exception:
pass
def _manifest(
inspection: DeviceInspection,
serial: str,
gate1: Gate1Observation,
observation: Gate2Observation,
screenshot_path: Path,
adapter: UiautomatorQuantityGate2Adapter,
) -> dict[str, Any]:
return {
"schema_version": 1,
"operation": "t105-quantity-gate2",
"captured_at": observation.captured_at.isoformat(),
"product": {"goods_id": EXPECTED_GOODS_ID},
"channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": {
"model": inspection.model,
"android_version": inspection.android_version,
"pdd_package": PDD_PACKAGE,
"pdd_version": EXPECTED_PDD_VERSION,
},
"selection": {"color": observation.actual_color, "size": observation.actual_size},
"quantity": {"requested": observation.requested_quantity, "read": observation.quantity_read},
"prices": {
"gate1_unit_price": observation.gate1_unit_price,
"gate2_panel_total_price": observation.gate2_panel_total_price,
"max_total_price": observation.max_total_price,
},
"gate1_evidence": {
"captured_at": gate1.captured_at.isoformat(),
"screenshot_sha256": _sha256_file(gate1.screenshot_path),
},
"gate2_evidence": {
"path": screenshot_path.name,
"sha256": _sha256_file(screenshot_path),
},
"action_audit": {
"increment_attempts": adapter.increment_attempts,
"increment_rpc_outcome": adapter.increment_outcome,
"back_attempts": adapter.back_attempts,
"back_rpc_outcome": adapter.back_outcome,
},
"safe_exit": "completed",
"review_status": "human_review_required",
}
@@ -0,0 +1,351 @@
"""T-105 数量两态的纯只读取证;人工确认前不识别或操作数量控件。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from hashlib import sha256
import json
from math import isfinite
import os
from pathlib import Path
import re
import shutil
from typing import Any, Protocol
from uuid import uuid4
from adbutils.errors import AdbTimeout
from PIL import Image, UnidentifiedImageError
from uiautomator2.exceptions import HTTPTimeoutError
from ..device.adb import AdbClient, CommandRunner, DeviceConnectionError, DeviceInspection
from ..device.baseline import (
HIERARCHY_PARAMS,
PDD_PACKAGE,
SCREENSHOT_PARAMS,
_save_base64_screenshot,
_sha256_file,
_validate_hierarchy,
)
from .product_open import EXPECTED_PDD_VERSION
from .product_url import parse_product_url
EXPECTED_GOODS_ID = "937122477375"
EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREEN_SIZE = (1080, 2376)
TARGET_SELECTION = {"color": "黑色CHA(纯棉)", "size": "M(建议100-115)"}
DECLARED_QUANTITIES = {"initial": 1, "target": 2}
class QuantityGate2EvidenceError(RuntimeError):
"""T-105 两态证据未形成完整原子产物。"""
class QuantityGate2EvidenceTimeoutError(QuantityGate2EvidenceError):
"""只读取证设备调用超时。"""
class QuantityGate2ReadDevice(Protocol):
"""阶段一唯一设备边界;故意不暴露任何页面动作。"""
def app_info(self, package_name: str) -> dict[str, Any]: ...
def window_size(self) -> tuple[int, int]: ...
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: ...
class QuantityGate2ForegroundReader(Protocol):
"""只读取 Android 16 的唯一 resumed activity,不暴露通用 shell。"""
def read(self, serial: str) -> dict[str, str]: ...
class Android16TopResumedForegroundReader:
"""绕开 adbutils 2.12.0 对 Android 16 ``topResumedActivity`` 的误解析。"""
_TOP_RESUMED_PATTERN = re.compile(
r"(?m)^\s*topResumedActivity=ActivityRecord\{[^\r\n}]*?\s+u\d+\s+"
r"(?P<package>[^/\s]+)/(?P<activity>[^\s}]+)\s+t\d+\}\s*$"
)
def __init__(self, runner: CommandRunner, timeout_seconds: float) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._runner = runner
self._timeout_seconds = timeout_seconds
def read(self, serial: str) -> dict[str, str]:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise QuantityGate2EvidenceError("必须显式提供非空设备通道。")
result = self._runner.run(
("-s", serial, "shell", "dumpsys", "activity", "activities"),
self._timeout_seconds,
)
if result.returncode != 0:
raise QuantityGate2EvidenceError("Android 前台摘要读取失败,未发布证据。")
matches = list(self._TOP_RESUMED_PATTERN.finditer(result.stdout))
if len(matches) != 1:
raise QuantityGate2EvidenceError("Android 前台摘要不唯一,未发布证据。")
match = matches[0]
return {
"package": match.group("package"),
"activity": match.group("activity"),
}
@dataclass(frozen=True)
class QuantityGate2EvidenceResult:
output_directory: Path
manifest_path: Path
screenshot_path: Path
hierarchy_path: Path
app_path: Path
class QuantityGate2EvidenceCapturer:
"""记录人工准备的数量状态,不从页面推断声明是否正确。"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], QuantityGate2ReadDevice],
foreground_reader: QuantityGate2ForegroundReader,
timeout_seconds: float,
) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._adb_client = adb_client
self._connector = connector
self._foreground_reader = foreground_reader
self._timeout_seconds = timeout_seconds
self._started = False
def capture(
self,
serial: str,
goods_id: str,
human_declared_state: str,
human_declared_quantity: int,
output_directory: Path,
) -> QuantityGate2EvidenceResult:
if self._started:
raise QuantityGate2EvidenceError("同一取证器不可重复调用。")
self._started = True
_validate_inputs(serial, goods_id, human_declared_state, human_declared_quantity)
target = Path(output_directory)
_validate_new_target(target)
staging: Path | None = None
try:
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
device = self._connector(serial)
initial_app = _require_read_precondition(
device,
self._foreground_reader.read(serial),
)
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
screenshot_path = staging / "screenshot.png"
screenshot_payload = _read_rpc(
device,
"takeScreenshot",
SCREENSHOT_PARAMS,
self._timeout_seconds,
)
if not isinstance(screenshot_payload, str):
raise QuantityGate2EvidenceError("数量状态截图无效,未发布证据。")
_save_base64_screenshot(screenshot_payload, screenshot_path)
_require_screenshot_size(screenshot_path)
hierarchy = _read_rpc(
device,
"dumpWindowHierarchy",
HIERARCHY_PARAMS,
self._timeout_seconds,
)
_validate_hierarchy(hierarchy)
hierarchy_path = staging / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
final_app = _require_read_precondition(
device,
self._foreground_reader.read(serial),
)
if final_app != initial_app:
raise QuantityGate2EvidenceError("数量状态取证期间前台页面漂移,未发布证据。")
app_path = staging / "app.json"
app_path.write_text(
json.dumps(final_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_manifest(
inspection,
serial,
goods_id,
human_declared_state,
human_declared_quantity,
screenshot_path,
hierarchy_path,
app_path,
),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.rename(staging, target)
staging = None
except (DeviceConnectionError, QuantityGate2EvidenceError):
_clean_staging(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
raise QuantityGate2EvidenceTimeoutError("数量两态只读取证超时,未发布证据。") from error
except (OSError, UnidentifiedImageError, ValueError) as error:
_clean_staging(staging)
raise QuantityGate2EvidenceError("数量两态证据无法原子发布,未发布证据。") from error
except Exception as error:
_clean_staging(staging)
raise QuantityGate2EvidenceError("数量两态只读取证未完成,未发布证据。") from error
return QuantityGate2EvidenceResult(
output_directory=target,
manifest_path=target / "manifest.json",
screenshot_path=target / "screenshot.png",
hierarchy_path=target / "hierarchy.xml",
app_path=target / "app.json",
)
def _is_positive_finite(value: object) -> bool:
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and value > 0
and isfinite(value)
)
def _validate_inputs(serial: object, goods_id: object, state: object, quantity: object) -> None:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise QuantityGate2EvidenceError("必须显式提供非空设备通道。")
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise QuantityGate2EvidenceError("商品不是 T-105 已批准取证目标。")
parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}")
if type(state) is not str or state not in DECLARED_QUANTITIES:
raise QuantityGate2EvidenceError("人工声明状态无效。")
if type(quantity) is not int or quantity != DECLARED_QUANTITIES[state]:
raise QuantityGate2EvidenceError("人工声明数量与批准状态不一致。")
def _validate_new_target(target: Path) -> None:
if target.exists() or not target.name:
raise QuantityGate2EvidenceError("输出目录必须是不存在的明确新目录。")
def _require_expected_device(inspection: DeviceInspection) -> None:
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
raise QuantityGate2EvidenceError("设备不是已批准取证组合。")
def _require_read_precondition(
device: QuantityGate2ReadDevice,
current: object,
) -> dict[str, str]:
info = device.app_info(PDD_PACKAGE)
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
if version != EXPECTED_PDD_VERSION:
raise QuantityGate2EvidenceError("拼多多版本不是已批准取证版本。")
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
raise QuantityGate2EvidenceError("拼多多不在前台。")
activity = current.get("activity")
if not isinstance(activity, str) or not activity.strip():
raise QuantityGate2EvidenceError("前台应用摘要不完整。")
if device.window_size() != EXPECTED_SCREEN_SIZE:
raise QuantityGate2EvidenceError("屏幕坐标空间不是已批准尺寸。")
return {"package": PDD_PACKAGE, "activity": activity, "pdd_version": EXPECTED_PDD_VERSION}
def _read_rpc(
device: QuantityGate2ReadDevice,
method: str,
params: object,
timeout_seconds: float,
) -> object:
return device.jsonrpc_call(method, params, timeout=timeout_seconds)
def _require_screenshot_size(path: Path) -> None:
with Image.open(path) as image:
image.load()
if image.size != EXPECTED_SCREEN_SIZE or image.format != "PNG":
raise QuantityGate2EvidenceError("数量状态截图格式或尺寸无效。")
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
shutil.rmtree(staging)
def _manifest(
inspection: DeviceInspection,
serial: str,
goods_id: str,
state: str,
quantity: int,
screenshot_path: Path,
hierarchy_path: Path,
app_path: Path,
) -> dict[str, Any]:
return {
"schema_version": 1,
"operation": "t105-quantity-gate2-readonly-evidence",
"captured_at": datetime.now(UTC).isoformat(),
"product": {
"goods_id": goods_id,
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
},
"human_declared_state": state,
"human_declared_quantity": quantity,
"human_declared_selection": TARGET_SELECTION,
"review_status": "human_review_required",
"channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": {
"model": inspection.model,
"android_version": inspection.android_version,
"pdd_package": PDD_PACKAGE,
"pdd_version": EXPECTED_PDD_VERSION,
},
"artifacts": [
{
"path": screenshot_path.name,
"role": "quantity_state_raw_screenshot",
"sha256": _sha256_file(screenshot_path),
},
{
"path": hierarchy_path.name,
"role": "quantity_state_raw_hierarchy_local_only",
"sha256": _sha256_file(hierarchy_path),
},
{
"path": app_path.name,
"role": "quantity_state_app_identity",
"sha256": _sha256_file(app_path),
},
],
}
@@ -0,0 +1,307 @@
"""T-103 尺码显示动作的一次性真机证据采集。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from hashlib import sha256
import json
from math import isfinite
import os
from pathlib import Path
import shutil
from time import monotonic, sleep
from typing import Any
from uuid import uuid4
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
from ..device.baseline import PDD_PACKAGE, _save_base64_screenshot, _sha256_file
from .product_url import parse_product_url
from .sku_selection import (
EXPECTED_GOODS_ID,
_parse_nodes,
_require_color_only_panel,
_require_safe_reveal_path,
_revealed_unselected_projection,
resolve_task_selection,
SkuSelectionError,
SkuSelectionFlow,
)
from .sku_selection_runner import (
EXPECTED_ANDROID_VERSION,
EXPECTED_DEVICE_MODEL,
EXPECTED_SCREEN_SIZE,
SkuSelectionRunError,
UiautomatorSkuPanelAdapter,
_require_expected_device,
_require_expected_version,
_require_screenshot_size,
)
_TARGET_URL = f"https://mobile.yangkeduo.com/goods.html?goods_id={EXPECTED_GOODS_ID}"
_REVEAL_FAILURE_STAGES = frozenset(
(
"reveal_precondition",
"reveal_attempted",
"reveal_candidate",
"reveal_after",
"reveal_publish",
)
)
_REVEAL_FAILURE_MARKER = object()
class SkuRevealSpikeError(RuntimeError):
"""一次性 reveal 取证未形成可发布证据。"""
def _annotate_reveal_failure(error: BaseException, stage: str) -> None:
"""只记录本模块实际走到的固定阶段;入口阶段使用独立 marker,不会被覆盖。"""
if type(stage) is not str or stage not in _REVEAL_FAILURE_STAGES:
return
try:
setattr(error, "_cmbuyer_reveal_failure_stage", stage)
# marker 最后写入,任一 setter 失败都不能形成可信阶段。
setattr(error, "_cmbuyer_reveal_failure_marker", _REVEAL_FAILURE_MARKER)
except BaseException:
pass
def safe_reveal_failure_stage(error: BaseException) -> str | None:
"""读取可公开的 reveal 控制流阶段;伪造属性或 hostile getter 均失败闭合。"""
try:
marker = getattr(error, "_cmbuyer_reveal_failure_marker", None)
stage = getattr(error, "_cmbuyer_reveal_failure_stage", None)
if marker is not _REVEAL_FAILURE_MARKER or type(stage) is not str:
return None
return stage if stage in _REVEAL_FAILURE_STAGES else None
except BaseException:
return None
@dataclass(frozen=True)
class SkuRevealSpikeResult:
output_directory: Path
manifest_path: Path
class SkuRevealSpikeCapturer:
"""打开已取证面板、选一次目标颜色,再采集唯一 reveal 的前后证据。"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], Any],
timeout_seconds: float,
monotonic_clock: Callable[[], float] = monotonic,
sleep_function: Callable[[float], None] = sleep,
) -> None:
if not _positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._adb_client = adb_client
self._connector = connector
self._timeout_seconds = timeout_seconds
self._clock = monotonic_clock
self._sleep = sleep_function
def capture(
self,
serial: str,
goods_id: str,
output_directory: Path,
) -> SkuRevealSpikeResult:
stage = "reveal_precondition"
staging: Path | None = None
adapter: UiautomatorSkuPanelAdapter | None = None
try:
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise SkuRevealSpikeError("商品不是 T-103 已取证目标,已停止取证。")
link = parse_product_url(_TARGET_URL)
target = Path(output_directory)
_validate_new_target(target)
staging = _prepare_staging(target)
deadline = self._clock() + self._timeout_seconds
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
_require_expected_version(adapter.app_info(PDD_PACKAGE))
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
raise SkuRevealSpikeError("设备不是已取证的竖屏坐标空间,已停止取证。")
pre_intent = adapter.dump_window_hierarchy()
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
remaining = deadline - self._clock()
if remaining <= 0:
raise SkuRevealSpikeError("规格入口取证超时,未执行 reveal。")
flow = SkuSelectionFlow(
adapter,
entry_wait_timeout_seconds=remaining,
monotonic_clock=self._clock,
sleep_function=self._sleep,
)
flow.open_sku_panel(link.canonical_url, pre_intent)
# spike 只复用生产 Flow 的私有前置准备,不调用完整选择流程,避免重复 reveal/M。
flow._prepare_reveal_precondition(
resolve_task_selection("黑色CHA(纯棉)", "M(建议100-115)")
)
before_hierarchy = adapter.dump_window_hierarchy()
before_nodes = _parse_nodes(before_hierarchy)
_require_color_only_panel(before_nodes)
_require_safe_reveal_path(before_nodes)
_capture_frame(adapter, staging / "before", before_hierarchy)
# 截图 RPC 期间页面也可能变化;真正发送手势前必须用新树再次证明同一前置与安全通道。
before_hierarchy = adapter.dump_window_hierarchy()
before_nodes = _parse_nodes(before_hierarchy)
_require_color_only_panel(before_nodes)
_require_safe_reveal_path(before_nodes)
_require_expected_version(adapter.app_info(PDD_PACKAGE))
current = adapter.app_current()
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
raise SkuRevealSpikeError("reveal 前拼多多不在前台,未执行手势。")
(staging / "before" / "hierarchy.xml").write_text(before_hierarchy, encoding="utf-8")
rpc_outcome = "completed"
# 此后即属于 attempted:adapter 会在 RPC 前封存唯一机会,结果不明也只能调和。
stage = "reveal_attempted"
try:
adapter.reveal_size_options_once()
except SkuSelectionRunError:
rpc_outcome = "ambiguous_reconciled"
stage = "reveal_candidate"
projection, after_hierarchy = self._wait_for_candidate(adapter, deadline)
stage = "reveal_after"
after_directory = staging / "after"
_capture_frame(adapter, after_directory, after_hierarchy)
reverified = adapter.dump_window_hierarchy()
if _revealed_unselected_projection(_parse_nodes(reverified)) != projection:
raise SkuRevealSpikeError("截图后候选状态漂移,未发布证据。")
(after_directory / "hierarchy.xml").write_text(reverified, encoding="utf-8")
stage = "reveal_publish"
manifest = _manifest(inspection, serial, rpc_outcome, staging)
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
os.rename(staging, target)
staging = None
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError) as error:
_clean_staging(staging)
failure_stage = stage
if stage == "reveal_attempted" and (adapter is None or not adapter.reveal_attempted):
# attempted 只能在 adapter 已于 RPC 前封存唯一机会后成立。
failure_stage = "reveal_precondition"
_annotate_reveal_failure(error, failure_stage)
raise
except Exception as error:
_clean_staging(staging)
mapped = SkuRevealSpikeError("规格 reveal 取证未完成,未发布本地证据目录。")
failure_stage = stage
if stage == "reveal_attempted" and (adapter is None or not adapter.reveal_attempted):
failure_stage = "reveal_precondition"
_annotate_reveal_failure(mapped, failure_stage)
raise mapped from error
return SkuRevealSpikeResult(target, target / "manifest.json")
def _wait_for_candidate(
self,
adapter: UiautomatorSkuPanelAdapter,
deadline: float,
) -> tuple[tuple[tuple[str, ...], ...], str]:
stable: tuple[tuple[str, ...], ...] | None = None
while True:
_require_expected_version(adapter.app_info(PDD_PACKAGE))
current = adapter.app_current()
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
raise SkuRevealSpikeError("reveal 后拼多多不在前台,未发布证据。")
hierarchy = adapter.dump_window_hierarchy()
try:
projection = _revealed_unselected_projection(_parse_nodes(hierarchy))
except SkuSelectionError:
projection = None
if projection is not None and projection == stable:
return projection, hierarchy
stable = projection
remaining = deadline - self._clock()
if remaining <= 0:
raise SkuRevealSpikeError("reveal 后未形成稳定候选状态,未发布证据。")
self._sleep(min(0.2, remaining))
def _capture_frame(adapter: UiautomatorSkuPanelAdapter, directory: Path, hierarchy: str) -> None:
directory.mkdir()
hierarchy_path = directory / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
screenshot_path = directory / "screenshot.png"
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
_require_screenshot_size(screenshot_path)
def _manifest(
inspection: DeviceInspection,
serial: str,
rpc_outcome: str,
staging: Path,
) -> dict[str, Any]:
artifacts = []
for relative in (
"before/screenshot.png",
"before/hierarchy.xml",
"after/screenshot.png",
"after/hierarchy.xml",
):
path = staging / relative
artifacts.append({"path": relative, "sha256": _sha256_file(path)})
return {
"schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(),
"operation": "t103-sku-reveal-evidence",
"profile_id": "pdd-8.17.0-size-reveal-gap-v1",
"product": {"goods_id": EXPECTED_GOODS_ID},
"channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": {
"model": inspection.model,
"android_version": inspection.android_version,
"pdd_package": PDD_PACKAGE,
"pdd_version": "8.17.0",
},
"reveal_attempts": 1,
"rpc_outcome": rpc_outcome,
"candidate_status": "human_review_required",
"artifacts": artifacts,
}
def _validate_new_target(target: Path) -> None:
if target.exists() or not target.name:
raise SkuRevealSpikeError("输出目录必须是不存在的明确新目录。")
def _prepare_staging(target: Path) -> Path:
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
return staging
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
shutil.rmtree(staging)
def _positive_finite(value: object) -> bool:
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and value > 0
and isfinite(value)
)
File diff suppressed because it is too large Load Diff
@@ -21,16 +21,31 @@ from adbutils.errors import AdbTimeout
from uiautomator2.exceptions import HTTPTimeoutError from uiautomator2.exceptions import HTTPTimeoutError
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file from ..device.baseline import (
PDD_PACKAGE,
SCREENSHOT_PARAMS,
_save_base64_screenshot,
_sha256_file,
_validate_hierarchy,
)
from .product_open import EXPECTED_PDD_VERSION from .product_open import EXPECTED_PDD_VERSION
from .product_url import ProductUrl, parse_product_url from .product_url import ProductUrl, ProductUrlError, parse_product_url
from .sku_selection import ( from .sku_selection import (
EXPECTED_GOODS_ID, EXPECTED_GOODS_ID,
EXPECTED_UNIT_PRICE, EXPECTED_UNIT_PRICE,
SkuPanelDevice, SkuPanelDevice,
SkuSelectionError, SkuSelectionError,
SkuSelectionFlow, SkuSelectionFlow,
_SKU_ENTRY_FAILURE_STAGES,
_REVEAL_GESTURE,
_ENTRY_TEXT_BOUNDS,
_TARGET_COLOR_UNROLLED_BOUNDS,
_TARGET_SIZE_BOUNDS,
_action_bounds, _action_bounds,
_annotate_sku_entry_failure,
_parse_nodes,
_safe_sku_entry_failure_stage,
_unit_price,
resolve_task_selection, resolve_task_selection,
) )
@@ -39,6 +54,25 @@ EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16" EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREEN_SIZE = (1080, 2376) EXPECTED_SCREEN_SIZE = (1080, 2376)
# CLI 只允许输出这些固定阶段码。阶段码描述运行器自己的控制流,不包含页面
# 文本、节点属性、serial、路径或第三方异常;未知/伪造值统一降级为 unknown。
_FAILURE_STAGES = frozenset(
(
"precheck",
"device_inspection",
"device_session",
"product_open",
"sku_entry",
*_SKU_ENTRY_FAILURE_STAGES,
"sku_selection",
"price_verification",
"screenshot_capture",
"screenshot_reverify",
"safe_exit",
"publish",
)
)
class SkuSelectionRunError(RuntimeError): class SkuSelectionRunError(RuntimeError):
"""T-103 运行未完整完成;错误文本不携带设备或页面原文。""" """T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
@@ -60,6 +94,52 @@ class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
"""第三方设备接口失败的脱敏映射。""" """第三方设备接口失败的脱敏映射。"""
class SkuExitSpikeError(SkuSelectionRunError):
"""T-104 阶段 A 未形成完整的本机退出证据。"""
def safe_failure_stage(error: BaseException) -> str:
"""返回允许公开的固定阶段码,绝不回显异常正文。"""
try:
stage = getattr(error, "_cmbuyer_failure_stage", None)
# exact str 避免恶意 str 子类在 hash/eq 中执行任意异常;诊断路径
# 自己也必须失败闭合,不能让异常正文越过 CLI 的统一脱敏出口。
if type(stage) is not str or stage not in _FAILURE_STAGES:
return "unknown"
if stage in _SKU_ENTRY_FAILURE_STAGES:
return stage if _safe_sku_entry_failure_stage(error) == stage else "unknown"
return stage
except BaseException:
return "unknown"
def _annotate_failure(error: BaseException, stage: str) -> None:
"""只给本次异常附加白名单控制流事实;原异常文本仍不对外输出。"""
safe_stage = stage if stage in _FAILURE_STAGES else "unknown"
try:
setattr(error, "_cmbuyer_failure_stage", safe_stage)
except BaseException:
# 极端第三方异常不允许写属性时仍保持原失败闭合语义。
pass
def _failure_stage_for(error: BaseException, runner_stage: str) -> str:
if runner_stage == "sku_entry":
flow_stage = _safe_sku_entry_failure_stage(error)
if flow_stage is not None:
return flow_stage
return runner_stage
def _annotate_mapped_failure(mapped: BaseException, source: BaseException, runner_stage: str) -> None:
stage = _failure_stage_for(source, runner_stage)
if stage in _SKU_ENTRY_FAILURE_STAGES:
_annotate_sku_entry_failure(mapped, stage)
_annotate_failure(mapped, stage)
@dataclass(frozen=True) @dataclass(frozen=True)
class SkuSelectionRunResult: class SkuSelectionRunResult:
"""已发布的截图和无页面正文 manifest 摘要。""" """已发布的截图和无页面正文 manifest 摘要。"""
@@ -68,12 +148,21 @@ class SkuSelectionRunResult:
screenshot_path: Path screenshot_path: Path
manifest_path: Path manifest_path: Path
unit_price: str unit_price: str
captured_at: datetime
@dataclass(frozen=True)
class SkuExitSpikeResult:
"""已原子发布、仍需人工判断页面身份的 T-104 证据。"""
output_directory: Path
manifest_path: Path
class UiautomatorSkuPanelAdapter(SkuPanelDevice): class UiautomatorSkuPanelAdapter(SkuPanelDevice):
"""把 uiautomator2 缩为 T-103 所需的读取与三种命名操作。 """把 uiautomator2 缩为 T-103 所需的读取与四种命名操作。
``tap_sku_entry``、``tap_sku_option`` 和 ``leave_sku_panel`` 是仅有的状态改变方法; 四个命名方法是仅有的状态改变入口;reveal 的手势参数固定且不向 Flow 暴露;
坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。 坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。
""" """
@@ -83,7 +172,14 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
self._device = device self._device = device
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
self._entry_was_tapped = False self._entry_was_tapped = False
self._entry_bounds_attempted: str | None = None
self._entry_rpc_outcome = "not_attempted"
self._option_bounds_attempted: set[str] = set()
self._option_rpc_outcomes: dict[str, str] = {}
self._reveal_attempted = False
self._reveal_rpc_outcome = "not_attempted"
self._left_panel = False self._left_panel = False
self._back_rpc_outcome = "not_attempted"
@property @property
def entry_was_tapped(self) -> bool: def entry_was_tapped(self) -> bool:
@@ -95,6 +191,38 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
def left_panel(self) -> bool: def left_panel(self) -> bool:
return self._left_panel return self._left_panel
@property
def reveal_attempted(self) -> bool:
return self._reveal_attempted
@property
def entry_rpc_outcome(self) -> str:
return self._entry_rpc_outcome
@property
def entry_bounds_attempted(self) -> str | None:
return self._entry_bounds_attempted
@property
def option_rpc_outcomes(self) -> tuple[tuple[str, str], ...]:
return tuple(sorted(self._option_rpc_outcomes.items()))
@property
def reveal_rpc_outcome(self) -> str:
return self._reveal_rpc_outcome
@property
def option_attempts(self) -> int:
return len(self._option_bounds_attempted)
@property
def back_attempts(self) -> int:
return int(self._left_panel)
@property
def back_rpc_outcome(self) -> str:
return self._back_rpc_outcome
def app_info(self, package_name: str) -> dict[str, Any]: def app_info(self, package_name: str) -> dict[str, Any]:
value = self._call("app_info", package_name) value = self._call("app_info", package_name)
if not isinstance(value, dict): if not isinstance(value, dict):
@@ -114,19 +242,46 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
return value return value
def tap_sku_entry(self, bounds: str) -> None: def tap_sku_entry(self, bounds: str) -> None:
if self._entry_was_tapped:
raise SkuSelectionDeviceAdapterError("规格入口已经尝试过,拒绝重试。")
# 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。 # 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。
self._entry_was_tapped = True self._entry_was_tapped = True
self._entry_bounds_attempted = bounds
self._entry_rpc_outcome = "ambiguous"
self._tap_bounds_once(bounds) self._tap_bounds_once(bounds)
self._entry_rpc_outcome = "completed"
def tap_sku_option(self, bounds: str) -> None: def tap_sku_option(self, bounds: str) -> None:
if bounds in self._option_bounds_attempted:
raise SkuSelectionDeviceAdapterError("同一规格选项已经尝试过,拒绝重试。")
# 规格 RPC 也可能送达后超时;按 exact bounds 封存本次唯一机会。
self._option_bounds_attempted.add(bounds)
self._option_rpc_outcomes[bounds] = "ambiguous"
self._tap_bounds_once(bounds) self._tap_bounds_once(bounds)
self._option_rpc_outcomes[bounds] = "completed"
def reveal_size_options_once(self) -> None:
if self._reveal_attempted:
raise SkuSelectionDeviceAdapterError("规格显示动作已经尝试过,拒绝重试。")
# 手势唯一机会在 RPC 前封存;生产 API 不接受方向、坐标或步数参数。
self._reveal_attempted = True
self._reveal_rpc_outcome = "ambiguous"
self._call(
"jsonrpc_call",
"swipe",
list(_REVEAL_GESTURE),
timeout=self._timeout_seconds,
)
self._reveal_rpc_outcome = "completed"
def leave_sku_panel(self) -> None: def leave_sku_panel(self) -> None:
if self._left_panel: if self._left_panel:
raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。") raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。")
# 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。 # 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。
self._left_panel = True self._left_panel = True
self._back_rpc_outcome = "ambiguous"
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds) self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds)
self._back_rpc_outcome = "completed"
def capture_screenshot(self) -> str: def capture_screenshot(self) -> str:
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds) value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds)
@@ -158,6 +313,210 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
raise SkuSelectionDeviceAdapterError("规格面板设备操作失败,已停止操作。") from error raise SkuSelectionDeviceAdapterError("规格面板设备操作失败,已停止操作。") from error
class UiautomatorSkuExitAdapter:
"""T-104 阶段 A 的窄设备边界:只读能力加唯一一次命名 Back。
取证脚本不能取得 T-103 的入口、规格选项或 reveal 方法。Back 的唯一机会在
JSON-RPC 前封存,因为超时无法证明事件没有送达,任何结果不明都不得重发。
"""
def __init__(self, device: Any, timeout_seconds: float) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._device = device
self._timeout_seconds = timeout_seconds
self._back_attempted = False
self._back_rpc_outcome = "not_attempted"
@property
def back_attempts(self) -> int:
return int(self._back_attempted)
@property
def back_rpc_outcome(self) -> str:
return self._back_rpc_outcome
def app_info(self, package_name: str) -> dict[str, Any]:
value = self._call("app_info", package_name)
if not isinstance(value, dict):
raise SkuSelectionDeviceAdapterError("无法读取应用版本,已停止取证。")
return value
def app_current(self) -> dict[str, Any]:
value = self._call("app_current")
if not isinstance(value, dict):
raise SkuSelectionDeviceAdapterError("无法读取前台应用,已停止取证。")
return value
def dump_window_hierarchy(self) -> str:
value = self._call(
"jsonrpc_call",
"dumpWindowHierarchy",
[False, 50],
timeout=self._timeout_seconds,
)
if not isinstance(value, str):
raise SkuSelectionDeviceAdapterError("节点树读取失败,已停止取证。")
return value
def capture_screenshot(self) -> str:
value = self._call(
"jsonrpc_call",
"takeScreenshot",
SCREENSHOT_PARAMS,
timeout=self._timeout_seconds,
)
if not isinstance(value, str):
raise SkuSelectionScreenshotError("退出后截图读取失败,未发布任何证据产物。")
return value
def display_size(self) -> tuple[int, int]:
value = self._call("window_size")
if (
not isinstance(value, tuple)
or len(value) != 2
or any(not isinstance(item, int) for item in value)
):
raise SkuSelectionDeviceAdapterError("无法读取屏幕坐标空间,已停止取证。")
return value
def leave_sku_panel(self) -> None:
if self._back_attempted:
raise SkuSelectionDeviceAdapterError("本次取证已经尝试过返回,拒绝重试。")
self._back_attempted = True
self._back_rpc_outcome = "ambiguous"
self._call(
"jsonrpc_call",
"pressKey",
["back"],
timeout=self._timeout_seconds,
)
self._back_rpc_outcome = "completed"
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
try:
operation = getattr(self._device, method)
return operation(*args, **kwargs)
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
raise SkuSelectionRunTimeoutError("T-104 设备操作超时,已停止操作。") from error
except SkuSelectionRunError:
raise
except Exception as error:
raise SkuSelectionDeviceAdapterError("T-104 设备操作失败,已停止操作。") from error
class SkuExitSpikeCapturer:
"""从人工停驻的已验证目标面板执行一次 Back,再只读采集阶段 A 证据。"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], Any],
timeout_seconds: float,
) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._adb_client = adb_client
self._connector = connector
self._timeout_seconds = timeout_seconds
self._started = False
def capture(self, serial: str, output_directory: Path) -> SkuExitSpikeResult:
if self._started:
raise SkuExitSpikeError("同一取证器不可重复调用。")
self._started = True
staging: Path | None = None
adapter: UiautomatorSkuExitAdapter | None = None
try:
if type(serial) is not str or not serial.strip():
raise SkuExitSpikeError("必须显式提供非空设备通道。")
target = Path(output_directory)
_validate_new_target(target)
staging = _prepare_staging(target)
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
adapter = UiautomatorSkuExitAdapter(
self._connector(serial),
self._timeout_seconds,
)
# 第一次只读核验拒绝把任意页面带入 Back 边界;第二次紧邻 Back,覆盖核验期间漂移。
_require_t104_exit_precondition(adapter)
_require_t104_exit_precondition(adapter)
rpc_outcome = "completed"
try:
adapter.leave_sku_panel()
except SkuSelectionRunError:
if adapter.back_attempts != 1 or adapter.back_rpc_outcome != "ambiguous":
raise
# RPC 失败不能证明 Back 未送达。只读采集可供人调和,但绝不再发第二次动作。
rpc_outcome = "ambiguous_reconciled"
if adapter.back_attempts != 1:
raise SkuExitSpikeError("返回动作审计不完整,未发布任何证据产物。")
if rpc_outcome == "completed" and adapter.back_rpc_outcome != "completed":
raise SkuExitSpikeError("返回动作结果不完整,未发布任何证据产物。")
# Back 后先确认仍是已取证版本的 PDD;不能先把其他前台应用写进本地证据。
initial_app = _require_t104_post_app(adapter)
screenshot_path = staging / "post_exit_screenshot.png"
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
_require_screenshot_size(screenshot_path)
hierarchy = adapter.dump_window_hierarchy()
_validate_hierarchy(hierarchy)
hierarchy_path = staging / "post_exit_hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
current_app = _require_t104_post_app(adapter)
if current_app != initial_app:
raise SkuExitSpikeError("退出后应用摘要在采集期间漂移,未发布任何证据产物。")
app_path = staging / "post_exit_app.json"
app_path.write_text(
json.dumps(current_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_t104_exit_manifest(
inspection,
serial,
rpc_outcome,
screenshot_path,
hierarchy_path,
app_path,
),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.rename(staging, target)
staging = None
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError):
_clean_staging(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
raise SkuExitSpikeError("T-104 取证超时,未发布任何证据产物。") from error
except OSError as error:
_clean_staging(staging)
raise SkuExitSpikeError("T-104 证据目录无法发布,未发布任何证据产物。") from error
except Exception as error:
_clean_staging(staging)
raise SkuExitSpikeError("T-104 取证未完成,未发布任何证据产物。") from error
return SkuExitSpikeResult(target, target / "manifest.json")
class SkuSelectionRunner: class SkuSelectionRunner:
"""只运行 T-103 目标规格恢复、价格确认、原始截图和一次安全退出。""" """只运行 T-103 目标规格恢复、价格确认、原始截图和一次安全退出。"""
@@ -183,41 +542,57 @@ class SkuSelectionRunner:
task_size: str, task_size: str,
output_directory: Path, output_directory: Path,
) -> SkuSelectionRunResult: ) -> SkuSelectionRunResult:
stage = "precheck"
staging: Path | None = None
adapter: UiautomatorSkuPanelAdapter | None = None
flow: SkuSelectionFlow | None = None
try:
link = parse_product_url(product_url) link = parse_product_url(product_url)
if link.goods_id != EXPECTED_GOODS_ID: if link.goods_id != EXPECTED_GOODS_ID:
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。") raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
selection = resolve_task_selection(task_color, task_size) selection = resolve_task_selection(task_color, task_size)
target = Path(output_directory) target = Path(output_directory)
_validate_new_target(target) _validate_new_target(target)
adapter: UiautomatorSkuPanelAdapter | None = None
flow: SkuSelectionFlow | None = None
staging = _prepare_staging(target) staging = _prepare_staging(target)
deadline = self._monotonic_clock() + self._timeout_seconds deadline = self._monotonic_clock() + self._timeout_seconds
try:
stage = "device_inspection"
inspection = self._adb_client.inspect(serial) inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection) _require_expected_device(inspection)
stage = "device_session"
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds) adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
_require_expected_version(adapter.app_info(PDD_PACKAGE)) _require_expected_version(adapter.app_info(PDD_PACKAGE))
if adapter.display_size() != EXPECTED_SCREEN_SIZE: if adapter.display_size() != EXPECTED_SCREEN_SIZE:
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。") raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
pre_intent_hierarchy = adapter.dump_window_hierarchy() pre_intent_hierarchy = adapter.dump_window_hierarchy()
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。 # 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
stage = "product_open"
self._adb_client.start_pdd_view_intent(serial, link.goods_id) self._adb_client.start_pdd_view_intent(serial, link.goods_id)
remaining = deadline - self._monotonic_clock() remaining = deadline - self._monotonic_clock()
if remaining <= 0: if remaining <= 0:
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。") raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
stage = "sku_entry"
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining) flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy) flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
stage = "sku_selection"
flow.select_sku_options(selection) flow.select_sku_options(selection)
stage = "price_verification"
unit_price = flow.verify_target_selection_and_read_price(selection) unit_price = flow.verify_target_selection_and_read_price(selection)
if unit_price != EXPECTED_UNIT_PRICE: if unit_price != EXPECTED_UNIT_PRICE:
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。") raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
stage = "screenshot_capture"
screenshot_path = staging / "screenshot.png" screenshot_path = staging / "screenshot.png"
try: try:
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path) screenshot_payload = adapter.capture_screenshot()
captured_at = datetime.now(UTC)
_save_base64_screenshot(screenshot_payload, screenshot_path)
_require_screenshot_size(screenshot_path) _require_screenshot_size(screenshot_path)
except SkuSelectionRunError: except SkuSelectionRunError:
raise raise
@@ -226,36 +601,63 @@ class SkuSelectionRunner:
manifest_path = staging / "manifest.json" manifest_path = staging / "manifest.json"
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。 # 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
stage = "screenshot_reverify"
final_price = flow.verify_target_selection_and_read_price(selection) final_price = flow.verify_target_selection_and_read_price(selection)
if final_price != EXPECTED_UNIT_PRICE: if final_price != EXPECTED_UNIT_PRICE:
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。") raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。 # 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
stage = "safe_exit"
flow.exit_sku_panel_safely() flow.exit_sku_panel_safely()
_require_completed_action_audit(adapter)
stage = "publish"
manifest_path.write_text( manifest_path.write_text(
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n", json.dumps(
_manifest(
inspection,
serial,
link,
screenshot_path,
task_color,
task_size,
adapter,
captured_at,
),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8", encoding="utf-8",
) )
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。 # Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
os.rename(staging, target) os.rename(staging, target)
staging = None staging = None
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError): except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
_clean_staging(staging) _clean_staging(staging)
_annotate_failure(error, _failure_stage_for(error, stage))
raise raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error: except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging) _clean_staging(staging)
raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
_annotate_mapped_failure(mapped, error, stage)
raise mapped from error
except OSError as error: except OSError as error:
_clean_staging(staging) _clean_staging(staging)
raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
_annotate_mapped_failure(mapped, error, stage)
raise mapped from error
except Exception as error: except Exception as error:
_clean_staging(staging) _clean_staging(staging)
raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
_annotate_mapped_failure(mapped, error, stage)
raise mapped from error
finally: finally:
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。 # 结果不明只允许读回 pending;失败路径绝不继续后续动作或自动 Back。
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel: if flow is not None:
try: try:
flow.reconcile_pending_action() flow.reconcile_pending_action()
flow.exit_sku_panel_safely()
except (SkuSelectionRunError, SkuSelectionError): except (SkuSelectionRunError, SkuSelectionError):
pass pass
@@ -264,6 +666,7 @@ class SkuSelectionRunner:
screenshot_path=target / "screenshot.png", screenshot_path=target / "screenshot.png",
manifest_path=target / "manifest.json", manifest_path=target / "manifest.json",
unit_price=EXPECTED_UNIT_PRICE, unit_price=EXPECTED_UNIT_PRICE,
captured_at=captured_at,
) )
@@ -310,6 +713,87 @@ def _require_expected_device(inspection: DeviceInspection) -> None:
raise SkuSelectionRunError("设备型号或 Android 版本不是已取证组合,已停止操作。") raise SkuSelectionRunError("设备型号或 Android 版本不是已取证组合,已停止操作。")
def _require_t104_exit_precondition(adapter: UiautomatorSkuExitAdapter) -> None:
"""fresh 核验 T-103 已人验的唯一 Back 前置,不产生任何页面动作。"""
_require_expected_version(adapter.app_info(PDD_PACKAGE))
current = adapter.app_current()
if current.get("package") != PDD_PACKAGE:
raise SkuExitSpikeError("Back 前拼多多不在前台,已停止取证。")
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
raise SkuExitSpikeError("屏幕坐标空间不是已取证尺寸,已停止取证。")
if _unit_price(_parse_nodes(adapter.dump_window_hierarchy())) != EXPECTED_UNIT_PRICE:
raise SkuExitSpikeError("Back 前目标规格或当前价不匹配,已停止取证。")
def _post_exit_app_evidence(value: object) -> dict[str, str]:
"""只保留页面身份复核需要的应用字段,不把第三方返回整体写盘。"""
if not isinstance(value, dict):
raise SkuExitSpikeError("退出后应用摘要无效,未发布任何证据产物。")
package = value.get("package")
activity = value.get("activity")
if not isinstance(package, str) or not package.strip():
raise SkuExitSpikeError("退出后应用包名缺失,未发布任何证据产物。")
if not isinstance(activity, str) or not activity.strip():
raise SkuExitSpikeError("退出后 Activity 缺失,未发布任何证据产物。")
return {"package": package, "activity": activity}
def _require_t104_post_app(adapter: UiautomatorSkuExitAdapter) -> dict[str, str]:
current = _post_exit_app_evidence(adapter.app_current())
if current["package"] != PDD_PACKAGE:
raise SkuExitSpikeError("Back 后拼多多不在前台,未采集页面证据。")
_require_expected_version(adapter.app_info(PDD_PACKAGE))
return current
def _t104_exit_manifest(
inspection: DeviceInspection,
serial: str,
rpc_outcome: str,
screenshot_path: Path,
hierarchy_path: Path,
app_path: Path,
) -> dict[str, Any]:
"""阶段 A 只写事实与哈希;不声明退出成功,也不授予价格证据角色。"""
return {
"schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(),
"operation": "t104-sku-exit-evidence",
"product": {"goods_id": EXPECTED_GOODS_ID},
"channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": {
"model": inspection.model,
"android_version": inspection.android_version,
"pdd_package": PDD_PACKAGE,
"pdd_version": EXPECTED_PDD_VERSION,
},
"back_attempts": 1,
"rpc_outcome": rpc_outcome,
"post_exit_status": "human_review_required",
"artifacts": [
{
"path": screenshot_path.name,
"role": "post_exit_human_review_only",
"sha256": _sha256_file(screenshot_path),
},
{
"path": hierarchy_path.name,
"role": "post_exit_raw_hierarchy_local_only",
"sha256": _sha256_file(hierarchy_path),
},
{
"path": app_path.name,
"role": "post_exit_app_identity_human_review_only",
"sha256": _sha256_file(app_path),
},
],
}
def _require_screenshot_size(screenshot_path: Path) -> None: def _require_screenshot_size(screenshot_path: Path) -> None:
try: try:
with Image.open(screenshot_path) as image: with Image.open(screenshot_path) as image:
@@ -322,20 +806,83 @@ def _require_screenshot_size(screenshot_path: Path) -> None:
raise SkuSelectionScreenshotError("原始截图无效,未发布任何证据产物。") from error raise SkuSelectionScreenshotError("原始截图无效,未发布任何证据产物。") from error
def _manifest(inspection: DeviceInspection, serial: str, link: ProductUrl, screenshot_path: Path, task_color: str, task_size: str) -> dict[str, Any]: def _require_completed_action_audit(adapter: UiautomatorSkuPanelAdapter) -> None:
"""发布只接受完整正常链;测试替身或缺动作结果不能伪造真机闭环。"""
if (
not adapter.entry_was_tapped
or adapter.entry_bounds_attempted != _ENTRY_TEXT_BOUNDS
or adapter.entry_rpc_outcome != "completed"
or adapter.option_rpc_outcomes
!= tuple(
sorted(
(
(_TARGET_COLOR_UNROLLED_BOUNDS, "completed"),
(_TARGET_SIZE_BOUNDS, "completed"),
)
)
)
or not adapter.reveal_attempted
or adapter.reveal_rpc_outcome != "completed"
or adapter.back_attempts != 1
or adapter.back_rpc_outcome != "completed"
):
raise SkuSelectionRunError("规格动作审计链不完整,未发布任何证据产物。")
def _manifest(
inspection: DeviceInspection,
serial: str,
link: ProductUrl,
screenshot_path: Path,
task_color: str,
task_size: str,
adapter: UiautomatorSkuPanelAdapter,
captured_at: datetime,
) -> dict[str, Any]:
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。""" """仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
option_outcomes = dict(adapter.option_rpc_outcomes)
return { return {
"schema_version": 1, "schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(), "captured_at": captured_at.isoformat(),
"operation": "t103-sku-selection", "operation": "t103-sku-selection",
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url}, "product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
"target_selection": {"color": task_color, "size": task_size}, "target_selection": {"color": task_color, "size": task_size},
"unit_price": EXPECTED_UNIT_PRICE, "unit_price": EXPECTED_UNIT_PRICE,
"selection_status": "restored", "selection_status": "restored",
"panel_status": "verified", "panel_status": "verified_before_back",
"back_attempts": adapter.back_attempts,
"back_rpc_outcome": adapter.back_rpc_outcome,
"actions": {
"sku_entry": {
"attempts": int(adapter.entry_was_tapped),
"rpc_outcome": adapter.entry_rpc_outcome,
},
"target_color": {
"attempts": int(_TARGET_COLOR_UNROLLED_BOUNDS in option_outcomes),
"rpc_outcome": option_outcomes.get(
_TARGET_COLOR_UNROLLED_BOUNDS, "not_attempted"
),
},
"size_reveal": {
"attempts": int(adapter.reveal_attempted),
"rpc_outcome": adapter.reveal_rpc_outcome,
},
"target_size": {
"attempts": int(_TARGET_SIZE_BOUNDS in option_outcomes),
"rpc_outcome": option_outcomes.get(
_TARGET_SIZE_BOUNDS, "not_attempted"
),
},
"back": {
"attempts": adapter.back_attempts,
"rpc_outcome": adapter.back_rpc_outcome,
},
},
"post_exit_status": "same_product_verified",
"safe_exit": "completed", "safe_exit": "completed",
"page_identity": "human_review_required", "page_identity": "same_goods_evidence_bound",
"channel": "wifi" if ":" in serial else "usb", "channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(), "serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": { "device": {
@@ -0,0 +1,5 @@
"""采购工具轮询会话协调器。"""
from .coordinator import ClaimedTaskView, PollingCoordinator, PollingState, RecoveryStatus, StartReadiness
__all__ = ["ClaimedTaskView", "PollingCoordinator", "PollingState", "RecoveryStatus", "StartReadiness"]
@@ -0,0 +1,589 @@
"""在 Qt 事件循环中协调可恢复的领取会话。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
from PySide6.QtCore import QObject, QThread, QTimer, Signal, Slot
from cmbuyer_client.core.errors import (
AmbiguousRemoteError,
ClientError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
StateError,
)
from cmbuyer_client.core.models import ClaimedTask
from cmbuyer_client.localstate.models import PollingSession, ProfileSettings, RecoverySnapshot
from cmbuyer_client.logging_policy import redact_text
SAFE_AMBIGUOUS_REASONS = frozenset(
("http_result_unknown", "server_result_unknown", "truncated_response")
)
class PollingState(str, Enum):
STOPPED = "STOPPED"
STARTING = "STARTING"
BLOCKED = "BLOCKED"
RECOVERING = "RECOVERING"
WAITING = "WAITING"
CLAIMING = "CLAIMING"
ACTIVE = "ACTIVE"
RECOVERY_REQUIRED = "RECOVERY_REQUIRED"
class PollingStore(Protocol):
def recovery_snapshot(self, profile_id: str) -> RecoverySnapshot: ...
def start_or_resume_polling(self, profile_id: str) -> PollingSession: ...
def request_stop(self, profile_id: str) -> PollingSession: ...
class ClaimGateway(Protocol):
def claim_next(self, profile_id: str) -> ClaimedTask | None: ...
class ExecutionConsumer(Protocol):
def accept_claim(self, claimed: ClaimedTask, profile: ProfileSettings) -> None: ...
@dataclass(frozen=True)
class StartReadiness:
"""由后续已取证执行能力注入;T-304 自己不探测网络或设备。"""
ready: bool
reason: str
@dataclass(frozen=True)
class ClaimedTaskView:
"""允许发往 UI 的最小投影,刻意不包含 authorization/claim token。"""
task_id: str
title: str
status: str = "已领取"
@classmethod
def from_claim(cls, claimed: ClaimedTask) -> "ClaimedTaskView":
return cls(task_id=claimed.task.id, title=redact_text(claimed.task.title))
@dataclass(frozen=True)
class RecoveryStatus:
"""可进入 UI 的恢复摘要;不携带 task snapshot、claim token 或密文。"""
has_open_session: bool
session_accept_new: bool
has_pending_claim: bool
has_active_claim: bool
has_pending_active_work: bool
@classmethod
def from_snapshot(cls, snapshot: RecoverySnapshot) -> "RecoveryStatus":
return cls(
has_open_session=snapshot.session is not None,
session_accept_new=bool(snapshot.session and snapshot.session.accept_new),
has_pending_claim=snapshot.pending_claim is not None,
has_active_claim=snapshot.active_claim is not None,
has_pending_active_work=bool(snapshot.pending_renew or snapshot.pending_evidence),
)
@dataclass(frozen=True)
class _BootstrapResult:
recovery: RecoveryStatus
session: PollingSession | None
normalized_stop: PollingSession | None = None
class _PollingWorker(QObject):
bootstrap_finished = Signal(object)
claim_finished = Signal(object)
stop_finished = Signal(object)
failed = Signal(str, object)
def __init__(self, store: PollingStore) -> None:
super().__init__()
self._store = store
self._gateway: ClaimGateway | None = None
@Slot(object)
def configure_gateway(self, gateway: object) -> None:
if not hasattr(gateway, "claim_next"):
self.failed.emit("configure", RuntimeError("invalid_claim_gateway"))
return
self._gateway = gateway # type: ignore[assignment]
@Slot(str)
def bootstrap(self, profile_id: str) -> None:
try:
snapshot = self._store.recovery_snapshot(profile_id)
recovery = RecoveryStatus.from_snapshot(snapshot)
if snapshot.session is not None and snapshot.session.accept_new:
stopped = self._store.request_stop(profile_id)
self.bootstrap_finished.emit(_BootstrapResult(recovery, None, stopped))
return
if snapshot.active_claim is not None:
self.bootstrap_finished.emit(_BootstrapResult(recovery, None))
return
session = self._store.start_or_resume_polling(profile_id)
self.bootstrap_finished.emit(_BootstrapResult(recovery, session))
except Exception as error:
self.failed.emit("bootstrap", error)
@Slot(str)
def inspect_restart(self, profile_id: str) -> None:
try:
snapshot = self._store.recovery_snapshot(profile_id)
recovery = RecoveryStatus.from_snapshot(snapshot)
stopped = None
if snapshot.session is not None and snapshot.session.accept_new:
stopped = self._store.request_stop(profile_id)
self.bootstrap_finished.emit(_BootstrapResult(recovery, None, stopped))
except Exception as error:
self.failed.emit("inspect", error)
@Slot(str)
def claim(self, profile_id: str) -> None:
try:
# DurableClientGateway 在返回前已经提交 EMPTY 或 active claim;UI 不能
# 以 generation 过期为由丢弃这个业务结果。
if self._gateway is None:
raise RuntimeError("claim_gateway_not_configured")
self.claim_finished.emit(self._gateway.claim_next(profile_id))
except Exception as error:
self.failed.emit("claim", error)
@Slot(str)
def stop(self, profile_id: str) -> None:
try:
self.stop_finished.emit(self._store.request_stop(profile_id))
except Exception as error:
self.failed.emit("stop", error)
class PollingCoordinator(QObject):
"""把计时、阻塞 I/O 和可见状态收敛到一个会话边界。"""
state_changed = Signal(object, str, int)
claim_visible = Signal(object)
recovery_status_changed = Signal(object)
configuration_freeze_changed = Signal(bool)
settled = Signal()
_configure_gateway_requested = Signal(object)
_bootstrap_requested = Signal(str)
_inspect_requested = Signal(str)
_claim_requested = Signal(str)
_stop_requested_signal = Signal(str)
def __init__(
self,
*,
profile_id: str,
store: PollingStore | None,
gateway_factory: Callable[[ProfileSettings], ClaimGateway] | None,
consumer: ExecutionConsumer | None,
profile_settings: ProfileSettings | None = None,
readiness: StartReadiness | None = None,
poll_interval_seconds: int = 15,
failure_threshold: int = 3,
timer_interval_ms: int | None = None,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
if not 5 <= poll_interval_seconds <= 300:
raise ValueError("invalid_poll_interval")
if not 1 <= failure_threshold <= 10:
raise ValueError("invalid_failure_threshold")
self.profile_id = profile_id
self._store = store
self._gateway_factory = gateway_factory
self._gateway: ClaimGateway | None = None
self._consumer = consumer
self._profile_settings = profile_settings
self._frozen_profile: ProfileSettings | None = None
self._readiness = readiness
self.recovery_status: RecoveryStatus | None = None
self._timer_interval_override = timer_interval_ms
self._interval_ms = timer_interval_ms or (
profile_settings.poll_interval_seconds * 1000 if profile_settings is not None else poll_interval_seconds * 1000
)
if self._interval_ms <= 0:
raise ValueError("invalid_timer_interval")
self._failure_threshold = (
profile_settings.failure_threshold if profile_settings is not None else failure_threshold
)
self._consecutive_failures = 0
self._operation: str | None = None
self._stop_requested = False
self._epoch = 0
self._scheduled_epoch: int | None = None
self._post_stop_state = PollingState.STOPPED
self._post_stop_reason = "轮询已停止。"
self._thread: QThread | None = None
self._worker: _PollingWorker | None = None
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._on_timer_timeout)
if consumer is None:
self.state = PollingState.BLOCKED
self.reason = "单趟执行能力尚未接入,不能领取真实任务。"
elif store is None or gateway_factory is None:
self.state = PollingState.BLOCKED
self.reason = "轮询依赖未完整注入,不能领取真实任务。"
elif profile_settings is None:
self.state = PollingState.BLOCKED
self.reason = "尚未保存完整配置,不能开始轮询。"
elif readiness is None or not readiness.ready:
self.state = PollingState.BLOCKED
self.reason = "执行就绪条件未满足,不能开始轮询。" if readiness is None else readiness.reason
else:
self.state = PollingState.STOPPED
self.reason = "轮询已停止。"
if store is not None:
self._thread = QThread(self)
self._worker = _PollingWorker(store)
self._worker.moveToThread(self._thread)
self._configure_gateway_requested.connect(self._worker.configure_gateway)
self._inspect_requested.connect(self._worker.inspect_restart)
self._bootstrap_requested.connect(self._worker.bootstrap)
self._claim_requested.connect(self._worker.claim)
self._stop_requested_signal.connect(self._worker.stop)
self._worker.bootstrap_finished.connect(self._on_bootstrap_finished)
self._worker.claim_finished.connect(self._on_claim_finished)
self._worker.stop_finished.connect(self._on_stop_finished)
self._worker.failed.connect(self._on_worker_failed)
self._thread.start()
self._operation = "inspect"
self._set_state(PollingState.STARTING, "正在读取重启恢复状态并关闭遗留自动领取许可…")
self._inspect_requested.emit(self.profile_id)
@property
def can_start(self) -> bool:
return (
self._consumer is not None
and self._store is not None
and self._gateway_factory is not None
and self._profile_settings is not None
and self._readiness is not None
and self._readiness.ready
and self.state == PollingState.STOPPED
and self._operation is None
)
@property
def consecutive_failures(self) -> int:
return self._consecutive_failures
@property
def operation_in_flight(self) -> bool:
return self._operation is not None
def update_profile_settings(self, settings: ProfileSettings) -> None:
if self._operation is None and self.state in (PollingState.STOPPED, PollingState.BLOCKED):
self._profile_settings = settings
self._refresh_idle_gate()
def update_readiness(self, readiness: StartReadiness) -> None:
self._readiness = readiness
self._refresh_idle_gate()
def _refresh_idle_gate(self) -> None:
if self._operation is not None or self.state == PollingState.RECOVERY_REQUIRED:
return
if self._consumer is None:
self._set_state(PollingState.BLOCKED, "单趟执行能力尚未接入,不能领取真实任务。")
elif self._gateway_factory is None or self._store is None:
self._set_state(PollingState.BLOCKED, "轮询依赖未完整注入,不能领取真实任务。")
elif self._profile_settings is None:
self._set_state(PollingState.BLOCKED, "尚未保存完整配置,不能开始轮询。")
elif self._readiness is None or not self._readiness.ready:
reason = "执行就绪条件未满足,不能开始轮询。" if self._readiness is None else self._readiness.reason
self._set_state(PollingState.BLOCKED, reason)
else:
self._set_state(PollingState.STOPPED, "轮询已停止。")
def start(self) -> None:
# 这道门禁必须早于任何 store/gateway 调用;独立应用没有 consumer,
# 即使调用方绕过禁用按钮直接调用本方法也保持零 HTTP。
if self._consumer is None:
self._set_state(PollingState.BLOCKED, "单趟执行能力尚未接入,不能领取真实任务。")
return
if self._store is None or self._gateway_factory is None or self._worker is None:
self._set_state(PollingState.BLOCKED, "轮询依赖未完整注入,不能领取真实任务。")
return
if self._profile_settings is None:
self._set_state(PollingState.BLOCKED, "尚未保存完整配置,不能开始轮询。")
return
if self._readiness is None or not self._readiness.ready:
reason = "执行就绪条件未满足,不能开始轮询。" if self._readiness is None else self._readiness.reason
self._set_state(PollingState.BLOCKED, reason)
return
if not self.can_start:
return
frozen = self._profile_settings
try:
gateway = self._gateway_factory(frozen)
except Exception:
self._set_state(PollingState.BLOCKED, "领取网关无法按本次冻结配置建立,不能开始轮询。")
return
if gateway is None or not hasattr(gateway, "claim_next"):
self._set_state(PollingState.BLOCKED, "领取网关未完整注入,不能开始轮询。")
return
self._frozen_profile = frozen
self._interval_ms = self._timer_interval_override or frozen.poll_interval_seconds * 1000
self._failure_threshold = frozen.failure_threshold
self._gateway = gateway
self._configure_gateway_requested.emit(gateway)
self._epoch += 1
self._scheduled_epoch = None
self._timer.stop()
self._stop_requested = False
self._post_stop_state = PollingState.STOPPED
self._consecutive_failures = 0
self._operation = "bootstrap"
self._set_state(PollingState.STARTING, "正在读取本地恢复状态并建立轮询会话…")
self._bootstrap_requested.emit(self.profile_id)
def stop(self) -> None:
if self.state == PollingState.STOPPED and self._operation is None:
return
if self._store is None or self._worker is None:
return
# epoch/latch 双保险:stopEvent 先让 timer 队列中已经排队的 timeout
# 失效,再处理持久 stop;之后只有显式 Start 才会获得新 epoch。
self._epoch += 1
self._scheduled_epoch = None
self._stop_requested = True
self._timer.stop()
if self._operation in ("inspect", "bootstrap", "claim", "stop"):
if self._operation == "claim":
self._set_state(PollingState.CLAIMING, "正在等待本次有界领取返回;不会取消或重发请求。")
return
self._request_stop(PollingState.STOPPED, "轮询已停止;只阻止下一次领取。")
def shutdown(self, wait_ms: int = 5000) -> bool:
"""只结束空闲 worker;飞行中 I/O 必须由事件循环等待 settled。"""
self._timer.stop()
if self._operation is not None:
return False
if self._thread is not None and self._thread.isRunning():
self._thread.quit()
return self._thread.wait(wait_ms)
return True
@Slot(object)
def _on_bootstrap_finished(self, raw: object) -> None:
completed_operation = self._operation
self._operation = None
result = raw
if not isinstance(result, _BootstrapResult):
self._block("本地恢复结果无效,轮询已阻止。")
return
self.recovery_status = result.recovery
self.recovery_status_changed.emit(result.recovery)
self.configuration_freeze_changed.emit(
result.recovery.has_pending_claim or result.recovery.has_active_claim
)
if completed_operation == "inspect":
if result.recovery.has_active_claim:
self._set_state(PollingState.RECOVERY_REQUIRED, "遗留会话已停止;必须先安全恢复当前任务。")
elif result.recovery.has_pending_claim:
self._set_state(PollingState.STOPPED, "遗留领取请求已停止;显式开始后只使用原幂等键恢复。")
else:
self._refresh_idle_gate()
self.settled.emit()
return
if result.normalized_stop is not None:
if result.recovery.has_active_claim:
self._set_state(PollingState.RECOVERY_REQUIRED, "遗留会话已停止;必须先安全恢复当前任务。")
else:
self._set_state(PollingState.STOPPED, "遗留会话已停止;请再次显式开始轮询。")
self.settled.emit()
return
if result.recovery.has_active_claim:
self._set_state(
PollingState.RECOVERY_REQUIRED,
"检测到未关闭的采购任务,必须先完成安全恢复,不能领取新任务。",
)
self.settled.emit()
return
if self._stop_requested:
self._request_stop(PollingState.STOPPED, "轮询已停止;未发起领取请求。")
return
if result.recovery.has_pending_claim:
self._set_state(PollingState.RECOVERING, "正在使用原幂等键恢复结果不明的领取请求…")
else:
self._set_state(PollingState.WAITING, "轮询会话已启动,正在等待领取。")
self._schedule_claim(0)
def _schedule_claim(self, delay_ms: int) -> None:
self._scheduled_epoch = self._epoch
self._timer.start(delay_ms)
@Slot()
def _on_timer_timeout(self) -> None:
scheduled_epoch = self._scheduled_epoch
self._scheduled_epoch = None
if scheduled_epoch != self._epoch:
return
self._begin_claim(scheduled_epoch)
def _begin_claim(self, dispatch_epoch: int) -> None:
if dispatch_epoch != self._epoch:
return
if self._operation is not None or self._stop_requested:
return
if self.state not in (PollingState.WAITING, PollingState.RECOVERING):
return
self._operation = "claim"
self.configuration_freeze_changed.emit(True)
self._set_state(PollingState.CLAIMING, "正在领取已授权任务…")
self._claim_requested.emit(self.profile_id)
@Slot(object)
def _on_claim_finished(self, claimed: object) -> None:
self._operation = None
self._consecutive_failures = 0
if claimed is not None and not isinstance(claimed, ClaimedTask):
self._request_stop(PollingState.BLOCKED, "领取结果类型无效,轮询已阻止。")
return
if isinstance(claimed, ClaimedTask):
self.claim_visible.emit(ClaimedTaskView.from_claim(claimed))
if self._stop_requested:
self._request_stop(
PollingState.RECOVERY_REQUIRED,
"停止期间领取已落库;必须先安全恢复该任务,不能领取下一条。",
)
return
try:
consumer = self._consumer
frozen_profile = self._frozen_profile
if consumer is None or frozen_profile is None:
raise RuntimeError("execution_consumer_missing")
# consumer 只能使用本次显式 Start 冻结的不可变配置;不得在
# 已领取后回读可变 UI/store,否则 ADB 身份和超时会发生趟内漂移。
consumer.accept_claim(claimed, frozen_profile)
except Exception:
self._request_stop(
PollingState.RECOVERY_REQUIRED,
"执行 consumer 未接收已落库任务;必须安全恢复,不能重新领取。",
)
return
self._set_state(PollingState.ACTIVE, "任务已安全领取并交给单趟执行能力。")
return
self.configuration_freeze_changed.emit(False)
if self._stop_requested:
self._request_stop(PollingState.STOPPED, "轮询已停止;本次没有可领取任务。")
return
self._set_state(PollingState.WAITING, "暂无已授权任务,等待下一次轮询。")
self._schedule_claim(self._interval_ms)
def _request_stop(self, target: PollingState, reason: str) -> None:
if self._operation == "stop":
return
self._timer.stop()
self._post_stop_state = target
self._post_stop_reason = reason
self._operation = "stop"
self._stop_requested_signal.emit(self.profile_id)
@Slot(object)
def _on_stop_finished(self, session: object) -> None:
self._operation = None
if not isinstance(session, PollingSession) or session.accept_new:
self._block("停止状态未能持久化,轮询已阻止。")
return
self._set_state(self._post_stop_state, self._post_stop_reason)
self.settled.emit()
@Slot(str, object)
def _on_worker_failed(self, operation: str, error: object) -> None:
self._operation = None
if operation == "inspect":
if isinstance(error, StateError) and error.reason == "profile_not_found":
self.recovery_status = RecoveryStatus(False, False, False, False, False)
self.recovery_status_changed.emit(self.recovery_status)
self._refresh_idle_gate()
else:
self._block("本地恢复状态无法安全读取;已停止且不能领取任务。")
self.settled.emit()
return
if (
operation == "claim"
and isinstance(error, AmbiguousRemoteError)
and error.reason in SAFE_AMBIGUOUS_REASONS
):
self._consecutive_failures += 1
if self._stop_requested:
self._request_stop(PollingState.STOPPED, "轮询已停止;结果不明的原领取请求已保留。")
elif self._consecutive_failures >= self._failure_threshold:
self._request_stop(
PollingState.BLOCKED,
"连续领取失败达到阈值;原幂等请求已保留,需排查后重新开始。",
)
else:
self._set_state(
PollingState.RECOVERING,
"领取结果不明;等待使用相同幂等键恢复,不会创建新请求。",
)
self._schedule_claim(self._interval_ms)
return
if operation == "claim" and isinstance(error, AmbiguousRemoteError):
self.configuration_freeze_changed.emit(True)
self._request_stop(
PollingState.BLOCKED,
"领取响应无法证明可安全定时恢复;原槽已保留,需显式开始后同键恢复。",
)
return
if operation == "stop":
self._block("停止状态无法安全落库,轮询已阻止;未清除任何恢复事实。")
return
if isinstance(error, CredentialRemoteError):
reason = "设备凭据无效或已撤销;修复凭据后再手工开始。"
elif isinstance(error, ProtocolRemoteError):
reason = "服务响应与固定协议不兼容;已停止普通重试。"
elif isinstance(error, ManualRemoteError):
reason = "服务端要求人工处理;已停止普通重试。"
elif isinstance(error, ClientError):
reason = "本地安全状态无法推进;已停止普通重试。"
else:
reason = "轮询发生未分类错误;已失败闭合。"
if operation == "claim":
# DurableClientGateway 已把协议错误和 409 人工冲突标成 terminal,
# 二者没有 pending/active;凭据或本地错误则可能保留 pending,继续冻结。
self.configuration_freeze_changed.emit(
not isinstance(error, (ProtocolRemoteError, ManualRemoteError))
)
if operation in ("bootstrap", "configure"):
self._block(reason)
else:
self._request_stop(PollingState.BLOCKED, reason)
def _block(self, reason: str) -> None:
self._timer.stop()
self._scheduled_epoch = None
self._set_state(PollingState.BLOCKED, reason)
if self._operation is None:
self.settled.emit()
def _set_state(self, state: PollingState, reason: str) -> None:
self.state = state
self.reason = reason
self.state_changed.emit(state, reason, self._consecutive_failures)
@@ -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
+106
View File
@@ -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")
+62 -2
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import os import os
from pathlib import Path from pathlib import Path
from typing import Any, Callable
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -17,21 +18,34 @@ class RuntimePaths:
root: Path root: Path
logs: Path logs: Path
artifacts: Path artifacts: Path
state: Path
database: Path
@classmethod @classmethod
def from_root(cls, root: Path) -> "RuntimePaths": 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( return cls(
root=resolved_root, root=resolved_root,
logs=resolved_root / "logs", logs=resolved_root / "logs",
artifacts=resolved_root / "artifacts", artifacts=resolved_root / "artifacts",
state=state,
database=state / "client-state.sqlite3",
) )
@classmethod @classmethod
def default(cls) -> "RuntimePaths": def default(cls) -> "RuntimePaths":
local_app_data = os.environ.get("LOCALAPPDATA") local_app_data = os.environ.get("LOCALAPPDATA")
if local_app_data: 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") return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
@@ -40,3 +54,49 @@ class RuntimePaths:
self.logs.mkdir(parents=True, exist_ok=True) self.logs.mkdir(parents=True, exist_ok=True)
self.artifacts.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()
+5
View File
@@ -0,0 +1,5 @@
"""采购工具原生 Qt Widgets 界面。"""
from .main_window import PurchaseToolWindow
__all__ = ["PurchaseToolWindow"]
+419
View File
@@ -0,0 +1,419 @@
"""采购执行 Tab:状态、当前任务、滚动日志和历史记录主从视图。"""
from __future__ import annotations
from PySide6.QtCore import QModelIndex, QSize, Qt, Signal, Slot
from PySide6.QtGui import QAction, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QGroupBox,
QHBoxLayout,
QLabel,
QMenu,
QPlainTextEdit,
QPushButton,
QSizePolicy,
QSplitter,
QStackedWidget,
QTableView,
QToolButton,
QVBoxLayout,
QWidget,
)
from cmbuyer_client.polling.coordinator import ClaimedTaskView, PollingCoordinator, PollingState
from cmbuyer_client.logging_policy import redact_text
from .records import PurchaseRecord, PurchaseRecordModel, PurchaseRecordProvider
class _LogView(QPlainTextEdit):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("rollingLog")
self.setReadOnly(True)
self.setPlaceholderText("轮询启动后将在这里显示脱敏日志。")
self.setAccessibleName("滚动日志")
def append_event(self, text: str) -> None:
bar = self.verticalScrollBar()
follow = bar.value() >= bar.maximum() - 2
self.appendPlainText(redact_text(text))
if follow:
bar.setValue(bar.maximum())
class _RecordTableView(QTableView):
"""把双击与 Enter 收敛为唯一 activation 信号,避免平台重复发命令。"""
record_activated = Signal(object)
def mouseDoubleClickEvent(self, event) -> None:
index = self.indexAt(event.position().toPoint())
if index.isValid():
self.setCurrentIndex(index.siblingAtColumn(0))
self.record_activated.emit(index)
event.accept()
return
super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event) -> None:
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and self.currentIndex().isValid():
self.record_activated.emit(self.currentIndex())
event.accept()
return
super().keyPressEvent(event)
class ExecutionPage(QWidget):
LIVE_PAGE = 0
DETAIL_PAGE = 1
COMPACT_DETAIL_WIDTH = 760
def __init__(
self,
coordinator: PollingCoordinator,
record_provider: PurchaseRecordProvider | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("executionPage")
self._coordinator = coordinator
self._selected_record_id: str | None = None
self._saved_scroll = 0
outer = QVBoxLayout(self)
outer.setContentsMargins(12, 12, 12, 12)
status_row = QHBoxLayout()
self.service_status = self._status("采购服务", "待首次真实领取验证")
self.device_status = self._status("ADB", "待后续执行能力验证")
self.app_status = self._status("拼多多版本", "待后续执行能力验证")
self.session_status = self._status("会话", "已停止")
for widget in (self.service_status, self.device_status, self.app_status, self.session_status):
status_row.addWidget(widget)
status_row.addStretch(1)
self.poll_action = QAction("开始轮询", self)
self.poll_action.setObjectName("pollAction")
self.poll_action.triggered.connect(self._toggle_polling)
self.addAction(self.poll_action)
self.poll_button = QPushButton()
self.poll_button.setObjectName("pollButton")
self.poll_button.clicked.connect(self.poll_action.trigger)
status_row.addWidget(self.poll_button)
outer.addLayout(status_row)
self.banner = QLabel()
self.banner.setObjectName("sessionBanner")
self.banner.setWordWrap(True)
self.banner.setAccessibleName("轮询会话状态")
self.banner.setFrameShape(QFrame.Shape.StyledPanel)
outer.addWidget(self.banner)
self.body_splitter = QSplitter(Qt.Orientation.Horizontal)
self.body_splitter.setObjectName("executionSplitter")
self.body_splitter.setChildrenCollapsible(False)
self.left_stack = QStackedWidget()
self.left_stack.setObjectName("leftWorkspace")
self.left_stack.addWidget(self._build_live_page())
self.left_stack.addWidget(self._build_detail_page())
self.body_splitter.addWidget(self.left_stack)
self.body_splitter.addWidget(self._build_records_page())
self.body_splitter.setStretchFactor(0, 2)
self.body_splitter.setStretchFactor(1, 1)
self.body_splitter.setSizes([760, 380])
outer.addWidget(self.body_splitter, 1)
self.view_record_action = QAction("查看所选记录", self)
self.view_record_action.setObjectName("viewSelectedRecord")
self.view_record_action.setEnabled(False)
self.view_record_action.triggered.connect(self.open_selected_record)
self.addAction(self.view_record_action)
self.view_record_button.setDefaultAction(self.view_record_action)
self.return_action = QAction("返回当前任务", self)
self.return_action.setObjectName("returnToCurrentTask")
self.return_action.setEnabled(False)
self.return_action.triggered.connect(self.return_to_live)
self.addAction(self.return_action)
self.return_button.setDefaultAction(self.return_action)
self.escape_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
self.escape_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
self.escape_shortcut.activated.connect(self._escape)
self.record_view.clicked.connect(self._on_record_selected)
self.record_view.record_activated.connect(self._open_index)
self.record_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.record_view.customContextMenuRequested.connect(self._show_record_menu)
self.record_view.selectionModel().currentChanged.connect(self._on_current_changed)
coordinator.state_changed.connect(self._on_polling_state)
coordinator.claim_visible.connect(self._show_claimed_task)
self._on_polling_state(coordinator.state, coordinator.reason, coordinator.consecutive_failures)
if record_provider is not None:
# T-304 没有公共历史仓库;只接受调用方准备好的 View DTO 快照,
# 独立应用不注入 provider,模型保持真实空态。
self.set_records(record_provider.snapshot())
@staticmethod
def _status(name: str, value: str) -> QLabel:
label = QLabel(f"{name}\n{value}")
label.setFrameShape(QFrame.Shape.StyledPanel)
label.setMinimumWidth(118)
label.setAccessibleName(name)
return label
def _build_live_page(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
task_group = QGroupBox("当前任务")
task_layout = QHBoxLayout(task_group)
self.current_task_text = QLabel("当前没有任务。\n启动后只领取已授权任务。")
self.current_task_text.setObjectName("currentTaskText")
self.current_task_text.setWordWrap(True)
self.current_task_text.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
self.current_task_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.current_image = QLabel("暂无可信商品图片")
self.current_image.setObjectName("currentTaskImage")
self.current_image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.current_image.setFrameShape(QFrame.Shape.StyledPanel)
self.current_image.setMinimumSize(QSize(180, 120))
self.current_image.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
task_layout.addWidget(self.current_task_text, 2)
task_layout.addWidget(self.current_image, 1)
layout.addWidget(task_group, 1)
log_group = QGroupBox("滚动日志")
log_layout = QVBoxLayout(log_group)
self.log_view = _LogView()
log_layout.addWidget(self.log_view)
layout.addWidget(log_group, 2)
return page
def _build_detail_page(self) -> QWidget:
page = QWidget()
page.setObjectName("recordDetailPage")
layout = QVBoxLayout(page)
header = QHBoxLayout()
self.detail_title = QLabel("采购记录详情")
self.detail_title.setObjectName("recordDetailTitle")
self.detail_title.setStyleSheet("font-size: 18px; font-weight: 600;")
header.addWidget(self.detail_title)
header.addStretch(1)
self.return_button = QToolButton()
self.return_button.setObjectName("returnCurrentTaskButton")
header.addWidget(self.return_button)
layout.addLayout(header)
top = QSplitter(Qt.Orientation.Horizontal)
self.detail_original = QPlainTextEdit()
self.detail_original.setObjectName("recordOriginalText")
self.detail_original.setReadOnly(True)
self.detail_original.setPlaceholderText("没有可显示的原始文字。")
self.detail_image = QLabel("没有可显示的可信图片")
self.detail_image.setObjectName("recordImage")
self.detail_image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.detail_image.setFrameShape(QFrame.Shape.StyledPanel)
top.addWidget(self.detail_original)
top.addWidget(self.detail_image)
top.setStretchFactor(0, 2)
top.setStretchFactor(1, 1)
layout.addWidget(top, 2)
result_group = QGroupBox("采购结果")
result_layout = QVBoxLayout(result_group)
self.detail_result = QPlainTextEdit()
self.detail_result.setObjectName("recordResult")
self.detail_result.setReadOnly(True)
self.detail_result.setPlaceholderText("暂无采购结果。")
result_layout.addWidget(self.detail_result)
layout.addWidget(result_group, 1)
return page
def _build_records_page(self) -> QWidget:
page = QGroupBox("采购记录")
page.setObjectName("recordsPanel")
self.records_panel = page
layout = QVBoxLayout(page)
header = QHBoxLayout()
self.records_summary = QLabel("暂无记录")
header.addWidget(self.records_summary)
header.addStretch(1)
self.view_record_button = QToolButton()
self.view_record_button.setObjectName("viewSelectedRecordButton")
header.addWidget(self.view_record_button)
layout.addLayout(header)
self.record_model = PurchaseRecordModel(parent=self)
self.record_view = _RecordTableView()
self.record_view.setObjectName("purchaseRecordTable")
self.record_view.setModel(self.record_model)
self.record_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.record_view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.record_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.record_view.setAlternatingRowColors(True)
self.record_view.setSortingEnabled(False)
self.record_view.horizontalHeader().setStretchLastSection(False)
self.record_view.horizontalHeader().setSectionResizeMode(0, self.record_view.horizontalHeader().ResizeMode.Stretch)
self.record_view.horizontalHeader().setSectionResizeMode(1, self.record_view.horizontalHeader().ResizeMode.ResizeToContents)
self.record_view.verticalHeader().setVisible(False)
layout.addWidget(self.record_view)
return page
def set_records(self, records: list[PurchaseRecord]) -> None:
selected_id = self._selected_record_id
self.record_model.set_records(records)
self.records_summary.setText(f"共 {len(records)} 条" if records else "暂无记录")
if selected_id is not None:
row = self.record_model.row_for_id(selected_id)
if row >= 0:
self.record_view.setCurrentIndex(self.record_model.index(row, 0))
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
self._render_record(self.record_model.record_at(row))
return
self._selected_record_id = None
self.view_record_action.setEnabled(False)
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
self.return_to_live()
@Slot(object, str, int)
def _on_polling_state(self, state: object, reason: str, failures: int) -> None:
polling_state = state if isinstance(state, PollingState) else PollingState.BLOCKED
self.session_status.setText(f"会话\n{self._state_text(polling_state)}")
suffix = f"(连续失败 {failures} 次)" if failures else ""
self.banner.setText(reason + suffix)
running = polling_state in (
PollingState.STARTING,
PollingState.RECOVERING,
PollingState.WAITING,
PollingState.CLAIMING,
PollingState.ACTIVE,
)
self.poll_action.setText("停止轮询" if running else "开始轮询")
self.poll_action.setEnabled(running or self._coordinator.can_start)
self.poll_button.setText(self.poll_action.text())
self.poll_button.setEnabled(self.poll_action.isEnabled())
self.poll_button.setToolTip("" if self.poll_action.isEnabled() else reason)
@staticmethod
def _state_text(state: PollingState) -> str:
return {
PollingState.STOPPED: "已停止",
PollingState.STARTING: "启动中",
PollingState.BLOCKED: "已阻止",
PollingState.RECOVERING: "安全恢复",
PollingState.WAITING: "等待领取",
PollingState.CLAIMING: "正在领取",
PollingState.ACTIVE: "任务执行中",
PollingState.RECOVERY_REQUIRED: "待安全恢复",
}[state]
@Slot()
def _toggle_polling(self) -> None:
if self._coordinator.state in (
PollingState.STARTING,
PollingState.RECOVERING,
PollingState.WAITING,
PollingState.CLAIMING,
PollingState.ACTIVE,
):
self._coordinator.stop()
else:
self._coordinator.start()
@Slot(QModelIndex)
def _on_record_selected(self, index: QModelIndex) -> None:
record = self.record_model.record_at(index.row())
if record is None:
return
self._selected_record_id = record.record_id
self.view_record_action.setEnabled(True)
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
self._saved_scroll = self.record_view.verticalScrollBar().value()
self._render_record(record)
@Slot(object)
def _show_claimed_task(self, raw: object) -> None:
if not isinstance(raw, ClaimedTaskView):
return
self.current_task_text.setText(
f"标题:{raw.title}\n任务 ID:{raw.task_id}\n状态:{raw.status}"
)
self.log_view.append_event(f"已安全领取任务 {raw.task_id};等待单趟执行能力处理。")
@Slot(QModelIndex, QModelIndex)
def _on_current_changed(self, current: QModelIndex, previous: QModelIndex) -> None:
del previous
if current.isValid():
self._on_record_selected(current)
@Slot(QModelIndex)
def _open_index(self, index: QModelIndex) -> None:
if index.isValid():
self.record_view.setCurrentIndex(index.siblingAtColumn(0))
self._on_record_selected(index)
self.view_record_action.trigger()
@Slot()
def open_selected_record(self) -> None:
if self._selected_record_id is None:
return
row = self.record_model.row_for_id(self._selected_record_id)
record = self.record_model.record_at(row)
if record is None:
return
self._saved_scroll = self.record_view.verticalScrollBar().value()
self._render_record(record)
self.left_stack.setCurrentIndex(self.DETAIL_PAGE)
self.return_action.setEnabled(True)
self._apply_compact_detail()
self.return_button.setFocus()
def _render_record(self, record: PurchaseRecord | None) -> None:
if record is None:
self.detail_title.setText("记录不存在")
self.detail_original.clear()
self.detail_result.clear()
self.detail_image.setText("没有可显示的可信图片")
return
self.detail_title.setText(record.title)
self.detail_original.setPlainText(record.original_text)
self.detail_result.setPlainText(record.result_text)
self.detail_image.setText(record.image_description or "没有可显示的可信图片")
@Slot()
def return_to_live(self) -> None:
if self.left_stack.currentIndex() != self.DETAIL_PAGE:
return
self.left_stack.setCurrentIndex(self.LIVE_PAGE)
self.return_action.setEnabled(False)
self.records_panel.setVisible(True)
row = self.record_model.row_for_id(self._selected_record_id or "")
if row >= 0:
self.record_view.setCurrentIndex(self.record_model.index(row, 0))
self.record_view.verticalScrollBar().setValue(self._saved_scroll)
self.record_view.setFocus()
@Slot()
def _escape(self) -> None:
# Qt popup/menu 优先消费 Esc;只有详情态的页面级 shortcut 会执行返回。
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
self.return_action.trigger()
@Slot(object)
def _show_record_menu(self, point: object) -> None:
index = self.record_view.indexAt(point)
if index.isValid():
self.record_view.setCurrentIndex(index.siblingAtColumn(0))
self._on_record_selected(index)
menu = QMenu(self.record_view)
menu.addAction(self.view_record_action)
menu.exec(self.record_view.viewport().mapToGlobal(point))
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self._apply_compact_detail()
def _apply_compact_detail(self) -> None:
compact_detail = self.width() < self.COMPACT_DETAIL_WIDTH and self.left_stack.currentIndex() == self.DETAIL_PAGE
self.records_panel.setVisible(not compact_detail)
@@ -0,0 +1,95 @@
"""采购工具固定双 Tab 原生窗口。"""
from __future__ import annotations
from PySide6.QtCore import QTimer, Qt, Slot
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import QMainWindow, QTabWidget
from cmbuyer_client.localstate.models import ProfileSettings
from cmbuyer_client.polling.coordinator import PollingCoordinator, PollingState, RecoveryStatus
from .execution import ExecutionPage
from .records import PurchaseRecordProvider
from .settings import ProfileStore, SettingsPage
class PurchaseToolWindow(QMainWindow):
EXECUTION_PAGE_ID = "purchase-execution"
SETTINGS_PAGE_ID = "settings"
def __init__(
self,
*,
store: ProfileStore,
coordinator: PollingCoordinator,
profile_settings: ProfileSettings | None,
has_stored_device_token: bool,
record_provider: PurchaseRecordProvider | None = None,
parent=None,
) -> None:
super().__init__(parent)
self.setWindowTitle("采购工具")
self.setAccessibleName("采购工具")
self.setMinimumSize(720, 520)
self.resize(1180, 760)
self._coordinator = coordinator
self._close_pending = False
recovery = coordinator.recovery_status
frozen = recovery is None or recovery.has_pending_claim or recovery.has_active_claim
self.tabs = QTabWidget()
self.tabs.setObjectName("mainTabs")
self.tabs.setTabsClosable(False)
self.tabs.setMovable(False)
self.execution_page = ExecutionPage(coordinator, record_provider)
self.execution_page.setProperty("pageId", self.EXECUTION_PAGE_ID)
self.settings_page = SettingsPage(
store,
settings=profile_settings,
has_stored_device_token=has_stored_device_token,
identity_frozen=frozen,
)
self.settings_page.setProperty("pageId", self.SETTINGS_PAGE_ID)
self.tabs.addTab(self.execution_page, "采购执行")
self.tabs.addTab(self.settings_page, "配置")
self.tabs.setCurrentWidget(self.execution_page)
self.setCentralWidget(self.tabs)
self.settings_page.settings_saved.connect(
lambda settings, has_token: self._coordinator.update_profile_settings(settings)
)
coordinator.recovery_status_changed.connect(self._on_recovery_status)
coordinator.configuration_freeze_changed.connect(self.settings_page.set_identity_frozen)
coordinator.settled.connect(self._finish_pending_close)
@Slot(object)
def _on_recovery_status(self, raw: object) -> None:
if not isinstance(raw, RecoveryStatus):
self.settings_page.set_identity_frozen(True)
return
self.settings_page.set_identity_frozen(raw.has_pending_claim or raw.has_active_claim)
def closeEvent(self, event: QCloseEvent) -> None:
running = self._coordinator.state in (
PollingState.STARTING,
PollingState.RECOVERING,
PollingState.WAITING,
PollingState.CLAIMING,
PollingState.ACTIVE,
) or self._coordinator.operation_in_flight
if running:
# 不 terminate 飞行中的 QThread。先提升 stop latch,等待 HTTP 自身
# 超时和 DurableClientGateway 落库,再由 settled 重试关闭。
self._close_pending = True
self._coordinator.stop()
event.ignore()
return
event.accept()
@Slot()
def _finish_pending_close(self) -> None:
if not self._close_pending:
return
self._close_pending = False
QTimer.singleShot(0, self.close)
+95
View File
@@ -0,0 +1,95 @@
"""采购记录的只读 Qt Model/View 数据源。"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from cmbuyer_client.logging_policy import redact_text
from cmbuyer_client.core.errors import ValidationError
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
@dataclass(frozen=True)
class PurchaseRecord:
record_id: str
title: str
status: str
created_at: str
original_text: str = ""
result_text: str = ""
image_description: str = ""
created_at_nanoseconds: int = field(init=False, repr=False)
def __post_init__(self) -> None:
# 记录 provider 只能注入可显示摘要;最终 UI 边界仍统一脱敏,避免
# consumer bug 把 Bearer/裸 token 放入 model、详情或可见日志。
for field in ("title", "status", "original_text", "result_text", "image_description"):
object.__setattr__(self, field, redact_text(getattr(self, field)))
if not isinstance(self.created_at, str):
raise ValueError("noncanonical_record_timestamp")
fraction = self.created_at[20:-1] if len(self.created_at) > 20 and self.created_at.endswith("Z") else ""
if fraction and fraction.endswith("0"):
raise ValueError("noncanonical_record_timestamp")
try:
timestamp = rfc3339_z_nanoseconds(self.created_at)
except ValidationError:
raise ValueError("noncanonical_record_timestamp") from None
object.__setattr__(self, "created_at_nanoseconds", timestamp)
class PurchaseRecordProvider(Protocol):
"""只返回已准备好的无秘密 View DTO;不得在 GUI 线程查询 SQLite/HTTP。"""
def snapshot(self) -> list[PurchaseRecord]: ...
class PurchaseRecordModel(QAbstractTableModel):
RECORD_ID_ROLE = int(Qt.ItemDataRole.UserRole) + 1
def __init__(self, records: list[PurchaseRecord] | None = None, parent=None) -> None:
super().__init__(parent)
self._records: list[PurchaseRecord] = []
self.set_records(records or [])
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0 if parent.isValid() else len(self._records)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0 if parent.isValid() else 2
def data(self, index: QModelIndex, role: int = int(Qt.ItemDataRole.DisplayRole)):
if not index.isValid() or not 0 <= index.row() < len(self._records):
return None
record = self._records[index.row()]
if role == int(Qt.ItemDataRole.DisplayRole):
return record.title if index.column() == 0 else record.status
if role == self.RECORD_ID_ROLE:
return record.record_id
if role == int(Qt.ItemDataRole.ToolTipRole):
return f"{record.title}\n{record.created_at}"
if role == int(Qt.ItemDataRole.TextAlignmentRole) and index.column() == 1:
return int(Qt.AlignmentFlag.AlignCenter)
return None
def headerData(self, section: int, orientation: Qt.Orientation, role: int = int(Qt.ItemDataRole.DisplayRole)):
if role != int(Qt.ItemDataRole.DisplayRole) or orientation != Qt.Orientation.Horizontal:
return None
return ("标题", "状态")[section] if 0 <= section < 2 else None
def set_records(self, records: list[PurchaseRecord]) -> None:
self.beginResetModel()
self._records = sorted(
records,
key=lambda item: (item.created_at_nanoseconds, item.record_id),
reverse=True,
)
self.endResetModel()
def record_at(self, row: int) -> PurchaseRecord | None:
return self._records[row] if 0 <= row < len(self._records) else None
def row_for_id(self, record_id: str) -> int:
return next((row for row, item in enumerate(self._records) if item.record_id == record_id), -1)
+249
View File
@@ -0,0 +1,249 @@
"""只做本地校验和显式保存的配置页。"""
from __future__ import annotations
from pathlib import Path
from typing import Protocol
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QComboBox,
QFormLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSpinBox,
QVBoxLayout,
QWidget,
)
from cmbuyer_client.core.models import SecretToken
from cmbuyer_client.core.errors import ValidationError
from cmbuyer_client.core.validation import require_uuid4
from cmbuyer_client.localstate.models import LOOPBACK_SERVICE_URL, ProfileSettings
class ProfileStore(Protocol):
def save_profile(self, settings: ProfileSettings, token: SecretToken | None) -> None: ...
class SettingsPage(QScrollArea):
settings_saved = Signal(object, bool)
VALIDATION_HINT = "服务身份将在首次真实领取时验证;设备与 App 状态由后续已取证执行能力验证。"
def __init__(
self,
store: ProfileStore,
*,
profile_id: str = "default",
settings: ProfileSettings | None = None,
has_stored_device_token: bool = False,
identity_frozen: bool = False,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("settingsPage")
self.setWidgetResizable(True)
self._store = store
self._profile_id = profile_id
self._has_stored_device_token = has_stored_device_token
self._loaded_settings = settings
self._identity_frozen = identity_frozen
content = QWidget()
outer = QVBoxLayout(content)
title = QLabel("配置")
title.setObjectName("settingsTitle")
title.setStyleSheet("font-size: 20px; font-weight: 600;")
outer.addWidget(title)
form = QFormLayout()
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
outer.addLayout(form)
self.service_url = QLineEdit(LOOPBACK_SERVICE_URL)
self.service_url.setReadOnly(True)
self.service_url.setObjectName("serviceUrl")
form.addRow("采购服务 URL", self.service_url)
self.device_id = QLineEdit()
self.device_id.setObjectName("deviceId")
self.device_id.setPlaceholderText("小写 UUIDv4")
form.addRow("设备 UUID", self.device_id)
self.device_token = QLineEdit()
self.device_token.setObjectName("deviceToken")
self.device_token.setEchoMode(QLineEdit.EchoMode.Password)
# 控件只负责给输入设置合理上限;长度与字符集必须在 save() 中显式
# 验证。若这里限制为 64,粘贴 65 位 token 会被 Qt 静默截成合法
# 64 位并覆盖原凭据。
self.device_token.setMaxLength(256)
self.device_token.setPlaceholderText("首次必填;已有凭据时留空表示保留")
form.addRow("设备 token", self.device_token)
self.token_status = QLabel()
self.token_status.setObjectName("tokenStatus")
form.addRow("凭据状态", self.token_status)
self.adb_path = QLineEdit()
self.adb_path.setObjectName("adbPath")
form.addRow("ADB 路径", self.adb_path)
self.adb_serial = QLineEdit()
self.adb_serial.setObjectName("adbSerial")
form.addRow("设备 serial", self.adb_serial)
self.transport = QComboBox()
self.transport.setObjectName("transport")
self.transport.addItem("USB", "usb")
self.transport.addItem("WiFi", "wifi")
form.addRow("连接方式", self.transport)
self.poll_interval = self._spin(5, 300, 15, "pollInterval", " 秒")
form.addRow("轮询间隔", self.poll_interval)
self.failure_threshold = self._spin(1, 10, 3, "failureThreshold", " 次")
form.addRow("连续失败停止阈值", self.failure_threshold)
self.http_timeout = self._spin(1, 120, 10, "httpTimeout", " 秒")
form.addRow("HTTP 超时", self.http_timeout)
self.step_timeout = self._spin(5, 300, 45, "stepTimeout", " 秒")
form.addRow("真机步骤超时", self.step_timeout)
self.validation_hint = QLabel(self.VALIDATION_HINT)
self.validation_hint.setObjectName("deferredValidationHint")
self.validation_hint.setWordWrap(True)
outer.addWidget(self.validation_hint)
self.feedback = QLabel()
self.feedback.setObjectName("settingsFeedback")
self.feedback.setWordWrap(True)
self.feedback.setAccessibleName("配置保存状态")
outer.addWidget(self.feedback)
self.save_action = QAction("保存配置", self)
self.save_action.setShortcut(QKeySequence.StandardKey.Save)
self.save_action.triggered.connect(self.save)
self.addAction(self.save_action)
self.save_button = QPushButton("保存配置")
self.save_button.setObjectName("saveSettings")
self.save_button.clicked.connect(self.save_action.trigger)
outer.addWidget(self.save_button, 0, Qt.AlignmentFlag.AlignRight)
outer.addStretch(1)
self.setWidget(content)
if settings is not None:
self._load(settings)
self._update_token_status()
self.set_identity_frozen(identity_frozen)
@staticmethod
def _spin(minimum: int, maximum: int, value: int, name: str, suffix: str) -> QSpinBox:
field = QSpinBox()
field.setObjectName(name)
field.setRange(minimum, maximum)
field.setValue(value)
field.setSuffix(suffix)
return field
def _load(self, settings: ProfileSettings) -> None:
self.device_id.setText(settings.device_id)
self.adb_path.setText(settings.adb_path)
self.adb_serial.setText(settings.adb_serial)
self.transport.setCurrentIndex(max(0, self.transport.findData(settings.transport)))
self.poll_interval.setValue(settings.poll_interval_seconds)
self.failure_threshold.setValue(settings.failure_threshold)
self.http_timeout.setValue(settings.http_timeout_seconds)
self.step_timeout.setValue(settings.step_timeout_seconds)
def set_identity_frozen(self, frozen: bool) -> None:
self._identity_frozen = frozen
for field in (
self.device_id,
self.adb_path,
self.adb_serial,
self.transport,
self.poll_interval,
self.failure_threshold,
self.http_timeout,
self.step_timeout,
):
field.setEnabled(not frozen)
if frozen:
self.feedback.setText("存在待恢复或执行中的领取;服务与设备身份参数已冻结。")
elif self.feedback.text().startswith("存在待恢复或执行中的领取"):
self.feedback.clear()
@Slot()
def save(self) -> None:
self.feedback.clear()
token_text = self.device_token.text().strip()
device_id = self.device_id.text().strip()
try:
require_uuid4(device_id, "invalid_device_id")
except (TypeError, ValueError, ValidationError):
self._validation_error(self.device_id, "设备 UUID 必须是小写 UUIDv4。")
return
if not self._has_stored_device_token and not token_text:
self._validation_error(self.device_token, "首次保存必须填写设备 token。")
return
if token_text and (len(token_text) != 64 or any(character not in "0123456789abcdef" for character in token_text)):
self._validation_error(self.device_token, "设备 token 必须为 64 位小写十六进制。")
return
try:
if self._identity_frozen:
if self._loaded_settings is None:
self._validation_error(self.device_id, "冻结配置缺少原始设置,不能保存。")
return
settings = self._loaded_settings
else:
adb_text = self.adb_path.text().strip()
if not adb_text:
self._validation_error(self.adb_path, "请填写 ADB 路径。")
return
adb_file = Path(adb_text).expanduser()
if not adb_file.is_file():
self._validation_error(self.adb_path, "ADB 路径必须指向本机已存在的文件。")
return
adb_file = adb_file.resolve(strict=True)
serial = self.adb_serial.text().strip()
if not serial:
self._validation_error(self.adb_serial, "请填写设备 serial。")
return
settings = ProfileSettings(
profile_id=self._profile_id,
service_url=LOOPBACK_SERVICE_URL,
device_id=device_id,
adb_path=str(adb_file),
adb_serial=serial,
transport=str(self.transport.currentData()),
poll_interval_seconds=self.poll_interval.value(),
failure_threshold=self.failure_threshold.value(),
http_timeout_seconds=self.http_timeout.value(),
step_timeout_seconds=self.step_timeout.value(),
)
token = SecretToken(token_text) if token_text else None
except (TypeError, ValueError):
self._validation_error(self.device_id, "配置格式不正确,请检查设备 UUID 与各项参数。")
return
try:
# None 明确表示保留 T-303 中已有的 DPAPI 密文,绝不是清除凭据。
self._store.save_profile(settings, token)
except Exception:
self.feedback.setText("配置保存失败,本地安全存储未更新。")
(self.device_token if token_text else self.device_id).setFocus()
return
self._has_stored_device_token = True
self._loaded_settings = settings
self.device_token.clear()
self._update_token_status()
self.feedback.setText("配置已保存。本地校验不代表服务、设备或 App 已就绪。")
self.settings_saved.emit(settings, True)
def _validation_error(self, field: QWidget, message: str) -> None:
self.feedback.setText(message)
field.setFocus()
def _update_token_status(self) -> None:
self.token_status.setText("已保存" if self._has_stored_device_token else "未保存")
+1
View File
@@ -0,0 +1 @@
"""core tests。"""
+187
View File
@@ -0,0 +1,187 @@
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_quantity = claim_wire()
wide_quantity["task"]["quantity"] = 2_147_483_648
self.assertEqual(ClaimedTask.from_wire(wide_quantity).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_claim_fields_share_explicit_server_bounds(self) -> None:
legal = claim_wire()
legal_goods = "1" * 32
legal["task"].update(
version=9_223_372_036_854_775_807,
title="😀" * 120,
goods_id=legal_goods,
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + legal_goods,
sku_color="色" * 80,
sku_size="码" * 80,
max_total_price="1" * 29 + ".00",
)
legal["authorization"]["task_version"] = 9_223_372_036_854_775_806
claimed = ClaimedTask.from_wire(legal)
self.assertEqual(len(claimed.task.title), 120)
# Python's default ensure_ascii=True expands astral characters to surrogate
# escape pairs, so this is a conservative parser-budget proof as well.
self.assertLess(len(json.dumps(legal, separators=(",", ":")).encode()), 32 * 1024)
mutations = (
("title", "😀" * 121),
("title", " title"),
("sku_color", "色" * 81),
("sku_color", "black "),
("sku_size", "码" * 81),
("sku_size", " M"),
("max_total_price", "1" * 30 + ".00"),
)
for field, invalid in mutations:
changed = claim_wire()
changed["task"][field] = invalid
with self.subTest(field=field, length=len(invalid)), self.assertRaises(ValidationError):
ClaimedTask.from_wire(changed)
overlong_goods = "1" * 33
changed = claim_wire()
changed["task"].update(
goods_id=overlong_goods,
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + overlong_goods,
)
with self.assertRaises(ValidationError):
ClaimedTask.from_wire(changed)
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", "\u00a0title", "title\u00a0"):
value = claim_wire()
value["task"]["title"] = title
with self.subTest(title=repr(title)), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value)
def test_persisted_text_has_runtime_independent_c0_and_nbsp_domain(self) -> None:
for field in ("title", "sku_color", "sku_size"):
for invalid in ("\u001cvalue", "value\u001f", "value\u001dinside", "\u00a0value", "value\u00a0"):
value = claim_wire()
value["task"][field] = invalid
with self.subTest(field=field, invalid=repr(invalid)), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value)
value = claim_wire()
value["task"][field] = "left\u00a0right"
self.assertEqual(getattr(ClaimedTask.from_wire(value).task, field), "left\u00a0right")
@@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
from functools import lru_cache
from hashlib import sha256 from hashlib import sha256
from io import BytesIO
import json import json
from pathlib import Path from pathlib import Path
import sys import sys
@@ -48,6 +50,16 @@ def _hash(path: Path) -> str:
return digest.hexdigest() return digest.hexdigest()
@lru_cache(maxsize=None)
def _source_png(size: tuple[int, int]) -> bytes:
image = Image.new("RGB", size, color=(0, 180, 0))
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
image.paste((255, 0, 0), (0, 0, 8, 540))
output = BytesIO()
image.save(output, format="PNG")
return output.getvalue()
def _default_xml() -> str: def _default_xml() -> str:
return ( return (
"<hierarchy rotation='0'>" "<hierarchy rotation='0'>"
@@ -116,12 +128,7 @@ def _write_raw(
raw = root / "raw" raw = root / "raw"
raw.mkdir(parents=True) raw.mkdir(parents=True)
screenshot = raw / "screenshot.png" screenshot = raw / "screenshot.png"
image = Image.new("RGB", size, color=(0, 180, 0)) screenshot.write_bytes(_source_png(size))
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
for y in range(540):
for x in range(8):
image.putpixel((x, y), (255, 0, 0))
image.save(screenshot, format="PNG")
hierarchy = raw / "hierarchy.xml" hierarchy = raw / "hierarchy.xml"
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8") hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
manifest = { manifest = {
+1
View File
@@ -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))
+321
View File
@@ -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
+895
View File
@@ -0,0 +1,895 @@
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
class NoUnprotectProtector(FakeProtector):
def __init__(self) -> None:
self.unprotect_calls = 0
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
self.unprotect_calls += 1
raise AssertionError("metadata_read_must_not_unprotect")
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_profile_summary_reads_settings_and_token_presence_without_unprotect(self) -> None:
protector = NoUnprotectProtector()
summary_store = LocalStateStore(
self.database,
protector,
now=lambda: self.clock[0],
)
summary = summary_store.load_profile_summary(PROFILE)
self.assertEqual(summary.settings, settings())
self.assertTrue(summary.has_stored_device_token)
self.assertNotIn(DEVICE_TOKEN, repr(summary))
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_empty_cipher_fails_closed_without_unprotect(self) -> None:
connection = sqlite3.connect(self.database)
try:
connection.execute(
"UPDATE profiles SET device_token_cipher=? WHERE profile_id=?",
(sqlite3.Binary(b""), PROFILE),
)
connection.commit()
finally:
connection.close()
protector = NoUnprotectProtector()
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
with self.assertRaisesRegex(StateError, "invalid_device_token_cipher"):
summary_store.load_profile_summary(PROFILE)
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_wrong_cipher_storage_class_fails_closed_without_unprotect(self) -> None:
current = settings()
row = (
current.service_url,
current.device_id,
current.adb_path,
current.adb_serial,
current.transport,
current.poll_interval_seconds,
current.failure_threshold,
current.http_timeout_seconds,
current.step_timeout_seconds,
"text",
64,
)
protector = NoUnprotectProtector()
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
fake_connection = mock.Mock()
fake_connection.execute.return_value.fetchone.return_value = row
with mock.patch.object(summary_store, "_read_transaction") as read_transaction:
read_transaction.return_value.__enter__.return_value = fake_connection
with self.assertRaisesRegex(StateError, "invalid_device_token_cipher"):
summary_store.load_profile_summary(PROFILE)
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_invalid_stored_settings_are_normalized_without_unprotect(self) -> None:
original = settings()
protector = NoUnprotectProtector()
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
for column, invalid, valid in (
("service_url", "http://127.0.0.1:9999", original.service_url),
("transport", "bluetooth", original.transport),
("poll_interval_seconds", 4, original.poll_interval_seconds),
):
with self.subTest(column=column):
connection = sqlite3.connect(self.database)
try:
connection.execute("PRAGMA ignore_check_constraints=ON")
connection.execute(
f"UPDATE profiles SET {column}=? WHERE profile_id=?",
(invalid, PROFILE),
)
connection.commit()
finally:
connection.close()
try:
with self.assertRaisesRegex(StateError, "stored_profile_invalid") as captured:
summary_store.load_profile_summary(PROFILE)
self.assertNotIn(DEVICE_TOKEN, str(captured.exception))
finally:
connection = sqlite3.connect(self.database)
try:
connection.execute("PRAGMA ignore_check_constraints=ON")
connection.execute(
f"UPDATE profiles SET {column}=? WHERE profile_id=?",
(valid, PROFILE),
)
connection.commit()
finally:
connection.close()
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_missing_profile_fails_without_creating_defaults(self) -> None:
with self.assertRaisesRegex(StateError, "profile_not_found"):
self.store.load_profile_summary("missing")
connection = sqlite3.connect(self.database)
try:
self.assertEqual(connection.execute("SELECT count(*) FROM profiles").fetchone()[0], 1)
finally:
connection.close()
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()))
@@ -1,4 +1,5 @@
<hierarchy> <hierarchy>
<!-- 商品内容区同名促销小字:它不是规格入口,保留为防误点反例。 -->
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,1256][1080,1355]" clickable="true" enabled="true" visible-to-user="true"> <node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,1256][1080,1355]" clickable="true" enabled="true" visible-to-user="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true"> <node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.ViewSwitcher" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true"> <node package="com.xunmeng.pinduoduo" class="android.widget.ViewSwitcher" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
@@ -8,7 +9,9 @@
</node> </node>
</node> </node>
</node> </node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true"> <!-- 只允许点击底部购买区第一行文本叶节点中心;第二行只用于精确结构校验。 -->
<node content-desc="快要抢光¥12.88" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true">
<node text="快要抢光 ¥ 12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2184][1042,2253]" clickable="false" enabled="true" visible-to-user="true" />
<node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" /> <node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" />
</node> </node>
</hierarchy> </hierarchy>
@@ -0,0 +1,12 @@
<hierarchy rotation="0">
<!-- T-104 同商品正锚:只保留人工确认 post-exit XML 中的标题容器及两个直接文本子节点。 -->
<node resource-id="com.xunmeng.pinduoduo:id/tv_title" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1571][1044,1689]" content-desc="2026年新款高档重工潮流烫钻中长款T恤显瘦宽松上衣淡人穿搭" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" long-clickable="true">
<node text="2026年新款高档重工潮流烫钻中长款T恤显瘦宽松" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1571][1023,1624]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="上衣淡人穿搭" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1636][306,1689]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<!-- 同一份 post-exit XML 中的底部规格入口;只证明页面身份,不在安全退出中点击。 -->
<node content-desc="快要抢光¥12.88" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="快要抢光 ¥ 12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2184][1042,2253]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</hierarchy>
@@ -0,0 +1,30 @@
<hierarchy rotation="0">
<!-- T-105 只保留人工确认数量 1 证据中的规格、顶部金额与数量控件。 -->
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,474][1080,863]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,498][1053,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/gnl" bounds="[396,752][645,827]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,752][645,827]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node content-desc="减少数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,752][474,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="1" package="com.xunmeng.pinduoduo" class="android.widget.EditText" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[480,752][561,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="增加数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[567,752][645,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[126,1000][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[126,1000][438,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/tv_content" bounds="[126,1000][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
<!-- 底部金额故意保留为反例;生产读取器只认上方证据角色。 -->
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,2181][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="提交订单 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[369,2225][710,2284]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</hierarchy>
@@ -0,0 +1,30 @@
<hierarchy rotation="0">
<!-- T-105 只保留人工确认数量 2 证据中的规格、顶部金额与数量控件。 -->
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,474][1080,861]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,498][1053,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,498][740,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="快卖完 ¥32.76" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,503][722,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,582][1053,644]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/gnl" bounds="[396,750][645,825]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,750][645,825]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node content-desc="减少数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,750][474,825]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="2" package="com.xunmeng.pinduoduo" class="android.widget.EditText" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[480,750][561,825]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="增加数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[567,750][645,825]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[126,998][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[126,998][438,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/tv_content" bounds="[126,998][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
<!-- 与 Gate2 值相同也不能成为读取候选。 -->
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,2181][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="提交订单 ¥32.76" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[366,2225][714,2284]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</hierarchy>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="券前¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[693,580][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node text="请选择: 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[396,575][1053,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][693,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
<node text="请选择: 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="券前¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[693,580][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node text="请选择: 颜色分类 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[396,575][1053,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][693,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
<node text="请选择: 颜色分类 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1000]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[730,503][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1000][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1489][1080,1930]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1489][1044,1570]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1506][114,1552]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[439,1582][831,1667]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1000]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[730,503][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node text="已选: 黑色 CHA (纯棉) S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1000][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1489][1080,1930]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1489][1044,1570]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1506][114,1552]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[36,1582][409,1667]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>
@@ -0,0 +1,597 @@
"""T-106 确认页四态纯只读取证的离线测试。"""
from __future__ import annotations
import argparse
import ast
import base64
from contextlib import redirect_stderr, redirect_stdout
from functools import lru_cache
from importlib.util import module_from_spec, spec_from_file_location
from io import BytesIO, StringIO
import json
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
from PIL import Image
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbDevice, CommandResult, DeviceInspection
from cmbuyer_client.pdd.order_confirm_spike import (
Android16ForegroundReader,
DECLARED_STATES,
EXPECTED_GOODS_ID,
OrderConfirmEvidenceCapturer,
OrderConfirmEvidenceError,
OrderConfirmEvidenceTimeoutError,
OrderConfirmForegroundReader,
OrderConfirmReadDevice,
)
SERIAL = "192.168.0.173:5555"
HIERARCHY = (
"<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'>"
"<node text='local raw page' /></hierarchy>"
)
TOP_RESUMED = (
" topResumedActivity=ActivityRecord{101034589 u0 "
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
)
@lru_cache(maxsize=1)
def _png_base64() -> str:
raw = BytesIO()
Image.new("RGB", (1080, 2376), color="white").save(raw, format="PNG")
return base64.b64encode(raw.getvalue()).decode("ascii")
class FakeAdbClient:
def __init__(self, *, model: str = "PKG110", android_version: str = "16") -> None:
self.calls: list[str] = []
self.inspection = DeviceInspection(
device=AdbDevice(serial=SERIAL, state="device", model=model),
model=model,
android_version=android_version,
)
def inspect(self, serial: str) -> DeviceInspection:
self.calls.append(serial)
return self.inspection
class FakeReadDevice:
def __init__(
self,
*,
version: str = "8.17.0",
screen_size: tuple[int, int] = (1080, 2376),
screenshot: object | None = None,
hierarchy: object = HIERARCHY,
) -> None:
self.version = version
self.screen_size = screen_size
self.screenshot = _png_base64() if screenshot is None else screenshot
self.hierarchy = hierarchy
self.calls: list[tuple[object, ...]] = []
self.app_info_reads = 0
self.window_reads = 0
self.post_version: str | None = None
self.post_screen_size: tuple[int, int] | None = None
def app_info(self, package_name: str) -> dict[str, str]:
self.calls.append(("app_info", package_name))
self.app_info_reads += 1
version = self.post_version if self.app_info_reads > 1 and self.post_version else self.version
return {"versionName": version}
def window_size(self) -> tuple[int, int]:
self.calls.append(("window_size",))
self.window_reads += 1
if self.window_reads > 1 and self.post_screen_size is not None:
return self.post_screen_size
return self.screen_size
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "takeScreenshot":
return self.screenshot
if method == "dumpWindowHierarchy":
return self.hierarchy
raise AssertionError("unexpected read RPC")
class FakeForegroundReader:
def __init__(
self,
*,
package: str = "com.xunmeng.pinduoduo",
activity: str = ".activity.NewPageActivity",
) -> None:
self.package = package
self.activity = activity
self.post_package: str | None = None
self.post_activity: str | None = None
self.calls: list[str] = []
def read(self, serial: str) -> dict[str, str]:
self.calls.append(serial)
package = self.post_package if len(self.calls) > 1 and self.post_package else self.package
activity = self.post_activity if len(self.calls) > 1 and self.post_activity else self.activity
return {"package": package, "activity": activity}
class FakeCommandRunner:
def __init__(self, result: CommandResult) -> None:
self.result = result
self.calls: list[tuple[tuple[str, ...], float]] = []
def run(self, arguments: tuple[str, ...], timeout_seconds: float) -> CommandResult:
self.calls.append((arguments, timeout_seconds))
return self.result
def _load_script() -> object:
path = CLIENT_ROOT / "scripts" / "capture_order_confirm_spike.py"
specification = spec_from_file_location("capture_order_confirm_spike_for_test", path)
assert specification is not None and specification.loader is not None
module = module_from_spec(specification)
specification.loader.exec_module(module)
return module
def _namespace(**changes: object) -> argparse.Namespace:
values: dict[str, object] = {
"serial": SERIAL,
"goods_id": EXPECTED_GOODS_ID,
"state": DECLARED_STATES[0],
"output_dir": Path("evidence"),
"timeout": 10.0,
"adb": "adb",
}
values.update(changes)
return argparse.Namespace(**values)
class Android16ForegroundReaderTests(unittest.TestCase):
def test_reads_exact_unique_top_resumed_activity(self) -> None:
runner = FakeCommandRunner(CommandResult(stdout=TOP_RESUMED))
reader = Android16ForegroundReader(runner, timeout_seconds=7)
self.assertEqual(
reader.read(SERIAL),
{
"package": "com.xunmeng.pinduoduo",
"activity": ".activity.NewPageActivity",
},
)
self.assertEqual(
runner.calls,
[
(
("-s", SERIAL, "shell", "dumpsys", "activity", "activities"),
7,
)
],
)
def test_missing_legacy_duplicate_or_failed_summary_is_rejected(self) -> None:
outputs = (
"",
"mResumedActivity: ActivityRecord{1 u0 com.xunmeng.pinduoduo/.Main t1}\n",
TOP_RESUMED + TOP_RESUMED,
"topResumedActivity=null\n",
)
for output in outputs:
with self.subTest(output=output):
reader = Android16ForegroundReader(
FakeCommandRunner(CommandResult(stdout=output)),
timeout_seconds=7,
)
with self.assertRaises(OrderConfirmEvidenceError):
reader.read(SERIAL)
reader = Android16ForegroundReader(
FakeCommandRunner(CommandResult(stdout=TOP_RESUMED, returncode=1)),
timeout_seconds=7,
)
with self.assertRaises(OrderConfirmEvidenceError):
reader.read(SERIAL)
with self.assertRaises(OrderConfirmEvidenceError):
reader.read(f" {SERIAL}")
class OrderConfirmEvidenceTests(unittest.TestCase):
def _capturer(
self,
adb: FakeAdbClient,
device: FakeReadDevice,
*,
foreground: FakeForegroundReader | None = None,
connector_calls: list[str] | None = None,
) -> OrderConfirmEvidenceCapturer:
def connect(serial: str) -> FakeReadDevice:
if connector_calls is not None:
connector_calls.append(serial)
return device
return OrderConfirmEvidenceCapturer(
adb, # type: ignore[arg-type]
connect,
foreground or FakeForegroundReader(),
timeout_seconds=2,
)
def test_all_four_human_states_publish_only_raw_read_evidence(self) -> None:
self.assertEqual(len(DECLARED_STATES), 4)
for state in DECLARED_STATES:
with self.subTest(state=state), TemporaryDirectory() as temporary:
adb = FakeAdbClient()
device = FakeReadDevice()
foreground = FakeForegroundReader()
connector_calls: list[str] = []
target = Path(temporary) / state
result = self._capturer(
adb,
device,
foreground=foreground,
connector_calls=connector_calls,
).capture(SERIAL, EXPECTED_GOODS_ID, state, target)
manifest_text = result.manifest_path.read_text(encoding="utf-8")
manifest = json.loads(manifest_text)
app = json.loads(result.app_path.read_text(encoding="utf-8"))
self.assertEqual(adb.calls, [SERIAL])
self.assertEqual(connector_calls, [SERIAL])
self.assertEqual(foreground.calls, [SERIAL, SERIAL])
self.assertEqual(
[call[1] for call in device.calls if call[0] == "jsonrpc"],
["takeScreenshot", "dumpWindowHierarchy"],
)
self.assertEqual(manifest["operation"], "t106-order-confirm-readonly-evidence")
self.assertEqual(manifest["human_declared_state"], state)
self.assertEqual(manifest["review_status"], "human_review_required")
self.assertEqual(manifest["product"]["goods_id"], EXPECTED_GOODS_ID)
self.assertEqual(app["package"], "com.xunmeng.pinduoduo")
self.assertEqual(
{path.name for path in target.iterdir()},
{"screenshot.png", "hierarchy.xml", "app.json", "manifest.json"},
)
self.assertNotIn(SERIAL, manifest_text)
self.assertNotIn("NewPageActivity", manifest_text)
self.assertNotIn("local raw page", manifest_text)
for artifact in manifest["artifacts"]:
self.assertEqual(len(artifact["sha256"]), 64)
def test_invalid_inputs_and_existing_target_stop_before_device_access(self) -> None:
scenarios = (
("", EXPECTED_GOODS_ID, DECLARED_STATES[0]),
(f" {SERIAL}", EXPECTED_GOODS_ID, DECLARED_STATES[0]),
(SERIAL, "958756616606", DECLARED_STATES[0]),
(SERIAL, EXPECTED_GOODS_ID, "unknown"),
)
for serial, goods_id, state in scenarios:
with self.subTest(state=state), TemporaryDirectory() as temporary:
adb = FakeAdbClient()
connector_calls: list[str] = []
with self.assertRaises(OrderConfirmEvidenceError):
self._capturer(
adb,
FakeReadDevice(),
connector_calls=connector_calls,
).capture(serial, goods_id, state, Path(temporary) / "evidence")
self.assertEqual(adb.calls, [])
self.assertEqual(connector_calls, [])
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
target.mkdir()
sentinel = target / "sentinel.txt"
sentinel.write_text("keep", encoding="utf-8")
adb = FakeAdbClient()
with self.assertRaises(OrderConfirmEvidenceError):
self._capturer(adb, FakeReadDevice()).capture(
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
)
self.assertEqual(adb.calls, [])
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
def test_device_version_package_activity_and_screen_mismatch_fail_closed(self) -> None:
scenarios = (
(FakeAdbClient(model="OTHER"), FakeReadDevice(), FakeForegroundReader()),
(FakeAdbClient(android_version="15"), FakeReadDevice(), FakeForegroundReader()),
(FakeAdbClient(), FakeReadDevice(version="8.17.1"), FakeForegroundReader()),
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(package="com.example.other")),
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(activity="")),
(FakeAdbClient(), FakeReadDevice(screen_size=(1080, 2400)), FakeForegroundReader()),
)
for adb, device, foreground in scenarios:
with self.subTest(device=device.__dict__), TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(OrderConfirmEvidenceError):
self._capturer(adb, device, foreground=foreground).capture(
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
self.assertEqual([call for call in device.calls if call[0] == "jsonrpc"], [])
def test_invalid_screenshot_or_hierarchy_never_publishes(self) -> None:
devices = (
FakeReadDevice(screenshot="not base64"),
FakeReadDevice(screenshot=123),
FakeReadDevice(hierarchy="<not-hierarchy />"),
FakeReadDevice(hierarchy=123),
)
for device in devices:
with self.subTest(value=device.screenshot), TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(OrderConfirmEvidenceError):
self._capturer(FakeAdbClient(), device).capture(
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_timeout_and_unexpected_failure_are_redacted(self) -> None:
class BrokenDevice(FakeReadDevice):
def __init__(self, failure: BaseException) -> None:
super().__init__()
self.failure = failure
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
raise self.failure
failures = (
(TimeoutError(f"secret {SERIAL} C:\\private\\raw.xml"), OrderConfirmEvidenceTimeoutError),
(RuntimeError(f"secret {SERIAL} C:\\private\\raw.xml"), OrderConfirmEvidenceError),
)
for failure, expected in failures:
with self.subTest(expected=expected), TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(expected) as raised:
self._capturer(FakeAdbClient(), BrokenDevice(failure)).capture(
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
)
self.assertNotIn(SERIAL, str(raised.exception))
self.assertNotIn("private", str(raised.exception))
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_post_capture_identity_drift_never_publishes(self) -> None:
mutators = (
lambda device, foreground: setattr(device, "post_version", "8.17.1"),
lambda device, foreground: setattr(foreground, "post_package", "com.example.other"),
lambda device, foreground: setattr(foreground, "post_activity", "OtherActivity"),
lambda device, foreground: setattr(device, "post_screen_size", (1080, 2400)),
)
for mutate in mutators:
with TemporaryDirectory() as temporary:
device = FakeReadDevice()
foreground = FakeForegroundReader()
mutate(device, foreground)
target = Path(temporary) / "evidence"
with self.assertRaises(OrderConfirmEvidenceError):
self._capturer(
FakeAdbClient(), device, foreground=foreground
).capture(SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_atomic_publish_failure_cleans_staging(self) -> None:
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with (
patch(
"cmbuyer_client.pdd.order_confirm_spike.os.rename",
side_effect=OSError(f"private {SERIAL}"),
),
self.assertRaises(OrderConfirmEvidenceError) as raised,
):
self._capturer(FakeAdbClient(), FakeReadDevice()).capture(
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
)
self.assertNotIn(SERIAL, str(raised.exception))
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_capturer_is_one_shot_even_after_rejection(self) -> None:
capturer = self._capturer(FakeAdbClient(), FakeReadDevice())
with TemporaryDirectory() as temporary:
with self.assertRaises(OrderConfirmEvidenceError):
capturer.capture("", EXPECTED_GOODS_ID, DECLARED_STATES[0], Path(temporary) / "bad")
with self.assertRaises(OrderConfirmEvidenceError):
capturer.capture(
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], Path(temporary) / "good"
)
def test_public_boundaries_expose_reading_only(self) -> None:
forbidden = {
"click",
"swipe",
"press",
"pressKey",
"send_keys",
"set_text",
"start_pdd_view_intent",
"open_product",
"open_sku_panel",
"set_quantity",
"go_to_order_confirm",
"submit_order_once",
"pay",
}
self.assertTrue(forbidden.isdisjoint(OrderConfirmReadDevice.__dict__))
self.assertTrue(forbidden.isdisjoint(OrderConfirmForegroundReader.__dict__))
self.assertEqual(
{name for name in Android16ForegroundReader.__dict__ if not name.startswith("_")},
{"read"},
)
self.assertEqual(
{name for name in OrderConfirmEvidenceCapturer.__dict__ if not name.startswith("_")},
{"capture"},
)
class OrderConfirmCliAndStaticBoundaryTests(unittest.TestCase):
def test_cli_parses_each_approved_state(self) -> None:
script = _load_script()
for state in DECLARED_STATES:
arguments = script.parse_arguments( # type: ignore[attr-defined]
[
"--serial",
SERIAL,
"--goods-id",
EXPECTED_GOODS_ID,
"--state",
state,
"--output-dir",
f"evidence-{state}",
]
)
script.validate_arguments(arguments) # type: ignore[attr-defined]
def test_cli_validation_rejects_unapproved_values(self) -> None:
script = _load_script()
invalid = (
_namespace(serial=""),
_namespace(serial=f" {SERIAL}"),
_namespace(goods_id="958756616606"),
_namespace(state="unknown"),
_namespace(output_dir=Path("")),
_namespace(timeout=0),
_namespace(timeout=float("inf")),
)
for arguments in invalid:
with self.subTest(arguments=arguments), self.assertRaises(ValueError):
script.validate_arguments(arguments) # type: ignore[attr-defined]
def test_cli_runtime_failure_never_echoes_sensitive_values(self) -> None:
script = _load_script()
fake_capturer = unittest.mock.Mock()
fake_capturer.capture.side_effect = OrderConfirmEvidenceError(
f"secret {SERIAL} C:\\private\\raw.xml body"
)
stderr = StringIO()
with (
patch.object(script, "OrderConfirmEvidenceCapturer", return_value=fake_capturer),
patch.dict(
sys.modules,
{"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()},
),
redirect_stderr(stderr),
):
result = script.main( # type: ignore[attr-defined]
[
"--serial",
SERIAL,
"--goods-id",
EXPECTED_GOODS_ID,
"--state",
DECLARED_STATES[0],
"--output-dir",
"C:\\private\\evidence",
]
)
self.assertEqual(result, 1)
self.assertNotIn(SERIAL, stderr.getvalue())
self.assertNotIn("private", stderr.getvalue())
self.assertNotIn("body", stderr.getvalue())
def test_cli_success_does_not_echo_local_path_or_device(self) -> None:
script = _load_script()
fake_capturer = unittest.mock.Mock()
stdout = StringIO()
with (
patch.object(script, "OrderConfirmEvidenceCapturer", return_value=fake_capturer),
patch.dict(
sys.modules,
{"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()},
),
redirect_stdout(stdout),
):
result = script.main( # type: ignore[attr-defined]
[
"--serial",
SERIAL,
"--goods-id",
EXPECTED_GOODS_ID,
"--state",
DECLARED_STATES[0],
"--output-dir",
"C:\\private\\evidence",
]
)
self.assertEqual(result, 0)
self.assertNotIn(SERIAL, stdout.getvalue())
self.assertNotIn("private", stdout.getvalue())
fake_capturer.capture.assert_called_once()
def test_sources_have_only_approved_read_rpc_literals_and_no_ui_mutators(self) -> None:
expected_by_source = {
Path("src/cmbuyer_client/pdd/order_confirm_spike.py"): {
"takeScreenshot",
"dumpWindowHierarchy",
},
Path("scripts/capture_order_confirm_spike.py"): set(),
}
forbidden_attributes = {
"click",
"swipe",
"press",
"pressKey",
"send_keys",
"set_text",
"start_pdd_view_intent",
"open_product",
"open_sku_panel",
"set_quantity_and_readback",
"go_to_order_confirm",
"submit_order_once",
}
forbidden_import_fragments = {
"quantity_gate2_runner",
"sku_selection_runner",
"submission",
"payment",
}
for relative, expected_rpc in expected_by_source.items():
source = (CLIENT_ROOT / relative).read_text(encoding="utf-8")
tree = ast.parse(source)
rpc_literals = {
node.args[1].value
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "_read_rpc"
and len(node.args) > 1
and isinstance(node.args[1], ast.Constant)
and isinstance(node.args[1].value, str)
}
self.assertEqual(rpc_literals, expected_rpc)
observed_attributes = {
node.attr
for node in ast.walk(tree)
if isinstance(node, ast.Attribute) and node.attr in forbidden_attributes
}
self.assertEqual(observed_attributes, set())
imported_modules = {
alias.name
for node in ast.walk(tree)
if isinstance(node, (ast.Import, ast.ImportFrom))
for alias in node.names
}
for fragment in forbidden_import_fragments:
self.assertTrue(all(fragment not in module for module in imported_modules))
if __name__ == "__main__":
unittest.main()
+944
View File
@@ -0,0 +1,944 @@
"""T-105 数量两态只读取证与后续生产边界的离线测试。"""
from __future__ import annotations
import argparse
import ast
import base64
from contextlib import redirect_stderr
from datetime import UTC, datetime
from functools import lru_cache
from importlib.util import module_from_spec, spec_from_file_location
from io import BytesIO, StringIO
import json
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
from PIL import Image
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbDevice, CommandResult, DeviceInspection
from cmbuyer_client.pdd.quantity_gate2_spike import (
Android16TopResumedForegroundReader,
DECLARED_QUANTITIES,
EXPECTED_GOODS_ID,
QuantityGate2EvidenceCapturer,
QuantityGate2EvidenceError,
QuantityGate2EvidenceTimeoutError,
QuantityGate2ForegroundReader,
QuantityGate2ReadDevice,
)
from cmbuyer_client.pdd.quantity_gate2 import (
Gate1Observation,
QuantityGate2Device,
QuantityGate2Error,
QuantityGate2Flow,
QuantityGate2OverCapError,
)
from cmbuyer_client.pdd.quantity_gate2_runner import QuantityGate2Runner
SERIAL = "192.168.0.173:5555"
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node text='local raw page' /></hierarchy>"
TOP_RESUMED = (
" topResumedActivity=ActivityRecord{101034589 u0 "
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
)
@lru_cache(maxsize=1)
def _png_base64() -> str:
raw = BytesIO()
Image.new("RGB", (1080, 2376), color="white").save(raw, format="PNG")
return base64.b64encode(raw.getvalue()).decode("ascii")
class FakeAdbClient:
def __init__(self, *, model: str = "PKG110", android_version: str = "16") -> None:
self.calls: list[str] = []
self.inspection = DeviceInspection(
device=AdbDevice(serial=SERIAL, state="device", model=model),
model=model,
android_version=android_version,
)
def inspect(self, serial: str) -> DeviceInspection:
self.calls.append(serial)
return self.inspection
class FakeReadDevice:
def __init__(
self,
*,
version: str = "8.17.0",
screen_size: tuple[int, int] = (1080, 2376),
screenshot: object | None = None,
hierarchy: object = HIERARCHY,
) -> None:
self.version = version
self.screen_size = screen_size
self.screenshot = _png_base64() if screenshot is None else screenshot
self.hierarchy = hierarchy
self.calls: list[tuple[object, ...]] = []
self.app_info_reads = 0
self.window_reads = 0
self.post_version: str | None = None
self.post_screen_size: tuple[int, int] | None = None
def app_info(self, package_name: str) -> dict[str, str]:
self.calls.append(("app_info", package_name))
self.app_info_reads += 1
version = self.post_version if self.app_info_reads > 1 and self.post_version is not None else self.version
return {"versionName": version}
def window_size(self) -> tuple[int, int]:
self.calls.append(("window_size",))
self.window_reads += 1
if self.window_reads > 1 and self.post_screen_size is not None:
return self.post_screen_size
return self.screen_size
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "takeScreenshot":
return self.screenshot
if method == "dumpWindowHierarchy":
return self.hierarchy
raise AssertionError(f"unexpected RPC {method}")
class FakeForegroundReader:
def __init__(
self,
*,
package: str = "com.xunmeng.pinduoduo",
activity: str = ".activity.NewPageActivity",
) -> None:
self.package = package
self.activity = activity
self.post_package: str | None = None
self.post_activity: str | None = None
self.calls: list[str] = []
def read(self, serial: str) -> dict[str, str]:
self.calls.append(serial)
package = self.post_package if len(self.calls) > 1 and self.post_package is not None else self.package
activity = self.post_activity if len(self.calls) > 1 and self.post_activity is not None else self.activity
return {"package": package, "activity": activity}
class FakeCommandRunner:
def __init__(self, result: CommandResult) -> None:
self.result = result
self.calls: list[tuple[tuple[str, ...], float]] = []
def run(self, arguments: tuple[str, ...], timeout_seconds: float) -> CommandResult:
self.calls.append((arguments, timeout_seconds))
return self.result
def _load_script() -> object:
script_path = CLIENT_ROOT / "scripts" / "capture_quantity_gate2_spike.py"
spec = spec_from_file_location("capture_quantity_gate2_spike_for_test", script_path)
assert spec is not None and spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _load_run_script() -> object:
script_path = CLIENT_ROOT / "scripts" / "run_t105_quantity_gate2.py"
spec = spec_from_file_location("run_t105_quantity_gate2_for_test", script_path)
assert spec is not None and spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _namespace(**changes: object) -> argparse.Namespace:
values: dict[str, object] = {
"serial": SERIAL,
"goods_id": EXPECTED_GOODS_ID,
"state": "initial",
"declared_quantity": 1,
"output_dir": Path("evidence"),
"timeout": 10.0,
"adb": "adb",
}
values.update(changes)
return argparse.Namespace(**values)
class Android16TopResumedForegroundReaderTests(unittest.TestCase):
def test_reads_exact_unique_android16_top_resumed_activity(self) -> None:
runner = FakeCommandRunner(CommandResult(stdout=TOP_RESUMED))
reader = Android16TopResumedForegroundReader(runner, timeout_seconds=7)
self.assertEqual(
reader.read(SERIAL),
{
"package": "com.xunmeng.pinduoduo",
"activity": ".activity.NewPageActivity",
},
)
self.assertEqual(
runner.calls,
[
(
(
"-s",
SERIAL,
"shell",
"dumpsys",
"activity",
"activities",
),
7,
)
],
)
def test_missing_legacy_or_duplicate_top_resumed_activity_is_rejected(self) -> None:
rejected_outputs = (
"",
(
"mResumedActivity: ActivityRecord{101034589 u0 "
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
),
TOP_RESUMED + TOP_RESUMED,
"topResumedActivity=null\n",
)
for output in rejected_outputs:
with self.subTest(output=output):
reader = Android16TopResumedForegroundReader(
FakeCommandRunner(CommandResult(stdout=output)),
timeout_seconds=7,
)
with self.assertRaises(QuantityGate2EvidenceError):
reader.read(SERIAL)
def test_command_failure_and_invalid_serial_are_rejected(self) -> None:
runner = FakeCommandRunner(CommandResult(stdout=TOP_RESUMED, returncode=1))
reader = Android16TopResumedForegroundReader(runner, timeout_seconds=7)
with self.assertRaises(QuantityGate2EvidenceError):
reader.read(SERIAL)
with self.assertRaises(QuantityGate2EvidenceError):
reader.read(f" {SERIAL}")
self.assertEqual(len(runner.calls), 1)
class QuantityGate2EvidenceTests(unittest.TestCase):
def _capturer(
self,
adb: FakeAdbClient,
device: FakeReadDevice,
*,
foreground: FakeForegroundReader | None = None,
connector_calls: list[str] | None = None,
) -> QuantityGate2EvidenceCapturer:
def connect(serial: str) -> FakeReadDevice:
if connector_calls is not None:
connector_calls.append(serial)
return device
return QuantityGate2EvidenceCapturer(
adb,
connect,
foreground or FakeForegroundReader(),
timeout_seconds=2,
)
def test_initial_and_target_states_publish_only_raw_read_evidence(self) -> None:
for state, quantity in DECLARED_QUANTITIES.items():
with self.subTest(state=state), TemporaryDirectory() as temporary:
adb = FakeAdbClient()
device = FakeReadDevice()
foreground = FakeForegroundReader()
connector_calls: list[str] = []
target = Path(temporary) / state
result = self._capturer(
adb,
device,
foreground=foreground,
connector_calls=connector_calls,
).capture(
SERIAL,
EXPECTED_GOODS_ID,
state,
quantity,
target,
)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
app = json.loads(result.app_path.read_text(encoding="utf-8"))
self.assertEqual(adb.calls, [SERIAL])
self.assertEqual(connector_calls, [SERIAL])
self.assertEqual(foreground.calls, [SERIAL, SERIAL])
self.assertEqual(
[call[1] for call in device.calls if call[0] == "jsonrpc"],
["takeScreenshot", "dumpWindowHierarchy"],
)
self.assertEqual(manifest["operation"], "t105-quantity-gate2-readonly-evidence")
self.assertEqual(manifest["human_declared_state"], state)
self.assertEqual(manifest["human_declared_quantity"], quantity)
self.assertEqual(
manifest["human_declared_selection"],
{"color": "黑色CHA(纯棉)", "size": "M(建议100-115)"},
)
self.assertEqual(manifest["review_status"], "human_review_required")
self.assertEqual(manifest["product"]["goods_id"], EXPECTED_GOODS_ID)
self.assertEqual(app["package"], "com.xunmeng.pinduoduo")
self.assertEqual(
{path.name for path in target.iterdir()},
{"screenshot.png", "hierarchy.xml", "app.json", "manifest.json"},
)
serialized = result.manifest_path.read_text(encoding="utf-8")
self.assertNotIn(SERIAL, serialized)
self.assertNotIn("NewPageActivity", serialized)
self.assertNotIn("local raw page", serialized)
for artifact in manifest["artifacts"]:
self.assertEqual(len(artifact["sha256"]), 64)
def test_invalid_inputs_and_existing_target_fail_before_device_access(self) -> None:
scenarios = (
("", EXPECTED_GOODS_ID, "initial", 1),
(f" {SERIAL}", EXPECTED_GOODS_ID, "initial", 1),
(SERIAL, "958756616606", "initial", 1),
(SERIAL, EXPECTED_GOODS_ID, "unknown", 1),
(SERIAL, EXPECTED_GOODS_ID, "initial", 2),
(SERIAL, EXPECTED_GOODS_ID, "target", 1),
(SERIAL, EXPECTED_GOODS_ID, "initial", True),
)
for serial, goods_id, state, quantity in scenarios:
with self.subTest(state=state, quantity=quantity), TemporaryDirectory() as temporary:
adb = FakeAdbClient()
connector_calls: list[str] = []
with self.assertRaises(QuantityGate2EvidenceError):
self._capturer(adb, FakeReadDevice(), connector_calls=connector_calls).capture(
serial,
goods_id,
state,
quantity,
Path(temporary) / "evidence",
)
self.assertEqual(adb.calls, [])
self.assertEqual(connector_calls, [])
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
target.mkdir()
sentinel = target / "sentinel.txt"
sentinel.write_text("keep", encoding="utf-8")
adb = FakeAdbClient()
with self.assertRaises(QuantityGate2EvidenceError):
self._capturer(adb, FakeReadDevice()).capture(
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
)
self.assertEqual(adb.calls, [])
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
def test_device_version_package_activity_and_screen_mismatch_fail_closed(self) -> None:
scenarios = (
(FakeAdbClient(model="OTHER"), FakeReadDevice(), FakeForegroundReader()),
(FakeAdbClient(android_version="15"), FakeReadDevice(), FakeForegroundReader()),
(FakeAdbClient(), FakeReadDevice(version="8.17.1"), FakeForegroundReader()),
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(package="com.example.other")),
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(activity="")),
(FakeAdbClient(), FakeReadDevice(screen_size=(1080, 2400)), FakeForegroundReader()),
)
for adb, device, foreground in scenarios:
with self.subTest(device=device.__dict__), TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(QuantityGate2EvidenceError):
self._capturer(adb, device, foreground=foreground).capture(
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
self.assertEqual(
[call for call in device.calls if call[0] == "jsonrpc"],
[],
)
def test_invalid_screenshot_or_hierarchy_leaves_no_partial_evidence(self) -> None:
scenarios = (
FakeReadDevice(screenshot="not base64"),
FakeReadDevice(screenshot=123),
FakeReadDevice(hierarchy="<not-hierarchy />"),
FakeReadDevice(hierarchy=123),
)
for device in scenarios:
with self.subTest(value=device.screenshot), TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(QuantityGate2EvidenceError):
self._capturer(FakeAdbClient(), device).capture(
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_timeout_is_redacted_and_cleans_staging(self) -> None:
class TimeoutDevice(FakeReadDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "takeScreenshot":
raise TimeoutError(f"secret {SERIAL} C:\\private\\evidence")
return super().jsonrpc_call(method, params, timeout)
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(QuantityGate2EvidenceTimeoutError) as raised:
self._capturer(FakeAdbClient(), TimeoutDevice()).capture(
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
)
self.assertNotIn(SERIAL, str(raised.exception))
self.assertNotIn("private", str(raised.exception))
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_post_capture_app_or_screen_drift_does_not_publish(self) -> None:
drift_mutators = (
lambda device, foreground: setattr(device, "post_version", "8.17.1"),
lambda device, foreground: setattr(foreground, "post_package", "com.example.other"),
lambda device, foreground: setattr(foreground, "post_activity", "OtherActivity"),
lambda device, foreground: setattr(device, "post_screen_size", (1080, 2400)),
)
for mutate in drift_mutators:
with TemporaryDirectory() as temporary:
device = FakeReadDevice()
foreground = FakeForegroundReader()
mutate(device, foreground)
target = Path(temporary) / "evidence"
with self.assertRaises(QuantityGate2EvidenceError):
self._capturer(
FakeAdbClient(),
device,
foreground=foreground,
).capture(
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_capturer_is_one_shot_even_after_rejection(self) -> None:
capturer = self._capturer(FakeAdbClient(), FakeReadDevice())
with TemporaryDirectory() as temporary:
with self.assertRaises(QuantityGate2EvidenceError):
capturer.capture("", EXPECTED_GOODS_ID, "initial", 1, Path(temporary) / "bad")
with self.assertRaises(QuantityGate2EvidenceError):
capturer.capture(SERIAL, EXPECTED_GOODS_ID, "initial", 1, Path(temporary) / "good")
def test_read_protocol_and_capturer_expose_no_page_actions(self) -> None:
forbidden = {
"click",
"swipe",
"press",
"pressKey",
"send_keys",
"set_text",
"open_product",
"open_sku_panel",
"set_quantity",
"go_to_order_confirm",
"submit_order_once",
"pay",
}
self.assertTrue(forbidden.isdisjoint(QuantityGate2ReadDevice.__dict__))
self.assertTrue(forbidden.isdisjoint(QuantityGate2ForegroundReader.__dict__))
self.assertEqual(
{
name
for name in Android16TopResumedForegroundReader.__dict__
if not name.startswith("_")
},
{"read"},
)
self.assertEqual(
{name for name in QuantityGate2EvidenceCapturer.__dict__ if not name.startswith("_")},
{"capture"},
)
class QuantityGate2CliAndStaticBoundaryTests(unittest.TestCase):
def test_cli_validation_rejects_all_unapproved_combinations(self) -> None:
script = _load_script()
invalid = (
_namespace(serial=""),
_namespace(serial=f" {SERIAL}"),
_namespace(goods_id="958756616606"),
_namespace(state="unknown"),
_namespace(declared_quantity=2),
_namespace(state="target", declared_quantity=1),
_namespace(output_dir=Path("")),
_namespace(timeout=0),
_namespace(timeout=float("inf")),
)
for arguments in invalid:
with self.subTest(arguments=arguments), self.assertRaises(ValueError):
script.validate_arguments(arguments) # type: ignore[attr-defined]
def test_cli_parses_exact_initial_and_target_arguments(self) -> None:
script = _load_script()
for state, quantity in DECLARED_QUANTITIES.items():
arguments = script.parse_arguments( # type: ignore[attr-defined]
[
"--serial",
SERIAL,
"--goods-id",
EXPECTED_GOODS_ID,
"--state",
state,
"--declared-quantity",
str(quantity),
"--output-dir",
f"evidence-{state}",
]
)
script.validate_arguments(arguments) # type: ignore[attr-defined]
def test_cli_runtime_failure_does_not_echo_sensitive_values(self) -> None:
script = _load_script()
error = QuantityGate2EvidenceError(f"secret {SERIAL} C:\\private\\evidence raw body")
stderr = StringIO()
fake_capturer = unittest.mock.Mock()
fake_capturer.capture.side_effect = error
with (
patch.object(script, "QuantityGate2EvidenceCapturer", return_value=fake_capturer),
patch.dict(sys.modules, {"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()}),
redirect_stderr(stderr),
):
result = script.main(
[
"--serial",
SERIAL,
"--goods-id",
EXPECTED_GOODS_ID,
"--state",
"initial",
"--declared-quantity",
"1",
"--output-dir",
"C:\\private\\evidence",
]
)
self.assertEqual(result, 1)
self.assertNotIn(SERIAL, stderr.getvalue())
self.assertNotIn("private", stderr.getvalue())
self.assertNotIn("raw body", stderr.getvalue())
def test_stage_one_sources_contain_only_approved_rpc_method_literals(self) -> None:
expected_by_source = {
Path("src/cmbuyer_client/pdd/quantity_gate2_spike.py"): {
"takeScreenshot",
"dumpWindowHierarchy",
},
Path("scripts/capture_quantity_gate2_spike.py"): set(),
}
for relative, expected in expected_by_source.items():
tree = ast.parse((CLIENT_ROOT / relative).read_text(encoding="utf-8"))
rpc_literals = {
node.args[1].value
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "_read_rpc"
and len(node.args) > 1
and isinstance(node.args[1], ast.Constant)
and isinstance(node.args[1].value, str)
}
self.assertEqual(rpc_literals, expected)
forbidden_attributes = {
node.attr
for node in ast.walk(tree)
if isinstance(node, ast.Attribute)
and node.attr in {"click", "swipe", "press", "pressKey", "set_text", "send_keys", "start_pdd_view_intent"}
}
self.assertEqual(forbidden_attributes, set())
def test_t103_and_t104_sources_do_not_import_quantity_gate2(self) -> None:
forbidden_import = "quantity_gate2"
sources = (
CLIENT_ROOT / "src/cmbuyer_client/pdd/sku_selection.py",
CLIENT_ROOT / "src/cmbuyer_client/pdd/sku_selection_runner.py",
)
for source in sources:
self.assertNotIn(forbidden_import, source.read_text(encoding="utf-8"))
_INITIAL_FIXTURE = CLIENT_ROOT / "tests" / "pdd" / "fixtures" / "quantity_gate2_initial_8_17_0.xml"
_TARGET_FIXTURE = CLIENT_ROOT / "tests" / "pdd" / "fixtures" / "quantity_gate2_target_8_17_0.xml"
_EXIT_FIXTURE = CLIENT_ROOT / "tests" / "pdd" / "fixtures" / "product_exit_8_17_0.xml"
def _gate1(path: Path = Path("gate1.png")) -> Gate1Observation:
return Gate1Observation(
color="黑色CHA(纯棉)",
size="M(建议100-115)",
quantity=1,
gate1_unit_price="12.88",
screenshot_path=path,
captured_at=datetime(2026, 8, 6, 0, 53, tzinfo=UTC),
)
class FakeQuantityFlowDevice:
def __init__(self, initial: str | None = None, target: str | None = None) -> None:
self.initial = initial or _INITIAL_FIXTURE.read_text(encoding="utf-8")
self.target = target or _TARGET_FIXTURE.read_text(encoding="utf-8")
self.exit = _EXIT_FIXTURE.read_text(encoding="utf-8")
self.current = self.initial
self.version = "8.17.0"
self.foreground = {"package": "com.xunmeng.pinduoduo", "activity": ".activity.NewPageActivity"}
self.screen_size = (1080, 2376)
self.increment_calls: list[str] = []
self.back_calls = 0
self.dump_calls = 0
self.raise_increment: BaseException | None = None
def app_info(self, package_name: str) -> dict[str, str]:
return {"versionName": self.version}
def current_foreground(self) -> dict[str, str]:
return dict(self.foreground)
def display_size(self) -> tuple[int, int]:
return self.screen_size
def dump_window_hierarchy(self) -> str:
self.dump_calls += 1
return self.current
def increment_quantity_once(self, bounds: str) -> None:
self.increment_calls.append(bounds)
if self.raise_increment is not None:
raise self.raise_increment
self.current = self.target
def capture_screenshot(self) -> str:
return _png_base64()
def leave_sku_panel_once(self) -> None:
self.back_calls += 1
self.current = self.exit
class QuantityGate2FlowTests(unittest.TestCase):
def test_quantity_two_reads_nonlinear_panel_total_and_exits_once(self) -> None:
device = FakeQuantityFlowDevice()
flow = QuantityGate2Flow(device, wait_timeout_seconds=0.1, poll_interval_seconds=0.01)
verified = flow.set_quantity_and_verify(_gate1(), 2, "40.00")
observation = flow.build_observation(
_gate1(),
2,
"40.00",
Path("gate2.png"),
datetime(2026, 8, 6, 1, 10, tzinfo=UTC),
)
flow.exit_sku_panel_safely()
self.assertEqual(verified.quantity, 2)
self.assertEqual(observation.quantity_read, 2)
self.assertEqual(observation.gate1_unit_price, "12.88")
self.assertEqual(observation.gate2_panel_total_price, "32.76")
self.assertEqual(observation.max_total_price, "40.00")
self.assertEqual(device.increment_calls, ["[567,752][645,827]"])
self.assertEqual(device.back_calls, 1)
self.assertTrue(flow.exited)
def test_quantity_one_is_zero_click_and_still_uses_panel_amount(self) -> None:
device = FakeQuantityFlowDevice()
flow = QuantityGate2Flow(device)
verified = flow.set_quantity_and_verify(_gate1(), 1, "12.88")
self.assertEqual((verified.quantity, verified.panel_total_price), (1, "12.88"))
self.assertEqual(device.increment_calls, [])
def test_only_evidenced_quantities_and_canonical_decimal_cap_are_allowed(self) -> None:
for quantity in (0, 3, -1, True, "2"):
with self.subTest(quantity=quantity):
device = FakeQuantityFlowDevice()
with self.assertRaises(QuantityGate2Error):
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), quantity, "40.00") # type: ignore[arg-type]
self.assertEqual(device.increment_calls, [])
for cap in ("0.00", "40", "040.00", 40.0, "NaN"):
with self.subTest(cap=cap):
device = FakeQuantityFlowDevice()
with self.assertRaises(QuantityGate2Error):
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, cap) # type: ignore[arg-type]
self.assertEqual(device.increment_calls, [])
def test_over_cap_stops_after_exact_readback_without_fabricated_math(self) -> None:
device = FakeQuantityFlowDevice()
flow = QuantityGate2Flow(device)
with self.assertRaises(QuantityGate2OverCapError):
flow.set_quantity_and_verify(_gate1(), 2, "30.00")
self.assertEqual(device.increment_calls, ["[567,752][645,827]"])
self.assertTrue(flow.can_exit_safely)
def test_increment_timeout_is_sealed_and_cannot_be_retried(self) -> None:
device = FakeQuantityFlowDevice()
device.raise_increment = TimeoutError("ambiguous delivery")
flow = QuantityGate2Flow(device, wait_timeout_seconds=0.01, poll_interval_seconds=0.005)
with self.assertRaises(TimeoutError):
flow.set_quantity_and_verify(_gate1(), 2, "40.00")
with self.assertRaises(QuantityGate2Error):
flow.set_quantity_and_verify(_gate1(), 2, "40.00")
self.assertEqual(len(device.increment_calls), 1)
self.assertFalse(flow.can_exit_safely)
def test_missing_duplicate_drift_and_overlay_fail_before_click(self) -> None:
initial = _INITIAL_FIXTURE.read_text(encoding="utf-8")
plus = '<node content-desc="增加数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[567,752][645,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />'
variants = (
initial.replace('content-desc="增加数量"', 'content-desc="数量加一"', 1),
initial.replace(plus, plus + plus),
initial.replace('text="M(建议100-115)"', 'text="S(建议80-100)"', 1),
initial.replace(
"</hierarchy>",
'<node package="com.example.overlay" class="android.view.ViewGroup" bounds="[560,740][660,840]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" /></hierarchy>',
),
)
for hierarchy in variants:
with self.subTest(marker=hierarchy[-180:]):
device = FakeQuantityFlowDevice(initial=hierarchy)
with self.assertRaises(QuantityGate2Error):
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, "40.00")
self.assertEqual(device.increment_calls, [])
def test_foreground_version_and_screen_drift_are_zero_click(self) -> None:
devices = (FakeQuantityFlowDevice(), FakeQuantityFlowDevice(), FakeQuantityFlowDevice())
devices[0].foreground = {"package": "com.android.systemui", "activity": ".Keyguard"}
devices[1].version = "8.17.1"
devices[2].screen_size = (1080, 2400)
for device in devices:
with self.subTest(device=device):
with self.assertRaises(QuantityGate2Error):
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, "40.00")
self.assertEqual(device.increment_calls, [])
def test_bottom_submit_amount_is_never_a_gate2_candidate(self) -> None:
target = _TARGET_FIXTURE.read_text(encoding="utf-8").replace("提交订单 ¥32.76", "提交订单 ¥999.99")
device = FakeQuantityFlowDevice(target=target)
result = QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, "40.00")
self.assertEqual(result.panel_total_price, "32.76")
def test_gate1_target_spec_and_timestamp_are_strict(self) -> None:
invalid = (
{"color": "白色"},
{"size": "L"},
{"quantity": 2},
{"gate1_unit_price": "12.89"},
{"captured_at": datetime(2026, 8, 6, 0, 53)},
)
base = _gate1().__dict__
for change in invalid:
with self.subTest(change=change), self.assertRaises(QuantityGate2Error):
Gate1Observation(**{**base, **change})
class FakeRawQuantityDevice:
def __init__(self, *, click_timeout: bool = False) -> None:
self.current = _INITIAL_FIXTURE.read_text(encoding="utf-8")
self.target = _TARGET_FIXTURE.read_text(encoding="utf-8")
self.exit = _EXIT_FIXTURE.read_text(encoding="utf-8")
self.calls: list[tuple[object, ...]] = []
self.click_timeout = click_timeout
def app_info(self, package: str) -> dict[str, str]:
self.calls.append(("app_info", package))
return {"versionName": "8.17.0"}
def window_size(self) -> tuple[int, int]:
self.calls.append(("window_size",))
return (1080, 2376)
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "dumpWindowHierarchy":
return self.current
if method == "click":
self.current = self.target
if self.click_timeout:
raise TimeoutError("may have been delivered")
return None
if method == "takeScreenshot":
return _png_base64()
if method == "pressKey":
self.current = self.exit
return None
raise AssertionError(method)
class QuantityGate2RunnerTests(unittest.TestCase):
def test_runner_publishes_gate2_evidence_then_one_safe_exit(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate1_path = base / "gate1.png"
gate1_path.write_bytes(base64.b64decode(_png_base64()))
target = base / "gate2"
raw = FakeRawQuantityDevice()
result = QuantityGate2Runner(
FakeAdbClient(),
lambda serial: raw,
FakeForegroundReader(),
timeout_seconds=0.1,
).run(SERIAL, EXPECTED_GOODS_ID, _gate1(gate1_path), 2, "40.00", target)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods.count("click"), 1)
self.assertEqual(methods.count("pressKey"), 1)
self.assertEqual(methods.count("takeScreenshot"), 1)
self.assertEqual(result.observation.gate2_panel_total_price, "32.76")
self.assertEqual(manifest["prices"]["gate1_unit_price"], "12.88")
self.assertEqual(manifest["prices"]["gate2_panel_total_price"], "32.76")
self.assertEqual(manifest["prices"]["max_total_price"], "40.00")
self.assertEqual(manifest["action_audit"]["increment_attempts"], 1)
self.assertEqual(manifest["action_audit"]["back_attempts"], 1)
self.assertEqual(manifest["safe_exit"], "completed")
self.assertEqual(manifest["review_status"], "human_review_required")
serialized = result.manifest_path.read_text(encoding="utf-8")
self.assertNotIn(SERIAL, serialized)
self.assertNotIn(str(gate1_path), serialized)
def test_over_cap_attempts_one_safe_exit_and_publishes_nothing(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate1_path = base / "gate1.png"
gate1_path.write_bytes(base64.b64decode(_png_base64()))
raw = FakeRawQuantityDevice()
target = base / "gate2"
with self.assertRaises(QuantityGate2OverCapError):
QuantityGate2Runner(
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
).run(SERIAL, EXPECTED_GOODS_ID, _gate1(gate1_path), 2, "30.00", target)
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods.count("click"), 1)
self.assertEqual(methods.count("pressKey"), 1)
self.assertFalse(target.exists())
self.assertEqual(list(base.glob(".gate2.staging-*")), [])
def test_ambiguous_increment_is_never_retried_or_followed_by_back(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate1_path = base / "gate1.png"
gate1_path.write_bytes(base64.b64decode(_png_base64()))
raw = FakeRawQuantityDevice(click_timeout=True)
with self.assertRaises(QuantityGate2Error):
QuantityGate2Runner(
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
).run(SERIAL, EXPECTED_GOODS_ID, _gate1(gate1_path), 2, "40.00", base / "gate2")
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods.count("click"), 1)
self.assertEqual(methods.count("pressKey"), 0)
class QuantityGate2ProductionStaticBoundaryTests(unittest.TestCase):
def test_protocol_has_only_named_t105_actions(self) -> None:
public = {name for name in QuantityGate2Device.__dict__ if not name.startswith("_")}
self.assertEqual(
public,
{
"app_info",
"current_foreground",
"display_size",
"dump_window_hierarchy",
"increment_quantity_once",
"capture_screenshot",
"leave_sku_panel_once",
},
)
def test_production_sources_have_no_old_gate2_field_or_forbidden_capability(self) -> None:
paths = (
CLIENT_ROOT / "src/cmbuyer_client/pdd/quantity_gate2.py",
CLIENT_ROOT / "src/cmbuyer_client/pdd/quantity_gate2_runner.py",
CLIENT_ROOT / "scripts/run_t105_quantity_gate2.py",
)
forbidden_attributes = {
"go_to_order_confirm",
"submit_order_once",
"start_pdd_view_intent",
"pay",
"swipe",
"set_text",
"send_keys",
}
for path in paths:
source = path.read_text(encoding="utf-8")
tree = ast.parse(source)
self.assertNotIn("gate2_unit_price", source)
self.assertFalse(
{
node.attr
for node in ast.walk(tree)
if isinstance(node, ast.Attribute) and node.attr in forbidden_attributes
}
)
quantity_tree = ast.parse(paths[0].read_text(encoding="utf-8"))
self.assertFalse(any(isinstance(node, ast.Mult) for node in ast.walk(quantity_tree)))
def test_t103_and_t104_still_do_not_import_t105(self) -> None:
for relative in (
"src/cmbuyer_client/pdd/sku_selection.py",
"src/cmbuyer_client/pdd/sku_selection_runner.py",
):
self.assertNotIn("quantity_gate2", (CLIENT_ROOT / relative).read_text(encoding="utf-8"))
def test_live_cli_fixes_the_human_review_case_and_redacts_runtime_failure(self) -> None:
script = _load_run_script()
with TemporaryDirectory() as temporary:
screenshot = Path(temporary) / "gate1.png"
screenshot.write_bytes(base64.b64decode(_png_base64()))
arguments = script.parse_arguments( # type: ignore[attr-defined]
[
"--serial", SERIAL,
"--goods-id", EXPECTED_GOODS_ID,
"--color", "黑色CHA(纯棉)",
"--size", "M(建议100-115)",
"--target-quantity", "2",
"--gate1-unit-price", "12.88",
"--gate1-screenshot", str(screenshot),
"--gate1-captured-at", "2026-08-06T00:53:00.023935+00:00",
"--max-total-price", "40.00",
"--output-dir", str(Path(temporary) / "gate2"),
]
)
script.validate_arguments(arguments) # type: ignore[attr-defined]
stderr = StringIO()
fake_runner = unittest.mock.Mock()
fake_runner.run.side_effect = QuantityGate2Error(f"secret {SERIAL} {temporary}")
with (
patch.object(script, "QuantityGate2Runner", return_value=fake_runner),
patch.dict(sys.modules, {"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()}),
redirect_stderr(stderr),
):
result = script.main( # type: ignore[attr-defined]
[
"--serial", SERIAL,
"--goods-id", EXPECTED_GOODS_ID,
"--color", "黑色CHA(纯棉)",
"--size", "M(建议100-115)",
"--target-quantity", "2",
"--gate1-unit-price", "12.88",
"--gate1-screenshot", str(screenshot),
"--gate1-captured-at", "2026-08-06T00:53:00.023935+00:00",
"--max-total-price", "40.00",
"--output-dir", str(Path(temporary) / "gate2"),
]
)
self.assertEqual(result, 1)
self.assertNotIn(SERIAL, stderr.getvalue())
self.assertNotIn(temporary, stderr.getvalue())
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
"""轮询协调器测试。"""
+380
View File
@@ -0,0 +1,380 @@
from __future__ import annotations
import os
import threading
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from cmbuyer_client.core.errors import (
AmbiguousRemoteError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
StateError,
)
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask
from cmbuyer_client.localstate.models import PollingSession, ProfileSettings, RecoverySnapshot
from cmbuyer_client.polling.coordinator import (
ClaimedTaskView,
PollingCoordinator,
PollingState,
StartReadiness,
)
from tests.core.test_models import claim_wire
PROFILE = "default"
SESSION_ID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
REQUEST_ID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
SENTINEL_TOKEN = "e" * 64
def profile(http_timeout: int = 10, failure_threshold: int = 3) -> ProfileSettings:
return ProfileSettings(
PROFILE,
"http://127.0.0.1:8080",
"f3c9f507-7473-4fa6-8d71-8786c34c6301",
"D:/Portable/adb/adb.exe",
"device-serial",
"usb",
failure_threshold=failure_threshold,
http_timeout_seconds=http_timeout,
)
def claimed_task() -> ClaimedTask:
wire = claim_wire()
wire["attempt"]["claim_token"] = SENTINEL_TOKEN
return ClaimedTask.from_wire(wire)
def snapshot(
*,
accept_new: bool | None = None,
pending: bool = False,
active: bool = False,
) -> RecoverySnapshot:
session = None if accept_new is None else PollingSession(PROFILE, SESSION_ID, accept_new)
request = ClaimRequest(SESSION_ID, REQUEST_ID) if pending else None
return RecoverySnapshot(session, request, claimed_task() if active else None, None, ())
class FakeStore:
def __init__(self, current: RecoverySnapshot) -> None:
self.current = current
self.recovery_calls = 0
self.start_calls = 0
self.stop_calls = 0
def recovery_snapshot(self, profile_id: str) -> RecoverySnapshot:
self.recovery_calls += 1
return self.current
def start_or_resume_polling(self, profile_id: str) -> PollingSession:
self.start_calls += 1
session = self.current.session or PollingSession(PROFILE, SESSION_ID, True)
session = PollingSession(PROFILE, session.session_id, True)
self.current = RecoverySnapshot(
session,
self.current.pending_claim,
self.current.active_claim,
self.current.pending_renew,
self.current.pending_evidence,
)
return session
def request_stop(self, profile_id: str) -> PollingSession:
self.stop_calls += 1
if self.current.session is None:
raise StateError("polling_session_not_found")
session = PollingSession(PROFILE, self.current.session.session_id, False)
self.current = RecoverySnapshot(
session,
self.current.pending_claim,
self.current.active_claim,
self.current.pending_renew,
self.current.pending_evidence,
)
return session
class FakeGateway:
def __init__(self, outcomes: list[object] | None = None, gate: threading.Event | None = None) -> None:
self.outcomes = list(outcomes or [None])
self.gate = gate
self.entered = threading.Event()
self.calls = 0
def claim_next(self, profile_id: str):
self.calls += 1
self.entered.set()
if self.gate is not None:
if not self.gate.wait(5):
raise RuntimeError("test_gateway_release_timeout")
outcome = self.outcomes.pop(0) if self.outcomes else None
if isinstance(outcome, Exception):
raise outcome
return outcome
class FakeConsumer:
def __init__(self) -> None:
self.claims: list[ClaimedTask] = []
self.profiles: list[ProfileSettings] = []
def accept_claim(self, claimed: ClaimedTask, profile: ProfileSettings) -> None:
self.claims.append(claimed)
self.profiles.append(profile)
def wait_until(predicate, timeout_ms: int = 2000) -> None:
elapsed = 0
while not predicate() and elapsed < timeout_ms:
# worker 仍通过真实 Qt 队列信号推进,因此测试必须持续处理事件;
# 1 ms 量子既会让出 QThread,又不会把每次状态转换放大成 10 ms 等待。
QTest.qWait(1)
elapsed += 1
if not predicate():
raise AssertionError("condition_not_reached")
class PollingCoordinatorTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def make_coordinator(
self,
store: FakeStore,
gateway: FakeGateway | None,
consumer: FakeConsumer | None,
*,
readiness: StartReadiness | None = StartReadiness(True, "ready"),
settings: ProfileSettings | None = None,
threshold: int = 3,
interval_ms: int = 5,
) -> tuple[PollingCoordinator, list[ProfileSettings]]:
frozen: list[ProfileSettings] = []
def factory(value: ProfileSettings):
frozen.append(value)
assert gateway is not None
return gateway
coordinator = PollingCoordinator(
profile_id=PROFILE,
store=store,
gateway_factory=None if gateway is None else factory,
consumer=consumer,
profile_settings=settings or profile(failure_threshold=threshold),
readiness=readiness,
failure_threshold=threshold,
timer_interval_ms=interval_ms,
)
self.addCleanup(lambda: self.assertTrue(coordinator.shutdown()))
wait_until(lambda: not coordinator.operation_in_flight)
return coordinator, frozen
def test_restart_normalizes_waiting_pending_and_active_without_http(self) -> None:
for name, current, expected in (
("waiting", snapshot(accept_new=True), PollingState.STOPPED),
("pending", snapshot(accept_new=True, pending=True), PollingState.STOPPED),
("active", snapshot(accept_new=True, active=True), PollingState.RECOVERY_REQUIRED),
):
with self.subTest(name=name):
store = FakeStore(current)
gateway = FakeGateway()
consumer = FakeConsumer()
coordinator, frozen = self.make_coordinator(store, gateway, consumer)
self.assertEqual(coordinator.state, expected)
self.assertFalse(store.current.session.accept_new)
self.assertEqual(store.stop_calls, 1)
self.assertEqual(gateway.calls, 0)
self.assertEqual(frozen, [])
def test_missing_consumer_direct_start_is_zero_gateway_and_zero_session_start(self) -> None:
store = FakeStore(snapshot())
gateway = FakeGateway()
coordinator, frozen = self.make_coordinator(store, gateway, None)
coordinator.start()
self.assertEqual(coordinator.state, PollingState.BLOCKED)
self.assertIn("尚未接入", coordinator.reason)
self.assertEqual(store.start_calls, 0)
self.assertEqual(gateway.calls, 0)
self.assertEqual(frozen, [])
def test_readiness_is_rechecked_inside_start_handler(self) -> None:
store = FakeStore(snapshot())
gateway = FakeGateway()
coordinator, frozen = self.make_coordinator(
store,
gateway,
FakeConsumer(),
readiness=StartReadiness(False, "设备尚未就绪"),
)
coordinator.start()
self.assertEqual(coordinator.state, PollingState.BLOCKED)
self.assertEqual(store.start_calls, 0)
self.assertEqual(gateway.calls, 0)
self.assertEqual(frozen, [])
def test_empty_waits_then_stop_persists_accept_new_false(self) -> None:
store = FakeStore(snapshot())
gateway = FakeGateway([None])
coordinator, frozen = self.make_coordinator(store, gateway, FakeConsumer(), interval_ms=1000)
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.WAITING and gateway.calls == 1)
self.assertEqual(frozen[0].http_timeout_seconds, 10)
coordinator.stop()
wait_until(lambda: coordinator.state == PollingState.STOPPED and not coordinator.operation_in_flight)
self.assertFalse(store.current.session.accept_new)
self.assertEqual(gateway.calls, 1)
def test_explicit_start_that_observes_stale_accept_new_stops_without_http(self) -> None:
store = FakeStore(snapshot())
gateway = FakeGateway([None])
coordinator, frozen = self.make_coordinator(store, gateway, FakeConsumer())
store.current = snapshot(accept_new=True)
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.STOPPED and not coordinator.operation_in_flight)
self.assertFalse(store.current.session.accept_new)
self.assertEqual(gateway.calls, 0)
self.assertEqual(len(frozen), 1)
self.assertIn("再次显式开始", coordinator.reason)
def test_stop_during_claim_commits_then_requires_recovery_without_consumer_delivery(self) -> None:
gate = threading.Event()
store = FakeStore(snapshot())
gateway = FakeGateway([claimed_task()], gate)
consumer = FakeConsumer()
coordinator, _ = self.make_coordinator(store, gateway, consumer)
visible: list[object] = []
coordinator.claim_visible.connect(visible.append)
coordinator.start()
# CLAIMING 状态先于 queued worker 实际进入 claim_next;必须等待
# worker 入口,避免与 FakeGateway 的超时边界竞争而产生套件级偶发失败。
wait_until(lambda: coordinator.state == PollingState.CLAIMING and gateway.entered.is_set())
coordinator._begin_claim(coordinator._epoch)
self.assertEqual(gateway.calls, 1)
coordinator.stop()
old_epoch = coordinator._epoch - 1
coordinator._begin_claim(old_epoch)
gate.set()
wait_until(lambda: coordinator.state == PollingState.RECOVERY_REQUIRED and not coordinator.operation_in_flight)
self.assertEqual(consumer.claims, [])
self.assertEqual(gateway.calls, 1)
self.assertFalse(store.current.session.accept_new)
self.assertEqual(len(visible), 1)
self.assertIsInstance(visible[0], ClaimedTaskView)
self.assertNotIn(SENTINEL_TOKEN, repr(visible[0]))
def test_only_reason_whitelist_gets_automatic_same_gateway_retry(self) -> None:
store = FakeStore(snapshot(accept_new=False, pending=True))
gateway = FakeGateway(
[AmbiguousRemoteError("http_result_unknown"), AmbiguousRemoteError("truncated_response")]
)
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer(), threshold=2)
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.BLOCKED and not coordinator.operation_in_flight)
self.assertEqual(gateway.calls, 2)
self.assertEqual(store.current.pending_claim.claim_request_id, REQUEST_ID)
self.assertFalse(store.current.session.accept_new)
def test_schema_ambiguity_blocks_after_one_call_and_preserves_pending(self) -> None:
for reason in (
"unknown_success_status",
"invalid_claim_success_response",
"invalid_claim_lease",
"ambiguous_response_framing",
"invalid_content_length",
"response_too_large",
):
with self.subTest(reason=reason):
store = FakeStore(snapshot(accept_new=False, pending=True))
gateway = FakeGateway([AmbiguousRemoteError(reason)])
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer())
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.BLOCKED and not coordinator.operation_in_flight)
self.assertEqual(gateway.calls, 1)
self.assertEqual(store.current.pending_claim.claim_request_id, REQUEST_ID)
def test_credential_manual_and_local_failures_do_not_enter_timer_retry(self) -> None:
for error, expected_frozen in (
(CredentialRemoteError("invalid_device_credentials"), True),
(ManualRemoteError("claim_requires_manual"), False),
(ProtocolRemoteError("invalid_protocol"), False),
(StateError("localstate_integrity_failed"), True),
):
with self.subTest(error=error.reason):
store = FakeStore(snapshot(accept_new=False, pending=True))
gateway = FakeGateway([error])
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer())
freeze_events: list[bool] = []
coordinator.configuration_freeze_changed.connect(freeze_events.append)
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.BLOCKED and not coordinator.operation_in_flight)
self.assertEqual(gateway.calls, 1)
self.assertFalse(coordinator._timer.isActive())
self.assertIsNone(coordinator._scheduled_epoch)
self.assertFalse(store.current.session.accept_new)
self.assertEqual(freeze_events[-1], expected_frozen)
def test_stop_latched_bootstrap_active_emits_settled_for_pending_close(self) -> None:
store = FakeStore(snapshot())
gateway = FakeGateway()
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer())
store.current = snapshot(accept_new=False, active=True)
settled: list[bool] = []
coordinator.settled.connect(lambda: settled.append(True))
coordinator.start()
coordinator.stop()
wait_until(lambda: coordinator.state == PollingState.RECOVERY_REQUIRED)
self.assertTrue(settled)
self.assertEqual(gateway.calls, 0)
def test_each_explicit_start_freezes_profile_for_gateway_factory(self) -> None:
store = FakeStore(snapshot())
gateway = FakeGateway([None])
coordinator, frozen = self.make_coordinator(store, gateway, FakeConsumer(), interval_ms=1000)
changed = profile(http_timeout=27)
coordinator.update_profile_settings(changed)
coordinator.start()
wait_until(lambda: gateway.calls == 1)
self.assertEqual(frozen, [changed])
coordinator.stop()
wait_until(lambda: not coordinator.operation_in_flight)
def test_consumer_receives_start_snapshot_even_if_profile_changes_while_claiming(self) -> None:
gate = threading.Event()
store = FakeStore(snapshot())
gateway = FakeGateway([claimed_task()], gate)
consumer = FakeConsumer()
original = profile(http_timeout=10)
coordinator, frozen = self.make_coordinator(
store,
gateway,
consumer,
settings=original,
)
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.CLAIMING and gateway.calls == 1)
changed = profile(http_timeout=27)
coordinator.update_profile_settings(changed)
gate.set()
wait_until(lambda: coordinator.state == PollingState.ACTIVE)
self.assertEqual(frozen, [original])
self.assertEqual(consumer.profiles, [original])
self.assertIsNot(consumer.profiles[0], changed)
def test_claim_view_redacts_sentinel_even_if_title_contains_it(self) -> None:
wire = claim_wire()
wire["task"]["title"] = "标题 " + SENTINEL_TOKEN
wire["attempt"]["claim_token"] = SENTINEL_TOKEN
claimed = ClaimedTask.from_wire(wire)
view = ClaimedTaskView.from_claim(claimed)
self.assertNotIn(SENTINEL_TOKEN, repr(view))
self.assertIn("已隐藏", view.title)
+1
View File
@@ -0,0 +1 @@
"""remote tests。"""
+167
View File
@@ -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)
+184
View File
@@ -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)
+162
View File
@@ -0,0 +1,162 @@
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_claim_rejects_service_field_bound_drift_as_ambiguous(self) -> None:
mutations = (
("title", "😀" * 121),
("sku_color", "色" * 81),
("sku_size", "码" * 81),
("max_total_price", "1" * 30 + ".00"),
)
for field, invalid in mutations:
value = claim_wire()
value["task"][field] = invalid
transport = FakeTransport(response(200, value))
with self.subTest(field=field), self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(transport).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
self.assertEqual(len(transport.calls), 1)
goods_id = "1" * 33
value = claim_wire()
value["task"].update(
goods_id=goods_id,
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + goods_id,
)
with self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(FakeTransport(response(200, value))).claim_next(
self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)
)
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)
+33
View File
@@ -22,3 +22,36 @@ class ApplicationArgumentsTests(unittest.TestCase):
def test_none_uses_process_arguments(self) -> None: def test_none_uses_process_arguments(self) -> None:
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]): with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
self.assertEqual(["process-name", "--process-option"], select_application_argv(None)) self.assertEqual(["process-name", "--process-option"], select_application_argv(None))
def test_standalone_entry_does_not_construct_http_or_device_capabilities(self) -> None:
source = (CLIENT_ROOT / "src" / "cmbuyer_client" / "app.py").read_text(encoding="utf-8")
for forbidden in (
"HttpTaskSource",
"HttpTransport",
"DurableClientGateway",
"import uiautomator2",
".device",
".pdd",
):
with self.subTest(forbidden=forbidden):
self.assertNotIn(forbidden, source)
self.assertIn("gateway_factory=None", source)
self.assertIn("consumer=None", source)
def test_t304_ui_has_no_hidden_connection_probe_or_irreversible_capability_import(self) -> None:
paths = [
*(CLIENT_ROOT / "src" / "cmbuyer_client" / "ui").glob("*.py"),
*(CLIENT_ROOT / "src" / "cmbuyer_client" / "polling").glob("*.py"),
]
source = "\n".join(path.read_text(encoding="utf-8") for path in paths)
for forbidden in (
"uiautomator2",
"cmbuyer_client.pdd",
"cmbuyer_client.device",
"submit_order",
"payment",
"http.client",
"subprocess",
):
with self.subTest(forbidden=forbidden):
self.assertNotIn(forbidden, source)
+21
View File
@@ -47,3 +47,24 @@ class LoggingPolicyTests(unittest.TestCase):
self.assertNotIn("not-for-log", content) self.assertNotIn("not-for-log", content)
self.assertNotIn("13900139000", content) self.assertNotIn("13900139000", content)
self.assertIn("[已隐藏]", 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)
+58 -1
View File
@@ -2,16 +2,18 @@
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
from pathlib import Path from pathlib import Path
import tempfile import tempfile
import unittest import unittest
from unittest import mock
CLIENT_ROOT = Path(__file__).resolve().parents[1] CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src")) 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): class RuntimePathsTests(unittest.TestCase):
@@ -23,3 +25,58 @@ class RuntimePathsTests(unittest.TestCase):
self.assertTrue(paths.logs.is_dir()) self.assertTrue(paths.logs.is_dir())
self.assertTrue(paths.artifacts.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()
+1
View File
@@ -0,0 +1 @@
"""原生 Qt Widgets UI 测试。"""
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
import os
import tempfile
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from cmbuyer_client.polling.coordinator import PollingState, RecoveryStatus
from cmbuyer_client.ui.execution import ExecutionPage
from cmbuyer_client.ui.main_window import PurchaseToolWindow
from cmbuyer_client.ui.records import PurchaseRecord
class FakeCoordinator(QObject):
state_changed = Signal(object, str, int)
claim_visible = Signal(object)
settled = Signal()
recovery_status_changed = Signal(object)
configuration_freeze_changed = Signal(bool)
def __init__(self) -> None:
super().__init__()
self.state = PollingState.BLOCKED
self.reason = "单趟执行能力尚未接入,不能领取真实任务。"
self.consecutive_failures = 0
self.can_start = False
self.operation_in_flight = False
self.recovery_status = RecoveryStatus(False, False, False, False, False)
self.starts = 0
self.stops = 0
def start(self) -> None:
self.starts += 1
def stop(self) -> None:
self.stops += 1
def update_profile_settings(self, settings) -> None:
pass
class FakeStore:
def __init__(self) -> None:
self.calls = 0
def save_profile(self, settings, token) -> None:
self.calls += 1
def records() -> list[PurchaseRecord]:
return [
PurchaseRecord("old", "旧记录", "失败", "2026-08-04T09:00:00Z", "旧文字", "旧结果"),
PurchaseRecord("new", "新记录", "待付款", "2026-08-04T10:00:00Z", "新文字", "新结果"),
]
class ExecutionPageTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def setUp(self) -> None:
self.coordinator = FakeCoordinator()
self.page = ExecutionPage(self.coordinator)
self.page.resize(1100, 700)
self.page.show()
QTest.qWait(20)
def tearDown(self) -> None:
self.page.close()
def test_standalone_start_is_disabled_with_nearby_reason_and_empty_real_model(self) -> None:
self.assertFalse(self.page.poll_button.isEnabled())
self.assertIn("尚未接入", self.page.banner.text())
self.assertEqual(self.page.record_model.rowCount(), 0)
self.assertEqual(self.page.record_model.columnCount(), 2)
self.assertEqual(self.coordinator.starts, 0)
def test_records_are_newest_first_and_open_via_action_then_escape_restores_focus(self) -> None:
self.page.set_records(records())
self.assertEqual(self.page.record_model.data(self.page.record_model.index(0, 0)), "新记录")
index = self.page.record_model.index(0, 0)
self.page.record_view.setCurrentIndex(index)
self.page.view_record_action.trigger()
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
self.assertEqual(self.page.detail_title.text(), "新记录")
QTest.keyClick(self.page.return_button, Qt.Key.Key_Escape)
QTest.qWait(20)
self.assertEqual(self.page.left_stack.currentIndex(), self.page.LIVE_PAGE)
self.assertEqual(self.page.record_view.currentIndex().row(), 0)
self.assertTrue(self.page.record_view.hasFocus())
def test_detail_selection_updates_in_place_without_modal(self) -> None:
self.page.set_records(records())
self.page.record_view.setCurrentIndex(self.page.record_model.index(0, 0))
self.page.open_selected_record()
second = self.page.record_model.index(1, 0)
self.page.record_view.setCurrentIndex(second)
self.page._on_record_selected(second)
self.assertEqual(self.page.detail_title.text(), "旧记录")
self.assertEqual(self.page.detail_result.toPlainText(), "旧结果")
def test_double_click_enter_and_visible_action_share_inline_detail_command(self) -> None:
self.page.set_records(records())
index = self.page.record_model.index(0, 0)
self.page.record_view.setCurrentIndex(index)
rect = self.page.record_view.visualRect(index)
QTest.mouseDClick(self.page.record_view.viewport(), Qt.MouseButton.LeftButton, pos=rect.center())
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
self.assertIsNone(QApplication.activeModalWidget())
self.page.return_to_live()
self.page.record_view.setFocus()
QTest.keyClick(self.page.record_view, Qt.Key.Key_Return)
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
self.page.return_to_live()
self.page.view_record_action.trigger()
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
def test_responsive_resize_keeps_model_selection_and_inline_detail(self) -> None:
self.page.set_records(records())
model = self.page.record_model
self.page.record_view.setCurrentIndex(model.index(0, 0))
self.page.open_selected_record()
for width in (700, 900, 1200):
self.page.resize(width, 700)
QTest.qWait(10)
self.assertIs(self.page.record_view.model(), model)
self.assertEqual(self.page.record_view.currentIndex().row(), 0)
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
def test_record_model_detail_and_log_redact_bare_token_sentinel(self) -> None:
token = "e" * 64
record = PurchaseRecord("secret", "标题 " + token, "失败", "2026-08-04T10:00:00Z", token, token)
self.page.set_records([record])
self.page.record_view.setCurrentIndex(self.page.record_model.index(0, 0))
self.page.open_selected_record()
self.page.log_view.append_event("Bearer " + token)
visible = "\n".join(
(
str(self.page.record_model.data(self.page.record_model.index(0, 0))),
self.page.detail_original.toPlainText(),
self.page.detail_result.toPlainText(),
self.page.log_view.toPlainText(),
)
)
self.assertNotIn(token, visible)
self.assertIn("已隐藏", visible)
class MainWindowTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def test_fixed_default_tabs_and_close_do_not_start_or_stop_blocked_session(self) -> None:
coordinator = FakeCoordinator()
window = PurchaseToolWindow(
store=FakeStore(),
coordinator=coordinator,
profile_settings=None,
has_stored_device_token=False,
)
window.show()
QTest.qWait(10)
self.assertEqual(window.tabs.count(), 2)
self.assertEqual([window.tabs.tabText(i) for i in range(2)], ["采购执行", "配置"])
self.assertIs(window.tabs.currentWidget(), window.execution_page)
self.assertFalse(window.tabs.tabsClosable())
window.close()
self.assertEqual(coordinator.starts, 0)
self.assertEqual(coordinator.stops, 0)
def test_close_waits_for_inflight_settlement_instead_of_terminating(self) -> None:
coordinator = FakeCoordinator()
coordinator.state = PollingState.CLAIMING
coordinator.operation_in_flight = True
window = PurchaseToolWindow(
store=FakeStore(),
coordinator=coordinator,
profile_settings=None,
has_stored_device_token=False,
)
window.show()
self.assertFalse(window.close())
self.assertTrue(window.isVisible())
self.assertEqual(coordinator.stops, 1)
coordinator.operation_in_flight = False
coordinator.state = PollingState.STOPPED
coordinator.settled.emit()
QTest.qWait(20)
self.assertFalse(window.isVisible())
def test_recovery_status_freezes_and_unfreezes_non_token_settings(self) -> None:
coordinator = FakeCoordinator()
window = PurchaseToolWindow(
store=FakeStore(),
coordinator=coordinator,
profile_settings=None,
has_stored_device_token=False,
)
coordinator.recovery_status_changed.emit(RecoveryStatus(True, False, True, False, False))
self.assertFalse(window.settings_page.device_id.isEnabled())
self.assertTrue(window.settings_page.device_token.isEnabled())
coordinator.recovery_status_changed.emit(RecoveryStatus(False, False, False, False, False))
self.assertTrue(window.settings_page.device_id.isEnabled())
window.close()
class PurchaseRecordTimestampTests(unittest.TestCase):
def test_records_sort_by_real_rfc3339_nanoseconds_not_raw_text(self) -> None:
precise = [
PurchaseRecord("later", "稍后", "完成", "2026-08-04T10:00:00.9Z"),
PurchaseRecord("earlier", "稍早", "完成", "2026-08-04T10:00:00.11Z"),
]
from cmbuyer_client.ui.records import PurchaseRecordModel
model = PurchaseRecordModel(precise)
self.assertEqual(model.record_at(0).record_id, "later")
def test_record_timestamp_rejects_offset_and_noncanonical_trailing_zero(self) -> None:
for timestamp in ("2026-08-04T10:00:00+08:00", "2026-08-04T10:00:00.10Z"):
with self.subTest(timestamp=timestamp), self.assertRaises(ValueError):
PurchaseRecord("id", "标题", "完成", timestamp)
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import os
from pathlib import Path
import tempfile
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QPushButton
from cmbuyer_client.localstate.models import ProfileSettings
from cmbuyer_client.ui.settings import SettingsPage
DEVICE_ID = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
TOKEN = "b" * 64
class FakeStore:
def __init__(self, error: Exception | None = None) -> None:
self.calls: list[tuple[ProfileSettings, object]] = []
self.error = error
def save_profile(self, settings: ProfileSettings, token: object) -> None:
self.calls.append((settings, token))
if self.error is not None:
raise self.error
class SettingsPageTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def setUp(self) -> None:
self.directory = tempfile.TemporaryDirectory()
self.adb = Path(self.directory.name) / "adb.exe"
self.adb.touch()
def tearDown(self) -> None:
self.directory.cleanup()
def fill(self, page: SettingsPage) -> None:
page.device_id.setText(DEVICE_ID)
page.adb_path.setText(str(self.adb))
page.adb_serial.setText("device-serial")
def existing_settings(self) -> ProfileSettings:
return ProfileSettings(
"default",
"http://127.0.0.1:8080",
DEVICE_ID,
str(self.adb),
"device-serial",
"usb",
)
def test_first_save_requires_token_and_performs_zero_store_write(self) -> None:
store = FakeStore()
page = SettingsPage(store)
page.show()
QTest.qWait(10)
self.fill(page)
page.save()
self.assertEqual(store.calls, [])
self.assertTrue(page.device_token.hasFocus())
self.assertIn("首次保存", page.feedback.text())
def test_first_save_rejects_65_character_token_without_silent_truncation(self) -> None:
store = FakeStore()
page = SettingsPage(store)
page.show()
QTest.qWait(10)
self.fill(page)
invalid_token = TOKEN + "b"
page.device_token.setText(invalid_token)
page.save()
self.assertEqual(store.calls, [])
self.assertEqual(page.device_token.text(), invalid_token)
self.assertTrue(page.device_token.hasFocus())
self.assertIn("64 位", page.feedback.text())
def test_visual_order_focuses_invalid_device_before_missing_token_or_adb(self) -> None:
store = FakeStore()
page = SettingsPage(store)
page.show()
QTest.qWait(10)
page.device_id.setText("not-a-uuid")
page.save()
self.assertEqual(store.calls, [])
self.assertTrue(page.device_id.hasFocus())
self.assertIn("UUIDv4", page.feedback.text())
def test_existing_blank_token_passes_none_and_success_never_refills(self) -> None:
store = FakeStore()
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.save()
self.assertEqual(len(store.calls), 1)
self.assertIsNone(store.calls[0][1])
self.assertEqual(page.device_token.text(), "")
self.assertEqual(page.token_status.text(), "已保存")
def test_nonempty_token_replaces_once_then_is_cleared(self) -> None:
store = FakeStore()
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.device_token.setText(TOKEN)
page.save()
self.assertEqual(len(store.calls), 1)
self.assertEqual(store.calls[0][1].value, TOKEN)
self.assertEqual(page.device_token.text(), "")
self.assertNotIn(TOKEN, page.feedback.text())
self.assertNotIn(TOKEN, page.token_status.text())
def test_existing_token_replacement_rejects_65_characters_without_store_write(self) -> None:
store = FakeStore()
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.show()
QTest.qWait(10)
invalid_token = TOKEN + "b"
page.device_token.setText(invalid_token)
page.save()
# 零写入即表示已保存的原 token 未被替换。
self.assertEqual(store.calls, [])
self.assertEqual(page.device_token.text(), invalid_token)
self.assertTrue(page.device_token.hasFocus())
self.assertEqual(page.token_status.text(), "已保存")
def test_failed_save_preserves_token_and_focuses_secret_field_without_echo(self) -> None:
store = FakeStore(RuntimeError("must-not-appear-" + TOKEN))
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.show()
QTest.qWait(10)
page.device_token.setText(TOKEN)
page.save()
self.assertEqual(page.device_token.text(), TOKEN)
self.assertTrue(page.device_token.hasFocus())
self.assertNotIn(TOKEN, page.feedback.text())
def test_pending_or_active_freezes_every_non_token_field_and_preserves_exact_settings(self) -> None:
original = self.existing_settings()
store = FakeStore()
page = SettingsPage(
store,
settings=original,
has_stored_device_token=True,
identity_frozen=True,
)
for field in (
page.device_id,
page.adb_path,
page.adb_serial,
page.transport,
page.poll_interval,
page.failure_threshold,
page.http_timeout,
page.step_timeout,
):
self.assertFalse(field.isEnabled())
self.assertTrue(page.device_token.isEnabled())
page.device_token.setText(TOKEN)
page.save()
self.assertEqual(store.calls[0][0], original)
def test_page_has_no_connection_probe_command_and_explains_deferred_validation(self) -> None:
page = SettingsPage(FakeStore())
button_texts = [button.text() for button in page.findChildren(QPushButton)]
self.assertEqual(button_texts, ["保存配置"])
self.assertIn("首次真实领取", page.validation_hint.text())
self.assertEqual(page.service_url.text(), "http://127.0.0.1:8080")
self.assertTrue(page.service_url.isReadOnly())
+9 -7
View File
@@ -87,13 +87,15 @@ T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径: 执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
1. T-111 先冻结“开始采购授权”的单趟契约;同时主审并合入只创建 `DRAFT` 的 T-202。 1. 完成 **T-103 人工真机验收**,再按依赖推进 T-104 → T-107 的分段安全判据。
2. T-111 完成后恢复 **T-103 真机取证**;按依赖推进 T-104 → T-107 的分段安全判据。 2. admin agent 在 T-107 后推进 T-210 → T-205 → T-206 → T-207 → T-208,冻结三闸门证据、
3. admin agent 先做 T-209 把旧两趟 schema / 状态机迁到单趟,再推进 T-203 服务端批量开始采购 / attempt 事件、围栏、一次性结果与人工调和。
一次性授权;两者不依赖页面选择器。T-205 起的 attempt 与证据字段等待 T-103。 3. client agent 先做当前可领取的 T-303,再推进 T-304 / T-306 / T-307 / T-308;与 admin 写路径
4. Phase 3:双端打通与单趟围栏前 dry-run。 不重叠的任务继续并行。
5. Phase 4:单趟真实提交、待付款、调和与收尾。 4. T-305 完成围栏前真机 dry-run;T-400 只用 fixture/fake port 建立离线一次性提交安全闭包。
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。 5. T-403 → T-402 先准备围栏后调和与人工付款事实记录;T-401 才在新真实单趟中首次点击一次,
点击后固定报 `UNKNOWN` 并由人调和,绝不付款。
6. T-405 生成可核验 Windows 包,T-404 对同一候选 commit 与产物做最终只读验收;V2 能力继续后置。
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、 > **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。 > **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
+6 -4
View File
@@ -104,8 +104,10 @@ US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统
等于任务值。缺失、重复、禁用、前缀碰撞、读回不一致都停止,不选相近项。 等于任务值。缺失、重复、禁用、前缀碰撞、读回不一致都停止,不选相近项。
- **F-006 三道价格闸门**:价格只从规格面板和订单确认页读取,金额使用十进制字符串。 - **F-006 三道价格闸门**:价格只从规格面板和订单确认页读取,金额使用十进制字符串。
1. 选中目标规格后读取当前 SKU 单价,计算 `单价 × 数量`,不得超过授权最高总价。 1. 选中目标规格后读取当前 SKU 单价,计算 `单价 × 数量`,不得超过授权最高总价。
2. 设置并复核数量后再次读取同一规格与单价;规格必须仍相同,单价必须与闸门一完全相等。 2. 设置并复核数量后再次核对同一规格,并唯一读取规格面板的目标数量总额;总额不得超过授权
3. 订单确认页核对规格、数量与应付总额;总额不得超过授权最高总价。 最高总价。多件优惠可能非线性,不用闸门一金额乘数量猜总额,也不要求目标数量总额等于闸门一。
3. 订单确认页核对规格、数量与应付总额;应付总额必须与闸门二面板总额严格相等,且再次不超过
授权最高总价。
任一道读不到、有歧义或不通过都停止并转人工,不从详情页、搜索页或按钮文字凑价格。 任一道读不到、有歧义或不通过都停止并转人工,不从详情页、搜索页或按钮文字凑价格。
- 同一设备会话连续完成上述步骤;不退出商品再等采购服务确认,也不把旧截图或旧页面读数当作 - 同一设备会话连续完成上述步骤;不退出商品再等采购服务确认,也不把旧截图或旧页面读数当作
当前提交依据。 当前提交依据。
@@ -127,13 +129,13 @@ US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统
### 结果、证据与异常 ### 结果、证据与异常
- **F-007**:按步骤回传实际规格、两次规格面板价格、数量读回、确认页总额、判定、时间和截图。 - **F-007**:按步骤回传实际规格、闸门一单价、数量读回、闸门二面板总额、确认页总额、判定、时间和截图。
截图是内部审计证据,不是提交前审批前置。 截图是内部审计证据,不是提交前审批前置。
- 内部系统允许上传规格面板与订单确认页的原始截图,截图中可保留页面已显示的地址和手机号;只允许 - 内部系统允许上传规格面板与订单确认页的原始截图,截图中可保留页面已显示的地址和手机号;只允许
已认证设备显式上传、已登录管理员查看。完整 XML 只留采购工具本机,不上传;地址/手机号不解析为 已认证设备显式上传、已登录管理员查看。完整 XML 只留采购工具本机,不上传;地址/手机号不解析为
业务字段或日志;外部支付页、支付凭据永不上传。 业务字段或日志;外部支付页、支付凭据永不上传。
- **F-011**:至少区分设备未连接、版本失配、商品打不开、面板打不开、规格不匹配、价格不可读、 - **F-011**:至少区分设备未连接、版本失配、商品打不开、面板打不开、规格不匹配、价格不可读、
两次价格不一致、数量复核失败、金额超上限、确认页不一致、提交控件不唯一、围栏失败、页面识别 规格漂移、数量复核失败、闸门二总额不可读或超上限、确认页与闸门二不一致、提交控件不唯一、围栏失败、页面识别
失败、安全校验、外部支付交接和超时。 失败、安全校验、外部支付交接和超时。
- 运行时拼多多 App 版本与已取证版本不一致时停止领取真机任务,提示重新取证。 - 运行时拼多多 App 版本与已取证版本不一致时停止领取真机任务,提示重新取证。
- 订单明确创建后展示截图、商品、规格、数量和授权金额;人在拼多多付款后手工标记完成。 - 订单明确创建后展示截图、商品、规格、数量和授权金额;人在拼多多付款后手工标记完成。
+35 -30
View File
@@ -31,7 +31,10 @@
| 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 | | 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 |
| 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 | | 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 |
| 截图处理 | `Pillow` | 已定 | 判断页面是否渲染完成,避免保存白屏壳层 | | 截图处理 | `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 解析移到采购服务;采购工具不再直接读表 | | Excel | 不引入 | 已定 | Excel 解析移到采购服务;采购工具不再直接读表 |
| 测试 | `unittest`(标准库) | 已定 | 前序项目 171 项测试均用标准库,无需 pytest | | 测试 | `unittest`(标准库) | 已定 | 前序项目 171 项测试均用标准库,无需 pytest |
| 打包 | `pyinstaller` | 已定 | 交付给运营电脑;开发期依赖 | | 打包 | `pyinstaller` | 已定 | 交付给运营电脑;开发期依赖 |
@@ -55,6 +58,10 @@
Go 侧重写表头校验和行级报错,不能直接复用前序项目的 Python 实现。 Go 侧重写表头校验和行级报错,不能直接复用前序项目的 Python 实现。
- **采购工具不持有业务权威。** 金额上限、授权有效性、任务状态流转的判定权在采购服务; - **采购工具不持有业务权威。** 金额上限、授权有效性、任务状态流转的判定权在采购服务;
采购工具本地校验只作为第二道防线,两边不一致时一律转人工。 采购工具本地校验只作为第二道防线,两边不一致时一律转人工。
- **HTTP 不做自动重试。** claim/renew/evidence 的重放权属于持有 durable 幂等槽的恢复门面;底层每次
方法最多一个请求。401 修复 Bearer 后、结果不明或重启恢复都必须复用原 key 与原 body/file。
- **客户端 SQLite 只保存恢复事实。** append-only session/claim/renew/evidence history 不是服务端任务
权威;它的作用是阻止崩溃、并发或状态损坏导致第二次领取、换图或换 key。
- **不引入 pytest / 不引入 ORM。** 同一职责不并存两套方案。 - **不引入 pytest / 不引入 ORM。** 同一职责不并存两套方案。
## 五、构建与运行命令 ## 五、构建与运行命令
@@ -125,48 +132,46 @@ D:\Portable\adb\adb.exe devices -l
证据不得提交 Git。T-102 已由人确认 goods_id `958756616606` 对应目标商品并完成截图/XML 隐私检查, 证据不得提交 Git。T-102 已由人确认 goods_id `958756616606` 对应目标商品并完成截图/XML 隐私检查,
设备、版本、证据路径与 SHA-256 已记录到任务执行记录。 设备、版本、证据路径与 SHA-256 已记录到任务执行记录。
### T-103 规格面板三状态只读取证(T-110 边界调整后待重新验证) ### T-103 规格面板状态取证与受控选择
`client/scripts/capture_sku_panel_spike.py` 只采集人已在手机上准备好的规格面板截图和完整 XML。 `client/scripts/capture_sku_panel_spike.py` 是历史人工声明状态的只读取证工具:只采集人已准备好的截图和
它不打开链接或规格面板,不识别面板,不点击、滑动、输入或选择规格,也不读取价格;接口只暴露 完整 XML,不打开链接或面板,不点击、滑动、输入、选择规格或读取价格。它保留的
`app_info`、`app_current` 和两个只读 JSON-RPC 方法。`panel-opened-target-preselected`、 `panel-opened-target-preselected` 等枚举只描述旧证据目录,不能作为当前生产 classifier 的事实来源。
`alternate-all-dimensions-selected`、`target-selection-restored` 三种值写入 manifest 的字段名是 运行拼多多版本必须精确为 `8.17.0`,前台 package 必须是拼多多;目标目录已存在、截图/XML 无效或
`human_declared_state`,明确表示人工声明,不得将其当作自动识别结果。旧的“未选 / 单维度 / 全选” 超时均不得覆盖已有内容或发布半成品。
状态假设已被真机事实推翻,旧枚举会被采集器和脱敏器明确拒绝。运行拼多多版本必须精确为 `8.17.0`,
前台 package 必须是拼多多,否则 fail closed;目标目录已存在、截图/XML 无效或超时均不得覆盖已有内容
或发布半成品。
T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110 只批准拼多多 `8.17.0`、goods_id T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110 只批准拼多多 `8.17.0`、goods_id
`937122477375` 上经真机确认的精确唯一 `快要抢光` 作为受控规格面板入口;“免拼购买 / 单独购买 / `937122477375` 上经真机确认的底部购买区第一行精确唯一 `快要抢光 + 金额` 作为受控规格面板入口;
直接拼成”等其他文案不能凭人工经验复用,必须分别重新取证。项目所有者已确认面板刚打开时目标颜色 商品内容区同名小字必须零点击,“免拼购买 / 单独购买 / 直接拼成”等其他文案不能凭经验复用。
“黑色CHA(纯棉)”和尺码“M(建议100-115)”均已自动选中;取证不强行取消选择,而是记录刚打开状态、
人工把两个维度都改成非目标值、再恢复目标值三个真实状态。只读取证 CLI 本身仍不执行点击;三种状态
分别使用一个全新原始证据目录:
```powershell 2026-08-05 五组 live 证据纠正了旧的“面板打开即自动预选”假设:真实初态颜色与尺码均未选,选择
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state panel-opened-target-preselected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe 目标颜色后尺码仍未选且不在视口;S 非目标态与恢复 M 目标态的 exact selected 切换已由人确认。
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state alternate-all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe 当前实现必须分别匹配未选初态、仅目标颜色态、S 态和 M 态的完整 profile,不得跨状态拼装价格、摘要、
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe 容器或坐标。显示尺码只能由证据绑定、私有、一次且不重试的窄 reveal 能力完成;在取得 reveal 前后
``` M 均未选的动作证据前,不得实现通用或猜测性滑动。
该动作证据使用独立 `client/scripts/capture_sku_reveal_spike.py`,不改造历史只读采集器,也不接入
生产 Flow。CLI 不接收颜色、尺码、坐标或手势参数;它只在严格命中“目标颜色已选、尺码隐藏且未选”
后执行一次证据绑定的固定手势,随后只读采集 before/after。M 意外选中、尺码仍隐藏、颜色/摘要漂移、
RPC 结果不明且无法调和或提交区风险均不得发布,成功后保留面板等待人工核对。
PDD 规格面板不可避免显示收货区域和手机号。项目所有者确认 cmbuyer 是内部系统,正式流程允许把 PDD 规格面板不可避免显示收货区域和手机号。项目所有者确认 cmbuyer 是内部系统,正式流程允许把
页面原始截图上传采购服务供已登录管理员查看,不做遮罩或裁剪。完整 XML 仍只留在 页面原始截图上传采购服务供已登录管理员查看,不做遮罩或裁剪。完整 XML 仍只留在
`%LOCALAPPDATA%\cmbuyer\artifacts\...\raw`;采购工具运行时在内存中读取当前页面树,但只返回规格、 `%LOCALAPPDATA%\cmbuyer\artifacts\...\raw`;采购工具运行时在内存中读取当前页面树,但只返回规格、
选中态、价格和页面状态摘要,不把地址或手机号解析为业务字段,也不写入日志、Git 或 Vikunja。 选中态、价格和页面状态摘要,不把地址或手机号解析为业务字段,也不写入日志、Git 或 Vikunja。
T-103 已完成 `t103-privacy-v5` 的一次真实派生:人确认 XML 无实际地址/手机号、目标颜色 旧 `t103-privacy-v5` 派生物只保留为历史证据,不能覆盖上述 live 状态。闸门一只在目标颜色与 M
“黑色CHA(纯棉)”与尺码“M(建议100-115)”保留;安全节点显示顶部当前价“快卖完 ¥12.88”与原价 均精确读回后,从已取证滚动态面板的只读当前价角色读取十进制 `12.88`;原价 `29.88`、未选初态价格、
“¥29.88”,底部“提交订单 ¥12.88”明确属于 T-103 隔离能力的硬拒绝区。派生截图的整宽黑色隐私带 详情页数字和底部“提交订单 ¥12.88”都不得成为价格候选。
遮住了顶部价格文字的一半,这只说明旧脱敏实验不适合展示,不否定已确认的规格面板价格事实。
为加快 MVP,T-103 从此不再修改或调用截图遮罩器。现有 v5 派生 XML 只用于提取不含页面个人信息的 为加快 MVP,T-103 不再修改或调用截图遮罩器。最小 fixture 从上述本机 live 证据中只提取获准判据节点,
最小 fixture;T-111 完成后的 `SkuSelectionFlow` 在本机实时页面树上按拼多多 8.17.0 与面板结构读取允许字段,完整 不含地址、手机号或支付凭据;`SkuSelectionFlow` 在本机实时页面树上读取允许字段,完整页面树不落日志、
页面树不落日志、不上传。T-204 直接接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,只允许 不上传。T-204 直接接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,只允许
已认证设备写入、已登录管理员读取;不上传 XML,不允许外部支付页或支付凭据。 已认证设备写入、已登录管理员读取;不上传 XML,不允许外部支付页或支付凭据。
这个范围调整不授权 T-103 调整数量、进入确认页、点击“提交订单”或触碰支付控件。T-111 完成后, 这个范围调整不授权 T-103 调整数量、进入确认页、点击“提交订单”或触碰支付控件。T-103 最终由人
T-103 以 `SkuSelectionFlow` 继续:由人把两个维度改成明确的非目标值,再运行受控入口、精确选择、 运行受控入口、目标颜色、一次 reveal、目标 M、读价和一次 Back 的完整真机验收;生产业务最终为单趟,
读价和安全退出脚本完成真机验收。生产业务最终为单趟,但能力仍按 T-103 / T-105 / T-107 分段取证。 但能力仍按 T-103 / T-105 / T-107 分段取证。
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规 Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p` `client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
+64 -16
View File
@@ -81,9 +81,9 @@ MVP 只做任务自带商品链接的 A 路径。创建任务与开始采购分
│ 3. 按维度精确选择并读回颜色、尺码 │ 3. 按维度精确选择并读回颜色、尺码
│ 4. 【闸门一】读 SKU 单价;单价×数量不得超过最高总价 │ 4. 【闸门一】读 SKU 单价;单价×数量不得超过最高总价
│ 5. 设置数量并精确读回 │ 5. 设置数量并精确读回
│ 6. 【闸门二】重读规格与单价;规格不变且价格等于闸门一 │ 6. 【闸门二】重读规格、数量与面板总额;总额不超最高总价
│ 7. 进入订单确认页 │ 7. 进入订单确认页
│ 8. 【闸门三】规格/数量一致,应付总额不超最高总价 │ 8. 【闸门三】规格/数量一致,应付总额等于闸门二且不超最高总价
│ 9. 上传验证摘要并申请服务端提交围栏 │ 9. 上传验证摘要并申请服务端提交围栏
│ 10. 仅在 click_permitted=true 且提交控件唯一时点击一次 │ 10. 仅在 click_permitted=true 且提交控件唯一时点击一次
│ 11. 回传观察结果;不确定时只调和,不重试 │ 11. 回传观察结果;不确定时只调和,不重试
@@ -98,28 +98,57 @@ RECONCILIATION_REQUIRED ──人工核查同一提交──> WAITING_PAYMENT /
### 受控规格面板入口 ### 受控规格面板入口
T-103 真机证据表明:拼多多 `8.17.0`、goods_id `937122477375` 通过精确文本“快要抢光”打开 T-103 最新真机证据表明:拼多多 `8.17.0`、goods_id `937122477375`、1080×2376 的规格面板
规格面板。T-110 已批准把该**特定证据、版本和页面状态**绑定的点击定义为受控导航。 入口是屏幕底部购买区第一行 `快要抢光 + 金额`,不是商品内容区的同名促销小字。T-110 已批准把
该**特定证据、版本和页面状态**绑定的点击定义为受控导航。
- 只能精确唯一匹配;缺失、重复、版本失配或打开后面板不唯一时零后续点击。 - 只能精确唯一匹配底部非点击文本叶节点、其直接可点击 PDD 父容器及证据绑定结构,并且只点击
- “免拼购买 / 单独购买 / 直接拼成”等其他文案不能用包含、前缀、同义或坐标兜底。 第一行文本叶节点中心;缺失、重复、版本失配、结构漂移或打开后面板不唯一时零后续点击。
- 商品内容区同名促销小字明确禁止作为入口。底部按钮中的金额只参与结构识别,不得解析、返回或
充当价格来源;价格仍只能从规格面板和订单确认页读取。
- `免拼购买` 只允许作为已取证、不可点击的精确兄弟节点验证底部结构,不能作为选择器、目标或兜底;
它变为可点击、位置漂移或出现在其他结构时必须拒绝。“单独购买 / 直接拼成”等其他文案同样不能
用包含、前缀、同义或坐标兜底。
- 受控入口只负责进入已取证面板,不等于支付授权,也不能暴露通用任意点击能力。 - 受控入口只负责进入已取证面板,不等于支付授权,也不能暴露通用任意点击能力。
- T-103 的隔离验证 capability 只包含开商品、开面板、选规格、读价和安全退出;数量、确认页、 - T-103 的隔离验证 capability 只包含开商品、开面板、选规格、读价和安全退出;数量、确认页、
提交和支付仍由后续真机任务分别取证后才能接入生产单趟执行器。 提交和支付仍由后续真机任务分别取证后才能接入生产单趟执行器。
2026-08-05 的五组 8.17.0 真机证据进一步证明,面板刚打开时颜色与尺码均未选中,尺码选项尚未
进入视口;选择目标颜色后,摘要只剩“请选择:尺码”,但尺码仍不可见。实现必须把未选初态、
仅颜色已选态、非目标尺码态和目标尺码态作为各自完整的证据 profile 匹配,禁止把不同状态的价格、
摘要、容器或坐标拼成一个宽松判据。
- 显示尺码只允许命名的私有 `reveal_size_options()` 窄能力:仅从已证明的“目标颜色已选、尺码未选
且隐藏”状态执行,绑定证据确认的嵌套滚动容器与列间安全通道,最多一次且不重试;不得公开通用
`swipe`、`scroll`、任意坐标或把底部提交区纳入动作范围。
- 生产 reveal 落地前,必须补齐“动作前后目标尺码均未选、尺码由隐藏变为可见、提交区未触发”的
同版本真机动作证据。现有“滚动后 M 已选中”快照不能证明滚动没有误点规格,不得据此猜测手势。
- 这组证据只能由独立 spike 在严格前置成立后执行一次固定、无参数手势采集;生产 Flow 不得调用该
spike。RPC 超时或响应不明时只允许只读调和,不得重试;候选后置必须再由人核对后才能成为生产判据。
- 尺码 action 的真实节点类型、精确文案和 selected 读回必须随 profile 一起验证;当前证据中的 S/M
是可点击 `TextView`。只有目标颜色与目标尺码双重读回一致后才能读取闸门一价格。
- 一次 Back 在调用前即封存且不得重试;仅“面板消失”不能作为生产安全退出的充分条件。同商品详情页
的严格退出判据由 T-104 取得独立真机证据后开放。
### 三道价格闸门 ### 三道价格闸门
| 闸门 | 当前页面 | 判据 | 拒绝条件 | | 闸门 | 当前页面 | 判据 | 拒绝条件 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 一 | 规格面板,选中目标规格后 | 单价唯一可读;`单价 × 授权数量 <= 最高总价` | 不可读、有歧义或超上限 | | 一 | 规格面板,选中目标规格后 | 单价唯一可读;`单价 × 授权数量 <= 最高总价` | 不可读、有歧义或超上限 |
| 二 | 规格面板,数量读回后 | 颜色、尺码仍正确;重读单价与闸门一完全相等 | 规格漂移或价格变化 | | 二 | 规格面板,数量读回后 | 颜色、尺码仍正确;目标数量面板总额唯一可读且不超最高总价 | 规格/数量漂移、总额不可读/有歧义或超上限 |
| 三 | 订单确认页 | 规格、数量正确;应付总额唯一可读且不超最高总价 | 任一不一致、不可读或超上限 | | 三 | 订单确认页 | 规格、数量正确;应付总额唯一可读、严格等于闸门二且不超最高总价 | 任一不一致、不可读、有歧义或超上限 |
金额一律使用十进制字符串和十进制定点运算,不用浮点数。价格只从规格面板和订单确认页读取; 金额一律使用十进制字符串和十进制定点运算,不用浮点数。价格只从规格面板和订单确认页读取;
详情正文、搜索卡片、底部购买/提交按钮的数字不作为价格来源。 详情正文、搜索卡片、底部购买/提交按钮的数字不作为价格来源。
闸门一与闸门二发生在同一设备会话中。它们用于发现选择或设置数量造成的页面变化,不需要管理员 2026-08-06 的拼多多 `8.17.0` 真机证据证明同一规格的多件优惠是非线性的:数量 1 时规格面板顶部
在中间确认。旧截图、缓存值和发布前 dry-run 都不能替代这次实时读取。 当前金额为 `12.88`,数量 2 时变为 `32.76`,而不是 `12.88 × 2`。因此闸门一仍是设置数量前的单价
预检,闸门二必须读取设置后的**面板总额** `gate2_panel_total_price` 并直接比较最高总价;不得把
Gate1 乘数量、不得要求 Gate2 等于 Gate1,也不得把底部“提交订单 ¥...”金额作为价格候选或兜底。
Gate3 再要求确认页应付总额与 Gate2 面板总额严格相等,这比只检查上限多一道跨页防漂移约束。
闸门一与闸门二发生在同一设备会话中,不需要管理员在中间确认。旧截图、缓存值和发布前 dry-run
都不能替代这次实时读取。
### B 路径(V2) ### B 路径(V2)
@@ -153,8 +182,8 @@ T-103 真机证据表明:拼多多 `8.17.0`、goods_id `937122477375` 通过
1. **一次性授权有效且服务端提交围栏已建立**:围栏把授权、任务、领取和本次验证摘要原子绑定到 1. **一次性授权有效且服务端提交围栏已建立**:围栏把授权、任务、领取和本次验证摘要原子绑定到
唯一 `order_submission`;明确响应包含 `click_permitted=true`。 唯一 `order_submission`;明确响应包含 `click_permitted=true`。
2. **闸门二通过**:目标规格未漂移,第二次规格面板单价等于第一次。 2. **闸门二通过**:目标规格和数量未漂移,目标数量面板总额唯一可读且不超过授权最高总价。
3. **闸门三通过**:确认页规格、数量正确,应付总额不超过授权最高总价。 3. **闸门三通过**:确认页规格、数量正确,应付总额严格等于闸门二面板总额,且不超过授权最高总价。
4. **提交控件唯一**:文本精确等于“提交订单”,可点击祖先唯一。 4. **提交控件唯一**:文本精确等于“提交订单”,可点击祖先唯一。
围栏请求超时、断网、冲突或响应不明时不得点击。围栏建立后: 围栏请求超时、断网、冲突或响应不明时不得点击。围栏建立后:
@@ -223,7 +252,7 @@ CREATE TABLE purchase_attempts (
claim_generation INTEGER NOT NULL, claim_generation INTEGER NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
gate1_unit_price TEXT, gate1_unit_price TEXT,
gate2_unit_price TEXT, gate2_panel_total_price TEXT,
quantity_read INTEGER, quantity_read INTEGER,
confirm_amount TEXT, confirm_amount TEXT,
failure_code TEXT, failure_code TEXT,
@@ -273,7 +302,7 @@ CREATE TABLE order_submissions (
attempt_id TEXT NOT NULL REFERENCES purchase_attempts(id), attempt_id TEXT NOT NULL REFERENCES purchase_attempts(id),
status TEXT NOT NULL, -- FENCED | SUBMITTED | RECONCILIATION_REQUIRED | MANUAL_RESOLVED status TEXT NOT NULL, -- FENCED | SUBMITTED | RECONCILIATION_REQUIRED | MANUAL_RESOLVED
gate1_unit_price TEXT NOT NULL, gate1_unit_price TEXT NOT NULL,
gate2_unit_price TEXT NOT NULL, gate2_panel_total_price TEXT NOT NULL,
quantity_read INTEGER NOT NULL, quantity_read INTEGER NOT NULL,
confirm_amount TEXT NOT NULL, confirm_amount TEXT NOT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -381,6 +410,24 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
- 围栏建立后即使租约过期也只恢复同一 `order_submission` 的调和,不能回到可领取队列。 - 围栏建立后即使租约过期也只恢复同一 `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 证据分层 ### 5.4 证据分层
| 数据 | 位置 | 边界 | | 数据 | 位置 | 边界 |
@@ -417,7 +464,7 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
| 页面结构随版本变化 | 旧选择器误点新页面 | 每条判据先真机取证,记录 App 版本、截图、XML、goods_id | | 页面结构随版本变化 | 旧选择器误点新页面 | 每条判据先真机取证,记录 App 版本、截图、XML、goods_id |
| 购买语义入口才打开面板 | 能力范围易扩散 | 只批准证据绑定的精确唯一入口;每种文案单独取证 | | 购买语义入口才打开面板 | 能力范围易扩散 | 只批准证据绑定的精确唯一入口;每种文案单独取证 |
| 当前价与原价/按钮价混杂 | 读错价格 | 限定已取证结构和语义;价格只在面板/确认页读,歧义即停 | | 当前价与原价/按钮价混杂 | 读错价格 | 限定已取证结构和语义;价格只在面板/确认页读,歧义即停 |
| 单趟页面状态变化 | 数量或促销导致价格变化 | 同一趟两次读规格面板价,再以确认页总额兜底 | | 单趟页面状态变化 | 数量或促销导致金额非线性变化 | 同一趟读 Gate1 单价与 Gate2 面板总额,再要求确认页总额与 Gate2 严格相等 |
| WiFi ADB / 双通道 | 断连或操作错设备 | serial 必填;USB/WiFi 同设备或身份不明时 fail closed | | WiFi ADB / 双通道 | 断连或操作错设备 | serial 必填;USB/WiFi 同设备或身份不明时 fail closed |
| 不可逆动作超时 | 可能已创建订单 | 服务端围栏 + 点击一次 + 只调和,不重试 | | 不可逆动作超时 | 可能已创建订单 | 服务端围栏 + 点击一次 + 只调和,不重试 |
| 双端契约漂移 | 静默不兼容 | [api.md](api.md) 唯一权威;契约改动跑完整双端门禁 | | 双端契约漂移 | 静默不兼容 | [api.md](api.md) 唯一权威;契约改动跑完整双端门禁 |
@@ -458,7 +505,8 @@ cmbuyer/
└── scripts/ └── scripts/
``` ```
执行器依赖 `TaskSource` / `ResultSink`,不直接读取 Excel 或拼接 HTTP。来源变化不得改变安全执行器。 执行器依赖核心端口,不直接读取 Excel 或拼接 HTTP。T-303 只提供 `TaskSource` / `EvidenceSink`;完整
`ResultSink` 在服务端 events/fail/fence/result 契约落地后分阶段组合,来源变化不得改变安全执行器。
## 九、架构纪律 ## 九、架构纪律
+3 -2
View File
@@ -45,8 +45,9 @@
- 价格**只在规格面板(闸门一 / 二)和订单确认页(闸门三)读**。 - 价格**只在规格面板(闸门一 / 二)和订单确认页(闸门三)读**。
- **不从商品详情页正文、搜索结果卡片或任何其他位置读价格。** - **不从商品详情页正文、搜索结果卡片或任何其他位置读价格。**
- 读不到就转人工,**不用别处的数字凑合**。 - 读不到就转人工,**不用别处的数字凑合**。
- 同一趟设置并复核数量后,闸门二重读的规格必须不变、单价必须与闸门一完全一致;授权锁定的是 - 同一趟设置并复核数量后,闸门二重读的规格必须不变,并唯一读取目标数量的规格面板总额,直接
最高总价而不是旧观察价。价格变化即停。 验证不超授权最高总价;后续闸门三应付总额必须与该面板总额严格相等并再次不超上限。2026-08-06
真机已证明多件优惠非线性,不得用闸门一金额乘数量猜 Gate2 总额,也不得要求 Gate2 等于 Gate1。
- 图片搜索(V2)的唯一产出是 `goods_id`,同样不读价。 - 图片搜索(V2)的唯一产出是 `goods_id`,同样不读价。
### 1.4 安全与隐私 ### 1.4 安全与隐私
+14 -12
View File
@@ -20,9 +20,9 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
| 1 | T-001 → T-004 → T-201 → T-202 | T-002 → T-101 → T-102 | T-003、原型与契约 | | 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 单趟重构;逐任务主审 | | 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 | 双端契约完整门禁 | | 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 主审 | | 4 | T-403 → T-402 | T-305;T-208 / T-303 / T-307 后做 T-308;再汇合到 T-400 | 围栏前 dry-run、客户端围栏恢复与离线单次提交主审 |
| 5 | T-402 / T-403 | T-401 | 真机人工授权、提交与调和验收 | | 5 | - | T-401 | 新真实单趟、首次一次性提交与人工调和验收 |
| 6 | - | T-405 | T-404 完整验收 | | 6 | - | T-405 | T-404 完整只读验收 |
并行门禁: 并行门禁:
@@ -60,7 +60,7 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
| T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 | | T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 |
| T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 | | T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 |
| T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不进入确认页、不提交 | | T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不进入确认页、不提交 |
| T-105 | 验证数量设置、读回与闸门二 | T-104 | 先只读取证并由人确认,再设置数量、精确读回并复核规格/单价;不一致即停 | | T-105 | 验证数量设置、读回与闸门二 | T-104 | 先只读取证并由人确认,再设置数量、精确读回并复核规格/目标数量面板总额;规格或数量不一致、总额不可读/超上限即停 |
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 只读采集导航前、确认页、最终控件及一次人工 Back 状态;agent 不执行页面动作 | | T-106 | dry-run:确认页与闸门三取证 | T-105 | 只读采集导航前、确认页、最终控件及一次人工 Back 状态;agent 不执行页面动作 |
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只点已取证导航控件一次;最终控件只读观察;一次 Back;静态不可达提交 | | T-107 | 固化确认页与提交控件安全判据 | T-106 | 只点已取证导航控件一次;最终控件只读观察;一次 Back;静态不可达提交 |
| T-110 | 批准证据绑定的受控规格入口 | T-103 no-go 决策 | 仅批准拼多多 8.17.0 已取证的精确唯一“快要抢光”,不泛化 | | T-110 | 批准证据绑定的受控规格入口 | T-103 no-go 决策 | 仅批准拼多多 8.17.0 已取证的精确唯一“快要抢光”,不泛化 |
@@ -90,21 +90,23 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
| --- | --- | --- | --- | | --- | --- | --- | --- |
| T-301 | 设备凭据与身份隔离(F-013) | T-201 | Bearer 不能建单/授权;管理会话不能领任务;凭据可撤销 | | T-301 | 设备凭据与身份隔离(F-013) | T-201 | Bearer 不能建单/授权;管理会话不能领任务;凭据可撤销 |
| T-302 | 已授权任务原子领取与租约(F-005) | T-301, T-203 | 只领 PENDING+有效授权;并发唯一;重放同一 attempt;claim token/generation 有效 | | 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-304 | 定时轮询与会话边界 | T-303, T-006 | 人启动后轮询;停止只阻止下次领取;连续失败停;当前任务/记录详情双视图 |
| T-306 | 规格面板原始截图与可靠证据上传 | T-104, T-303 | 只发布 Gate1 的显式原始 PNG;唯一恢复槽;可含页面地址/手机号;不上传 XML/路径或扩 kind | | 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-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 停在围栏前;不申请许可、不点击提交 | | T-305 | **单趟围栏前端到端 dry-run** | T-107, T-304, T-306, T-307, T-210 | 连续跑到三闸门及最终控件只读观察,随后一次安全 Back 并以固定 reason 停在围栏前;不申请许可、不点击提交 |
### Phase 4 · 真实提交与收尾 ### Phase 4 · 真实提交与收尾
| ID | 任务 | 依赖 | 验收要点 | | ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| T-401 | **单趟采购与提交围栏端到端** | T-305, T-208 | 同一设备会话通过三闸门、首次明确围栏许可后点击一次;结果不明只调和;明确创建转待付款;绝不付款 | | T-400 | 离线单次提交安全编排 | T-305, T-308 | 只用 fixture/fake port 证明四条件、点击意图先落盘、调用恰好一次和崩溃后只调和;生产接线仍不可达 |
| T-402 | 待付款收口与手工完成 | T-401 | 详情展示订单证据;人付款后手工标记;待付款不等于成功 | | T-403 | 失败分类、证据归档与管理员调和视图 | T-208 | 只展示已有事实并复用同一 submission 调和事务;围栏后无重试、释放或重新授权 |
| T-403 | 失败分类与证据归档 | T-401 | 覆盖需求 reason;终态原子回写;围栏后无重试 | | T-402 | 待付款人工收口与付款事实记录 | T-403 | 只记录人已在外部付款的事实;三组确认、完整 lineage 与追加式审计;系统不付款 |
| T-404 | 完整 MVP 验收 | T-402, T-403 | P0 验收、双端门禁、真机记录和 current-state 全部通过 | | T-401 | **首次真实提交与生产接线** | T-400, T-402 | 新真实单趟重新通过三闸门;首次明确许可后点击一次;首次运行固定 UNKNOWN 并人工调和;绝不付款 |
| T-405 | 采购工具打包与运行文档 | T-404 | 运营电脑可安装运行,记录产物 SHA-256 | | 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 及以后 ### V2 及以后
@@ -126,8 +128,8 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
- **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103) - **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103)
- **M3**:真机设置数量、闸门二、确认页闸门三和唯一提交控件 dry-run 通过,不点击。(T-107) - **M3**:真机设置数量、闸门二、确认页闸门三和唯一提交控件 dry-run 通过,不点击。(T-107)
- **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302) - **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302)
- **M5**:单趟从领取到围栏前 dry-run 跑通,事件与证据齐全。(T-305) - **M5**:围栏前 dry-run 跑通,客户端围栏恢复和离线一次性提交安全闭包成立。(T-305、T-308、T-400)
- **M6**:单趟真实提交只创建待付款订单,围栏/调和/人工收口闭环。(T-401~T-404) - **M6**:新真实单趟只创建可能的待付款订单,围栏/调和/人工收口、Windows 交付和只读验收闭环。(T-401~T-405)
**M2 仍是生死线。** M2 前可以并行管理会话、DRAFT 建单和开始采购的服务端授权事务,但不能编写 **M2 仍是生死线。** M2 前可以并行管理会话、DRAFT 建单和开始采购的服务端授权事务,但不能编写
依赖未取证页面字段的生产执行逻辑。提速来自两端并行、窄任务和主审复用门禁,不来自删除安全闸门。 依赖未取证页面字段的生产执行逻辑。提速来自两端并行、窄任务和主审复用门禁,不来自删除安全闸门。
+1 -1
View File
@@ -58,7 +58,7 @@
- 启动前同时校验采购服务、ADB 设备和已取证拼多多版本;任一不满足则不启动。 - 启动前同时校验采购服务、ADB 设备和已取证拼多多版本;任一不满足则不启动。
- 顶部始终显示会话状态;当前任务、图片、滚动日志和倒序采购记录实时更新。 - 顶部始终显示会话状态;当前任务、图片、滚动日志和倒序采购记录实时更新。
- 精确规格、两次单价、数量读回、确认页总额或提交控件任一不合格时停止该任务。 - 精确规格、闸门一单价、数量读回、闸门二面板总额、确认页同额校验或提交控件任一不合格时停止该任务。
- 围栏明确成功后只点一次“提交订单”;系统绝不点击付款。 - 围栏明确成功后只点一次“提交订单”;系统绝不点击付款。
- 连续轮询失败达到阈值自动停止;停止轮询不取消正在执行或已围栏的任务。 - 连续轮询失败达到阈值自动停止;停止轮询不取消正在执行或已围栏的任务。
+6 -3
View File
@@ -19,7 +19,7 @@
| IX-004 | US-002、US-004 | web 列表行 / `/tasks/{id}` | 双击行、浏览器前进后退 | 路由化详情抽屉或完整页,显示执行证据 | P0 | 已定 | | IX-004 | US-002、US-004 | web 列表行 / `/tasks/{id}` | 双击行、浏览器前进后退 | 路由化详情抽屉或完整页,显示执行证据 | P0 | 已定 |
| IX-005 | US-005 | web 批量操作条 | 点击开始采购 | 创建一次性授权并进入 `PENDING` | P0 | 已定 | | IX-005 | US-005 | web 批量操作条 | 点击开始采购 | 创建一次性授权并进入 `PENDING` | P0 | 已定 |
| IX-006 | US-006 | web 待付款详情 | 查看后手工标记 | 只记录人工付款完成,不执行支付 | 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-008 | US-003、US-008 | desk 采购执行 tab | 开始 / 停止轮询、选择记录、Esc | 单趟执行和安全人工接管 | P0 | 已定 |
| IX-009 | US-008 | web / desk 通用错误反馈 | 请求或流程失败 | 显示阶段、原因和安全下一步 | P0 | 已定 | | IX-009 | US-008 | web / desk 通用错误反馈 | 请求或流程失败 | 显示阶段、原因和安全下一步 | P0 | 已定 |
| IX-010 | US-005 | web 围栏前人工处理 | 重置 / 取消 | 关闭旧授权后回 `DRAFT` 或取消 | P0 | 已定 | | IX-010 | US-005 | web 围栏前人工处理 | 重置 / 取消 | 关闭旧授权后回 `DRAFT` 或取消 | P0 | 已定 |
@@ -57,7 +57,9 @@
### IX-006 待付款核对与标记完成 ### IX-006 待付款核对与标记完成
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和订单截图。 - 页面显示商品、目标与实际规格、数量、授权上限、闸门一单价、闸门二面板总额、确认页同额校验和
三闸门内部证据;MVP
没有提交后结果截图类型,不把确认页图片冒充订单结果图。
- 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。” - 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。”
- “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。 - “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。
- 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。 - 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。
@@ -105,7 +107,8 @@
1. 当前任务在同一趟内完成闸门二和闸门三,并确认提交控件精确唯一。 1. 当前任务在同一趟内完成闸门二和闸门三,并确认提交控件精确唯一。
2. 客户端显示“正在申请提交围栏”,提交当前验证摘要。 2. 客户端显示“正在申请提交围栏”,提交当前验证摘要。
3. 只有服务端首次明确返回 `click_permitted=true`,客户端才先持久化唯一点击意图,再精确点击一次。 3. 只有服务端首次明确返回 `click_permitted=true`,客户端才先持久化唯一点击意图,再精确点击一次。
4. 明确创建转待付款;外部支付、安全校验、超时、断连或未知页面转待调和。 4. MVP 首次真实点击后因没有已取证的提交后页面判据,无条件上报 `UNKNOWN` 并转待调和;只有管理员
对同一 submission 明确调和为已创建,才转待付款。
5. 围栏后的 web 详情只显示同一 `submission_id` 的证据、调和记录与人工结论。 5. 围栏后的 web 详情只显示同一 `submission_id` 的证据、调和记录与人工结论。
| 状态 | 允许动作 | 禁止动作 | | 状态 | 允许动作 | 禁止动作 |
+69 -20
View File
@@ -96,6 +96,11 @@
``` ```
- 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。 - 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。
- `title` 以 Go `strings.TrimSpace`(Unicode White_Space)后的持久化值计,最多 120 个 Unicode code
point;`sku_color`、`sku_size` 同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8
必须先拒绝,不能把替换字符当作合法 code point;为消除 Go 与 Python 默认 trim 差异,U+001C--U+001F
四个 C0 分隔符无论位置一律拒绝;超限不得截断。
- `goods_id` 只允许 1--32 位 ASCII 数字;规范金额只允许 1--32 个 ASCII 字符。
- `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。 - `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。 - 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
@@ -219,7 +224,14 @@
- 同一 `claim_request_id` 同设备、同 session 稳定重放原结果;同键异载荷返回 - 同一 `claim_request_id` 同设备、同 session 稳定重放原结果;同键异载荷返回
`409 {"error":"idempotency_conflict"}`。没有候选返回空 `204`,且 EMPTY 也持久化稳定重放。 `409 {"error":"idempotency_conflict"}`。没有候选返回空 `204`,且 EMPTY 也持久化稳定重放。
- claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、 - claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、
数量、金额、版本或到期时间漂移。源快照不一致时,新恢复/续租失败闭合。 数量、金额、版本或到期时间漂移。每次首次构造和旧 request 重放都重新校验持久化响应快照的字段
上限;源快照不一致时,新恢复/续租失败闭合。
- 响应内 `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个;`goods_id`
为 1--32 位 ASCII 数字,`max_total_price` 为最多 32 个 ASCII 字符的规范金额。创建、授权快照、
candidate、持久化 claim snapshot 和 HTTP 输出共用同一合法域;既有畸形行只失败闭合,不迁移、
截断或改写。candidate 查询必须先读取 task 与 authorization 两侧字段并分别验证;任一侧畸形必须
回滚且不得持久化 `EMPTY`,只有两侧均合法但快照不一致时才跳过。最坏合法字段组合编码后必须明确
小于既有 32 KiB claim 响应上限。
- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端 - 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端
首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约 首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约
过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。 过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。
@@ -314,17 +326,21 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
"selected_color": "黑色CHA(纯棉)", "selected_color": "黑色CHA(纯棉)",
"selected_size": "M(建议100-115)", "selected_size": "M(建议100-115)",
"gate1_unit_price": "12.88", "gate1_unit_price": "12.88",
"gate2_unit_price": "12.88", "gate2_panel_total_price": "32.76",
"quantity_read": 2, "quantity_read": 2,
"confirm_page_amount": "25.76", "confirm_page_amount": "32.76",
"submit_control_match_count": 1 "submit_control_match_count": 1
} }
``` ```
服务端在一个事务中校验:任务/版本/claim/attempt 一致;授权有效未消费且字段等于任务快照; 服务端在一个事务中校验:任务/版本/claim/attempt 一致;授权有效未消费且字段等于任务快照;
规格与授权相等;数量相等;两个单价相等;计算金额及确认页金额均不超过 `total_price_cap`;提交控件 规格与授权相等;数量相等;闸门一单价预检通过;闸门二目标数量面板总额不超过 `total_price_cap`;
计数为一;此前不存在该授权或 attempt 的 submission。随后创建唯一 `order_submission`,授权转 确认页金额严格等于闸门二面板总额且再次不超过上限;提交控件计数为一;此前不存在该授权或 attempt
`FENCED`,任务保持不可重领。 的 submission。随后创建唯一 `order_submission`,授权转 `FENCED`,任务保持不可重领。
`gate2_panel_total_price` 是目标数量下规格面板顶部证据角色的总额,不是单价。拼多多多件优惠可能
非线性,禁止用 `gate1_unit_price × quantity_read` 推导它,也禁止继续用旧字段名
`gate2_unit_price` 承载总额。底部“提交订单 ¥...”文字不属于价格来源。
首次明确成功响应: 首次明确成功响应:
@@ -365,30 +381,62 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
### 文本和金额校验 ### 文本和金额校验
- 规格字段:Unicode 规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。 - 标题/规格字段:输入按 Go `strings.TrimSpace` 的 Unicode White_Space 集形成实际持久化值;title 最多
- `goods_id`:仅 ASCII 十进制数字,canonical URL 中唯一。 120 个 Unicode code point,颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后再比较和持久化。 U+001C--U+001F 四个 C0 分隔符在任意位置均拒绝,客户端使用同一固定空白集而不依赖 Python
`str.strip()` 默认语义。不按 UTF-8 字节或视觉 grapheme 计数,不截断超限值。规格比较仍为规范化后
精确相等;不得包含、前缀、编辑距离或 AI 猜测。
- `goods_id`:仅 1--32 位 ASCII 十进制数字,canonical URL 中唯一。
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后必须是最多 32 个 ASCII 字符,再比较和持久化。
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。 - 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
## 三、采购工具本地模块合约 ## 三、采购工具本地模块合约
### `TaskSource` / `ResultSink` ### T-303 已实现端口
```python ```python
class TaskSource(Protocol): class TaskSource(Protocol):
def claim_next(self, session: Session) -> ClaimedPurchase | None: ... def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
def renew_lease(self, claim: Claim) -> Lease: ... def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
class ResultSink(Protocol): class EvidenceSink(Protocol):
def append_events(self, claim: Claim, events: list[TaskEvent]) -> None: ... def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
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: ...
``` ```
执行器不能依赖具体 HTTP 或 Excel 实现。`SubmissionPermit` 只能由 `ResultSink` 的服务端成功响应构造, T-303 只实现 claim/renew 与 `SKU_PANEL_GATE_1` 单张 PNG;不得用运行时 `NotImplementedError` 伪造
业务代码不能手工 new 一个许可。 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`,恰好两位小数、无前导零,最多
32 个 ASCII 字符);`goods_id` 只接受 1--32 位 ASCII 数字,title 最多 120 个 Unicode code point,
颜色与尺码各最多 80 个。客户端用与 Go `strings.TrimSpace` 相同的固定 Unicode White_Space 集校验
持久化文本,并与服务端共同拒绝任意位置的 U+001C--U+001F;不得截断或修复漂移响应。wire 整数为正 int64,拒绝 bool。claim 成功
响应总上限仍为 32 KiB,最坏合法字段组合由双端契约测试证明严格小于该值。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` 只能由
服务端首次明确成功响应构造,业务代码不能手工创建。
### 真机能力分层 ### 真机能力分层
@@ -399,6 +447,7 @@ class ResultSink(Protocol):
| `select_sku_options()` | 维度 → 精确值 | 选中态摘要 | 维度内唯一匹配并读回 | | `select_sku_options()` | 维度 → 精确值 | 选中态摘要 | 维度内唯一匹配并读回 |
| `read_sku_unit_price()` | 已确认规格面板 | 十进制单价 | 排除原价、按钮价和歧义候选 | | `read_sku_unit_price()` | 已确认规格面板 | 十进制单价 | 排除原价、按钮价和歧义候选 |
| `set_quantity_and_readback()` | 授权数量 | 实际数量 | 精确读回,否则停 | | `set_quantity_and_readback()` | 授权数量 | 实际数量 | 精确读回,否则停 |
| `read_sku_panel_total_price()` | 已读回目标数量 | 十进制面板总额 | 只读证据绑定的顶部当前金额;排除底部提交按钮金额和歧义候选 |
| `go_to_order_confirm()` | 已通过闸门二 | 确认页摘要 | 后续真机任务取证后才实现 | | `go_to_order_confirm()` | 已通过闸门二 | 确认页摘要 | 后续真机任务取证后才实现 |
| `submit_order_once()` | 不可伪造的首次 `SubmissionPermit` | 观察结果 | 许可、闸门、唯一控件全校验;点前持久化;绝不重试 | | `submit_order_once()` | 不可伪造的首次 `SubmissionPermit` | 观察结果 | 许可、闸门、唯一控件全校验;点前持久化;绝不重试 |
+112 -31
View File
@@ -11,8 +11,8 @@
## 当前快照 ## 当前快照
- 日期:2026-08-04 - 日期:2026-08-06
- 阶段:**Phase 1 · T-103 规格选择/读价与 Phase 2 安全服务端任务并行** - 阶段:**Phase 1 · T-106 确认页只读取证与 Phase 2 安全服务端任务并行**
- MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确 - MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确
选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端 选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端
创建待付款订单;不再有试选后确认,系统绝不自动付款。 创建待付款订单;不再有试选后确认,系统绝不自动付款。
@@ -22,13 +22,18 @@
- 生产代码:`admin/` 已有登录/会话、手工 DRAFT 建单、批量开始采购与一次性授权、任务详情、内部 - 生产代码:`admin/` 已有登录/会话、手工 DRAFT 建单、批量开始采购与一次性授权、任务详情、内部
原始截图、设备身份,以及已授权任务的原子领取/幂等重放/租约续期; 原始截图、设备身份,以及已授权任务的原子领取/幂等重放/租约续期;
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB `client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI, 连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板与数量两态的只读取证 CLI,
绑定 PKG110 / Android 16 / 拼多多 8.17.0 的证据工具,以及受控规格入口、精确规格选择、SKU 当前价 绑定 PKG110 / Android 16 / 拼多多 8.17.0 的证据工具,以及受控规格入口、精确规格选择、SKU 当前价
读取、原始截图和一次安全退出能力;该能力尚待最终人工真机验收,数量/确认页/提交仍未开放。 读取、原始截图和一次安全退出能力;并已具备严格 localhost claim/renew/evidence HTTP、Windows DPAPI
凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。原生桌面主界面、
显式本地配置、停止/重启会话边界和安全轮询协调器已实现;未注入单趟执行 consumer 时保持零领取。
已取证的固定尺码 reveal、精确 M 选择、规格面板单价 `12.88` 与一次 Back 已进入生产 Flow 并完成
真机人工验收;T-104 已把同商品详情页安全退出收紧为版本绑定、连续两帧稳定判据,数量/确认页/提交仍未开放。
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态; - 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
采购工具 112 项离线单元测试(全部 mock,不连接真机)。 采购工具 346 项离线单元测试(全部 mock,不连接真机)。
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发 - 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。仓库不含业务实例数据。 一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、
goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。仓库不含业务实例数据。
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口 - 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+, 优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
并且不覆盖低版本环境;成功后打印真实启动命令。 并且不覆盖低版本环境;成功后打印真实启动命令。
@@ -36,13 +41,19 @@
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。 editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光” - 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为受控导航。 打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为受控导航。
T-103 的隔离 capability 仍不得包含数量、确认页、提交订单、付款或通用点击;这些动作由后续任务 T-103 的隔离 capability 不包含数量、确认页、提交订单、付款或通用点击;这些动作由后续任务
分别取证后才接入生产单趟流程。T-103 的 v5 派生 XML 已由人确认无实际地址/ 分别取证后才接入生产单趟流程。T-103 旧 v5 派生 XML 中的“打开即目标预选”已被 2026-08-05
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88” 五组最新 live 证据纠正:真实初态为颜色与尺码均未选,目标颜色选中后尺码仍未选且不在视口;
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞 S 非目标值与恢复目标 M 的前后态已由人确认。底部“提交订单 ¥12.88”继续属于硬拒绝区。项目已停止
规格选择与读价;T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并 遮罩器开发,T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并
收紧为精确唯一“快要抢光”子节点及证据绑定可点击祖先,“免拼购买”仍为硬拒绝。离线实现和 112 项 最新失败证据已纠正入口绑定:此前实现误点商品内容区同名促销小字;真实入口是底部购买区第一行
测试已通过;T-103 只差人把手机置于 PDD 安全起点后完成精确规格恢复、读取 `12.88` 和一次安全退出。 `快要抢光 + 金额` 的精确文本叶节点中心。第二行“免拼购买”只能作为不可点击的证据绑定兄弟节点
校验结构,绝不作为选择器或目标;按钮金额不作为价格来源。提交 `2f1380d` 已把入口改为只点击
第一行中心 `(865,2218)`,旧内容区小字为零点击反例并保留一次点击无重试。T-103 已完成固定一次
reveal 的原子证据、人审和生产化:同一 Flow 只允许 current-only 初态依次执行目标颜色、reveal、
exact M,随后复用既有 dual 目标态读取 `12.88`;入口、颜色、reveal、M、Back 各一次的生产真机
manifest 与 Back 前后截图已由项目所有者完成三项人工确认。T-104 又用独立 post-exit 真机证据,
把“Back 后仍为同一商品”收紧为完整标题正锚、底部入口、连续两帧与版本绑定的自动判据。
完整事件链必须等待 T-107 与 T-210:先让 Gate2/Gate3 证据进入真实 schema,再实现 T-205 事件, 完整事件链必须等待 T-107 与 T-210:先让 Gate2/Gate3 证据进入真实 schema,再实现 T-205 事件,
不用 mock 或无证据成功事件抢跑。 不用 mock 或无证据成功事件抢跑。
@@ -51,11 +62,11 @@
| 路径 | 状态 | 说明 | | 路径 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 | | `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-211、T-301~T-308、T-400~T-405) | T-103~T-105、T-303、T-211 已完成;T-304 代码完成后保持 DOING 等人工 UI 复核;T-106 已领取并完成只读取证代码,等待四态真机人工验收;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 | | `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 | | `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 | | `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、真机取证及受控规格选择/读价/安全退出;数量、确认页和提交未开放 | | `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/数量 Gate2/安全退出;T-106 确认页四态仅开放纯只读取证,确认页生产导航和提交仍未开放 |
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 | | `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
## 任务状态 ## 任务状态
@@ -73,8 +84,25 @@
`DRAFT`,未实现授权、设备领取或采购执行。 `DRAFT`,未实现授权、设备领取或采购执行。
- 已完成 T-110、T-111、T-203、T-204、T-301 与 T-302。服务端已能授权、认证设备、原子领取、 - 已完成 T-110、T-111、T-203、T-204、T-301 与 T-302。服务端已能授权、认证设备、原子领取、
稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。 稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。
- T-103 离线 `SkuSelectionFlow` 已实现并通过独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107 - T-103 已完成受控入口、精确颜色、固定 reveal、exact M、规格面板 `12.88` 读价、一次 Back 和最终
按数量/确认页风险逐段开放。服务端后续顺序已收紧为 T-210 → T-205 → T-206 → T-207 → T-208。 人工真机验收;T-104 已完成同商品安全退出,T-105 已完成数量/Gate2,T-106~T-107 继续按确认页风险逐段开放。真机运行要求
同一物理手机只保留一个 ADB 通道;CLI 已增加不回显页面
正文的固定白名单失败阶段码;`sku_entry` 动态页面误判已按最新真机证据修复,覆盖点击中心的浮窗
会保持零点击,入口失败也已细分为前置、发现、点击和面板后置验证四个固定子阶段。服务端后续顺序已收紧为
T-210 → T-205 → T-206 → T-207 → T-208。
- T-105 已完成。阶段一数量 1/2 两态 screenshot/XML/App/设备摘要已完成四项人工确认。真机证明同一规格
数量 1 面板金额为 `12.88`、数量 2 为 `32.76`,多件优惠非线性;Gate2 已改为唯一读取目标数量
面板总额并直接比较最高总价,后续 Gate3 必须与其严格相等。阶段二的一次加号、精确读回、Gate2
原始截图和 T-104 一次安全退出已实现并通过 330 项离线测试及完整 `init.ps1`。最终生产运行精确
完成一次加号与一次 Back,项目所有者确认数量 2、目标规格、`12.88/32.76`、同商品安全退出,且未进入
确认/提交/支付页、未创建订单。底部“提交订单”金额继续排除且禁止点击;T-106 依赖已解除。
- T-303 已完成并通过三重审查;T-304 的轮询会话、配置页和固定双 Tab 主界面已通过两轮独立代码审计,
当前 255 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、
Narrator 和纯键盘人工验收。客户端后续按依赖推进 T-306/T-307/T-308,再与 T-305 汇合到
T-400 离线一次性提交安全编排。围栏后管理链为 T-403 → T-402;T-401 必须在新的真实单趟重新
通过三闸门,首次点击后固定进入 `UNKNOWN`/人工调和,T-405 打包后由 T-404 只读验收。
- T-211 已完成 claim wire 字段上限闭合:创建、授权、candidate、持久化快照、重放、HTTP 输出和
客户端解析使用同一合法域;单侧畸形历史数据失败闭合且不写 EMPTY,最坏合法响应严格小于 32 KiB。
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变 - 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
可读字段时必须先回修原型与交互清单。 可读字段时必须先回修原型与交互清单。
@@ -154,16 +182,70 @@ D:\Portable\adb\adb.exe devices -l
不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。 不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。
T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口, T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口,
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。面板刚打开时目标颜色“黑色CHA(纯棉)”与尺码 不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。2026-08-05 五组 live 证据证明面板刚打开时两维
“M(建议100-115)”已经自动选中。v5 派生 XML 与人工观察已证明顶部当前价为“快卖完 ¥12.88”、 均未选,选择目标颜色后尺码仍未选且在视口外;S 与恢复 M 的 exact selected 切换已经人工确认。
并列原价为“¥29.88”;促销前缀会变化,正式读价不能把该文案作为主判据,必须以面板结构、价格角色、 初态与滚动态的价格文案、坐标和容器会整体变化,必须按完整 profile 判断,禁止跨 profile 拼装。
严格金额格式和唯一性组合判断,冲突时返回 unreadable。 正式闸门一只在目标颜色和 M 均精确选中后,从已取证滚动态的只读价格角色读取 `12.88`;原价
`29.88`、详情页数字和底部提交按钮价均不得成为候选,冲突时返回 unreadable。
本轮不再运行或修改截图遮罩器。T-103 先用本机证据验证真机能力;T-204 再把原始规格页截图上传采购 本轮不再运行或修改截图遮罩器。T-103 已从五组本机 live 证据提取不含个人信息的最小 fixture,
服务,允许页面中已显示的地址和手机号,完整 XML 不上传。代码从已确认的派生 XML 提取最小 fixture, 并在真机运行时只返回获准摘要;原始截图展示/上传由 T-204 处理,完整 XML 不上传。面板四态
并在真机运行时只返回获准摘要。T-111 完成后,下一步实现 `SkuSelectionFlow` 的受控入口、 classifier、颜色与 S→M 精确选择、滚动态价格判据和独立一次性 reveal 证据脚本已经落地;生产 Flow
按维度精确选择、选中态读回、SKU 单价读取与安全退出;完成离线主审后,由人先把两个维度改成明确 中的受控 reveal 仍必须等待动作前后 M 均未选的真机证据和人工确认,最终完整链路也必须由人验收。
记录的非目标值,再执行真机脚本确认恢复目标规格与 `12.88` 单价。生产原始截图展示/上传由 T-204 处理。
T-105 阶段一只读取证命令如下。脚本不打开页面,也不点击数量、返回、确认、提交或付款控件;必须由人
先把目标商品规格面板准备为“黑色CHA(纯棉) + M(建议100-115) + 数量 1”,完成初态取证后再由人只点
一次数量 `+` 准备数量 2,随后运行目标态取证:
```powershell
# 仓库根目录;当前只保留已人工确认的 WiFi ADB 通道
.\client\.venv\Scripts\python.exe client\scripts\capture_quantity_gate2_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state initial --declared-quantity 1 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-initial-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
.\client\.venv\Scripts\python.exe client\scripts\capture_quantity_gate2_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state target --declared-quantity 2 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-target-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
```
两次成功目录只保存在本机。人工需核对截图/XML 确实对应数量 1/2、目标颜色和尺码、规格面板顶部金额,
并确认过程中未进入确认/提交/支付页且未创建订单;确认前 T-105 保持 `DOING`。
2026-08-06 两态已经完成上述人工确认。初态目录为
`%LOCALAPPDATA%\cmbuyer\artifacts\T-105\quantity-initial-937122477375-20260806-v3`,目标态目录为
`%LOCALAPPDATA%\cmbuyer\artifacts\T-105\quantity-target-937122477375-20260806`。数量节点精确读回
`1 → 2`,唯一加号的 `content-desc` 为“增加数量”,目标颜色和 M 均未漂移;顶部当前金额
`12.88 → 32.76`。旧 `gate2_unit_price` 语义已废止,T-210/T-205 消费前必须把 API/存储迁移为
`gate2_panel_total_price`,迁移未完成不得建立提交围栏。
T-105 阶段二生产验收已在人工确认的同一手机、拼多多 `8.17.0`、目标颜色/尺码和数量 1 面板完成。
脚本自己只点一次证据绑定的数量加号到 2,读回 `32.76`、保存 Gate2 原图并执行一次安全 Back;不打开
确认页,不点击底部“提交订单”,不创建订单。输出目录必须不存在:
```powershell
.\client\.venv\Scripts\python.exe client\scripts\run_t105_quantity_gate2.py --serial 192.168.0.173:5555 --goods-id 937122477375 --color "黑色CHA(纯棉)" --size "M(建议100-115)" --target-quantity 2 --gate1-unit-price 12.88 --gate1-screenshot "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-initial-937122477375-20260806-v3\screenshot.png" --gate1-captured-at "2026-08-06T00:53:00.023935+00:00" --max-total-price 40.00 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-gate2-live-937122477375-20260806" --timeout 10 --adb D:\Portable\adb\adb.exe
```
成功证据目录为 `%LOCALAPPDATA%\cmbuyer\artifacts\T-105\quantity-gate2-live-937122477375-20260806`;
项目所有者已确认数量精确为 2、颜色尺码未变、Gate1/Gate2 分别为 `12.88/32.76`、Gate2 截图正确、
一次 Back 回到同一商品详情,且未进入确认/提交/支付页、未创建订单。该命令只作已验运行记录,不再重跑。
T-106 已提供四态纯只读取证命令。每次运行都会重新核对设备、拼多多 `8.17.0`、前台包和
1080×2376 屏幕,只调用截图与 `compressed=false` XML;不会打开页面、点击、返回、滑动、输入或识别
确认页业务字段。四个输出目录必须均不存在,完整 XML 只留本机:
```powershell
# 1. 人工准备:Gate2 已通过、尚未进入确认页;运行后再由人记录实际进入确认页的精确控件文本/容器/匹配数
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state gate2-navigation-source --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\gate2-navigation-source-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
# 2. 人工只点击已核对的确认页入口并停在 Gate3 页面;绝不点击最终“提交订单”
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state confirm-gate3 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\confirm-gate3-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
# 3. 人工把最终“提交订单”控件准备为可见但不点击;记录精确文案、匹配数和启用态
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state submit-control-visible --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\submit-control-visible-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
# 4. 人工只返回一次并停手;结果不明时不再返回,直接停止
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state returned-safe-page --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\returned-safe-page-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
```
四态都必须由项目所有者本地核对截图/XML:规格、数量、Gate3 应付总额、最终控件精确文案/匹配数/
启用态、一次返回后的页面身份;同时确认未点击最终提交、未创建订单、未进入支付或安全验证页面。
满足全部人工验收前 T-106 保持 `DOING`,这些人工观察也不能在 T-106 中直接变成生产 selector。
## 关键背景 ## 关键背景
@@ -179,14 +261,13 @@ T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入
## 已知风险(开工前须知) ## 已知风险(开工前须知)
1. **M2 是生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价** 1. **M2 已通过**:T-103 已在真机按链接打开商品、精确勾选颜色分类和尺码并读到该 SKU 单价;
(T-103)。M2 前可以写不依赖页面字段的管理员会话、DRAFT 建单和服务端授权事务;不能写 后续页面能力仍必须逐任务先取证。真机结论改变字段时先回修契约与原型。
依赖未取证选择器的生产执行代码。真机结论改变字段时先回修契约与原型。
2. **拼多多页面结构随版本变化**,已观察到详情页无独立规格入口、价格节点被拆分等情况。 2. **拼多多页面结构随版本变化**,已观察到详情页无独立规格入口、价格节点被拆分等情况。
3. **授权卡死**:前序项目出现过 `EXECUTING` 授权永不推进导致任务锁死。本项目在 T-207 3. **授权卡死**:前序项目出现过 `EXECUTING` 授权永不推进导致任务锁死。本项目在 T-207
实现围栏前超时 / 放弃,在 T-208 实现围栏后调和;围栏后不得释放或重试。 实现围栏前超时 / 放弃,在 T-208 实现围栏后调和;围栏后不得释放或重试。
4. **规格面板读价尚未形成生产判据**:已人工观察当前价与原价,但 T-103 仍需把证据固化为 4. **确认页能力仍未开放**:T-105 已按本项目真机证据开放数量 1→2、readback 与 Gate2;确认页导航、
唯一、版本绑定且排除按钮价的读取规则;失败就转人工,不删价格闸门。 Gate3 和最终控件仍必须依次等待 T-106/T-107 取证,不能从旧项目或推理直接实现。
5. **MVP 已收窄**:只做手工填链接、批量开始采购和单趟创建待付款订单。Excel、ERP、图搜、 5. **MVP 已收窄**:只做手工填链接、批量开始采购和单趟创建待付款订单。Excel、ERP、图搜、
批量顺序编排 / 暂停接管、订单自动核对、AI 辅助全部推到 V2(见 `06-tasks.md` 的 批量顺序编排 / 暂停接管、订单自动核对、AI 辅助全部推到 V2(见 `06-tasks.md` 的
T-501~T-508)。 T-501~T-508)。
+13 -10
View File
@@ -470,6 +470,7 @@
<div><dt>颜色分类</dt><dd id="color">—</dd></div> <div><dt>颜色分类</dt><dd id="color">—</dd></div>
<div><dt>尺码 / 数量</dt><dd id="size-qty">—</dd></div> <div><dt>尺码 / 数量</dt><dd id="size-qty">—</dd></div>
<div><dt>闸门一单价</dt><dd id="gate1-price">—</dd></div> <div><dt>闸门一单价</dt><dd id="gate1-price">—</dd></div>
<div><dt>闸门二面板总额</dt><dd id="gate2-total">—</dd></div>
<div><dt>金额上限</dt><dd id="price-cap">—</dd></div> <div><dt>金额上限</dt><dd id="price-cap">—</dd></div>
<div class="span-2"><dt>提交围栏</dt><dd id="submission">未建立</dd></div> <div class="span-2"><dt>提交围栏</dt><dd id="submission">未建立</dd></div>
</dl> </dl>
@@ -583,7 +584,7 @@
const task = { const task = {
id: 'CB-20260803-0042', id: 'CB-20260803-0042',
product: '纯棉圆领短袖 T 恤(原型假数据)', product: '纯棉圆领短袖 T 恤(原型假数据)',
color: '白色', sizeQty: 'XL / 2 件', gate1Price: 'CNY 32.50', cap: 'CNY 80.00' color: '白色', sizeQty: 'XL / 2 件', gate1Price: 'CNY 32.50', gate2Total: 'CNY 65.00', cap: 'CNY 80.00'
}; };
const records = [ const records = [
{ {
@@ -604,15 +605,15 @@
id:'REC-DEMO-0038', taskId:'CB-20260803-0038', title:'标签打印纸补货(原型假数据)', collectedAt:'2026-08-03T10:06:42+08:00', id:'REC-DEMO-0038', taskId:'CB-20260803-0038', title:'标签打印纸补货(原型假数据)', collectedAt:'2026-08-03T10:06:42+08:00',
status:'WAITING_PAYMENT', spec:'白色 | 50×30 | 1 件', cap:'CNY 50.00', submission:'SUB-DEMO-0038 · SUBMITTED', status:'WAITING_PAYMENT', spec:'白色 | 50×30 | 1 件', cap:'CNY 50.00', submission:'SUB-DEMO-0038 · SUBMITTED',
note:'已明确创建待付款订单,自动化已停止,等待人核对后自行付款。', evidence:['订单确认页内部原始截图(内联假证据)'], note:'已明确创建待付款订单,自动化已停止,等待人核对后自行付款。', evidence:['订单确认页内部原始截图(内联假证据)'],
timeline:['10:04:22 同一趟精确选择规格','10:05:10 两次价格与数量复核通过','10:05:36 获得唯一围栏许可','10:06:42 明确创建待付款订单'], timeline:['10:04:22 同一趟精确选择规格','10:05:10 Gate1 单价、Gate2 面板总额与 Gate3 确认金额通过','10:05:36 获得唯一围栏许可','10:06:42 明确创建待付款订单'],
result:'已创建待付款订单:白色 | 50×30 | CNY 46.00 | 1 件', resultNote:'系统只创建待付款订单,付款始终由人完成。' result:'已创建待付款订单:白色 | 50×30 | CNY 46.00 | 1 件', resultNote:'系统只创建待付款订单,付款始终由人完成。'
}, },
{ {
id:'REC-DEMO-0032', taskId:'CB-20260803-0032', title:'仓库打包胶带(原型假数据)', collectedAt:'2026-08-03T09:18:05+08:00', id:'REC-DEMO-0032', taskId:'CB-20260803-0032', title:'仓库打包胶带(原型假数据)', collectedAt:'2026-08-03T09:18:05+08:00',
status:'NEEDS_MANUAL', spec:'透明 | 45mm | 4 件', cap:'CNY 55.00', submission:'未建立', status:'NEEDS_MANUAL', spec:'透明 | 45mm | 4 件', cap:'CNY 55.00', submission:'未建立',
note:'设置数量后规格面板价格与闸门一不一致,已在围栏前转人工。', evidence:[], note:'目标数量的规格面板总额超过授权上限,已在围栏前转人工。', evidence:[],
timeline:['09:16:40 打开商品','09:17:22 匹配透明 / 45mm','09:18:05 价格闸门二不通过,停止自动流程'], timeline:['09:16:40 打开商品','09:17:22 匹配透明 / 45mm','09:18:05 闸门二面板总额超限,停止自动流程'],
result:'未采购:闸门一 CNY 12.00 | 闸门二 CNY 13.50', resultNote:'没有建立围栏、创建订单或付款。' result:'未采购:闸门一单价 CNY 12.00 | 闸门二面板总额 CNY 58.00 > 上限 CNY 55.00', resultNote:'没有建立围栏、创建订单或付款。'
} }
].sort((a, b) => b.collectedAt.localeCompare(a.collectedAt)); ].sort((a, b) => b.collectedAt.localeCompare(a.collectedAt));
const recordStatus = { const recordStatus = {
@@ -627,7 +628,7 @@
let detailRequestId = 0; let detailRequestId = 0;
const baseSteps = { const baseSteps = {
idle: [], idle: [],
single: ['核对一次性授权并打开商品', '精确选择颜色分类与尺码', '闸门一:读取 SKU 单价并校验上限', '设置数量、读回并执行闸门二', '确认页执行闸门三与控件唯一校验', '申请服务端提交围栏', '首次许可后持久化并内部点击一次', '调和同一提交结果'] single: ['核对一次性授权并打开商品', '精确选择颜色分类与尺码', '闸门一:读取 SKU 单价并校验上限', '设置数量、读回并读取闸门二面板总额', '确认页执行闸门三同额与控件唯一校验', '申请服务端提交围栏', '首次许可后持久化并内部点击一次', '调和同一提交结果']
}; };
const scenarios = { const scenarios = {
not_ready: { tone:'critical', title:'web 服务不可达', text:'未开始轮询。检查本地网络和服务地址;已完成的本地证据会保留待补传。', web:'bad', device:'ok', version:'ok', leg:'未就绪', legTone:'critical', action:'开始轮询', enabled:false, reason:'web 服务未就绪,不能领取任务。', session:'未启动', step:'连接检查', kind:'idle', active:-1, countdown:'—', fail:'1 / 3' }, not_ready: { tone:'critical', title:'web 服务不可达', text:'未开始轮询。检查本地网络和服务地址;已完成的本地证据会保留待补传。', web:'bad', device:'ok', version:'ok', leg:'未就绪', legTone:'critical', action:'开始轮询', enabled:false, reason:'web 服务未就绪,不能领取任务。', session:'未启动', step:'连接检查', kind:'idle', active:-1, countdown:'—', fail:'1 / 3' },
@@ -637,8 +638,8 @@
claimed: { tone:'info', title:'已领取管理员授权的采购任务', text:'任务字段与最高总价已锁定;当前只准备打开目标商品,尚未建立提交围栏。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 已领取', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'当前任务已开始,停止轮询只应阻止下一次领取,不能硬取消本任务。', session:'运行中', step:'核对授权与商品', kind:'single', active:0, countdown:'暂停', fail:'0 / 3', lease:'04:48' }, claimed: { tone:'info', title:'已领取管理员授权的采购任务', text:'任务字段与最高总价已锁定;当前只准备打开目标商品,尚未建立提交围栏。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 已领取', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'当前任务已开始,停止轮询只应阻止下一次领取,不能硬取消本任务。', session:'运行中', step:'核对授权与商品', kind:'single', active:0, countdown:'暂停', fail:'0 / 3', lease:'04:48' },
selecting: { tone:'info', title:'正在精确选择颜色与尺码', text:'只在已取证维度容器内精确唯一匹配并读回;缺失、重复或版本变化立即停止。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 选择规格', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'当前受控步骤不能被硬取消。', session:'运行中', step:'精确选择规格', kind:'single', active:1, countdown:'暂停', fail:'0 / 3', lease:'04:18' }, selecting: { tone:'info', title:'正在精确选择颜色与尺码', text:'只在已取证维度容器内精确唯一匹配并读回;缺失、重复或版本变化立即停止。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 选择规格', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'当前受控步骤不能被硬取消。', session:'运行中', step:'精确选择规格', kind:'single', active:1, countdown:'暂停', fail:'0 / 3', lease:'04:18' },
gate1: { tone:'info', title:'闸门一:读取 SKU 单价', text:'当前规格单价必须唯一可读,单价乘数量不得超过管理员授权的最高总价。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门一', legTone:'info', action:'安全校验进行中', enabled:false, reason:'价格不可读或超上限时立即停止。', session:'运行中', step:'闸门一:读取 SKU 单价', kind:'single', active:2, countdown:'暂停', fail:'0 / 3', lease:'03:58' }, gate1: { tone:'info', title:'闸门一:读取 SKU 单价', text:'当前规格单价必须唯一可读,单价乘数量不得超过管理员授权的最高总价。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门一', legTone:'info', action:'安全校验进行中', enabled:false, reason:'价格不可读或超上限时立即停止。', session:'运行中', step:'闸门一:读取 SKU 单价', kind:'single', active:2, countdown:'暂停', fail:'0 / 3', lease:'03:58' },
quantity_gate2: { tone:'info', title:'数量复核与闸门二', text:'数量必须读回为 2;目标规格不能漂移,第二次单价必须等于闸门一 CNY 32.50。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门二', legTone:'info', action:'安全校验进行中', enabled:false, reason:'数量、规格或价格变化都停止。', session:'运行中', step:'数量读回与闸门二', kind:'single', active:3, countdown:'暂停', fail:'0 / 3', lease:'03:31' }, quantity_gate2: { tone:'info', title:'数量复核与闸门二', text:'数量必须读回为 2;目标规格不能漂移,面板总额 CNY 65.00 必须唯一可读且不超过 CNY 80.00。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门二', legTone:'info', action:'安全校验进行中', enabled:false, reason:'数量、规格、总额歧义或超限都停止。', session:'运行中', step:'数量读回与闸门二', kind:'single', active:3, countdown:'暂停', fail:'0 / 3', lease:'03:31' },
confirm_gate3: { tone:'caution', title:'确认页与闸门三', text:'正在核对规格、数量、应付总额与提交控件唯一;此时没有点击许可。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门三', legTone:'caution', action:'安全校验进行中', enabled:false, reason:'没有服务端围栏许可,绝不能点击提交订单。', session:'运行中', step:'闸门三:核对应付总额', kind:'single', active:4, countdown:'暂停', fail:'0 / 3', lease:'02:58' }, confirm_gate3: { tone:'caution', title:'确认页与闸门三', text:'正在核对规格、数量、应付总额严格等于闸门二面板总额,并确认提交控件唯一;此时没有点击许可。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门三', legTone:'caution', action:'安全校验进行中', enabled:false, reason:'没有服务端围栏许可,绝不能点击提交订单。', session:'运行中', step:'闸门三:核对应付总额', kind:'single', active:4, countdown:'暂停', fail:'0 / 3', lease:'02:58' },
fencing: { tone:'caution', title:'正在申请服务端提交围栏', text:'等待首次明确的 click_permitted。超时、冲突、网络失败或响应不明都不会点击。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 申请围栏', legTone:'caution', action:'等待围栏响应', enabled:false, reason:'申请中禁止取消、关闭或换 key 重复申请。', session:'运行中', step:'申请唯一提交围栏', kind:'single', active:5, countdown:'暂停', fail:'0 / 3', lease:'02:31' }, fencing: { tone:'caution', title:'正在申请服务端提交围栏', text:'等待首次明确的 click_permitted。超时、冲突、网络失败或响应不明都不会点击。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 申请围栏', legTone:'caution', action:'等待围栏响应', enabled:false, reason:'申请中禁止取消、关闭或换 key 重复申请。', session:'运行中', step:'申请唯一提交围栏', kind:'single', active:5, countdown:'暂停', fail:'0 / 3', lease:'02:31' },
fence_failed: { tone:'critical', title:'未获得首次明确点击许可', text:'围栏失败或响应不明:没有点击提交订单。只核查同一幂等键,不能直接重试。', web:'bad', device:'ok', version:'ok', leg:'单趟 · 未获许可', legTone:'critical', action:'打开围栏核查说明', enabled:true, safeAction:'review', reason:'只核查围栏状态,不触发提交。', session:'已停止', step:'核查幂等键', kind:'single', active:5, countdown:'—', fail:'1 / 3', submission:'围栏状态未知 · 幂等键 DEMO-FENCE-042' }, fence_failed: { tone:'critical', title:'未获得首次明确点击许可', text:'围栏失败或响应不明:没有点击提交订单。只核查同一幂等键,不能直接重试。', web:'bad', device:'ok', version:'ok', leg:'单趟 · 未获许可', legTone:'critical', action:'打开围栏核查说明', enabled:true, safeAction:'review', reason:'只核查围栏状态,不触发提交。', session:'已停止', step:'核查幂等键', kind:'single', active:5, countdown:'—', fail:'1 / 3', submission:'围栏状态未知 · 幂等键 DEMO-FENCE-042' },
fenced: { tone:'caution', title:'围栏已建立,只核对同一提交', text:'submission_id 已固定。内部流程至多点击一次;界面不提供提交、重领、放弃或重试。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 已围栏', legTone:'caution', action:'打开同一提交核查说明', enabled:true, safeAction:'review', reason:'只能查看与调和同一提交记录。', session:'受控执行', step:'调和订单结果', kind:'single', active:7, countdown:'暂停', fail:'0 / 3', lease:'01:49', submission:'SUB-DEMO-0042 · FENCED' }, fenced: { tone:'caution', title:'围栏已建立,只核对同一提交', text:'submission_id 已固定。内部流程至多点击一次;界面不提供提交、重领、放弃或重试。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 已围栏', legTone:'caution', action:'打开同一提交核查说明', enabled:true, safeAction:'review', reason:'只能查看与调和同一提交记录。', session:'受控执行', step:'调和订单结果', kind:'single', active:7, countdown:'暂停', fail:'0 / 3', lease:'01:49', submission:'SUB-DEMO-0042 · FENCED' },
@@ -646,7 +647,7 @@
uncertain: { tone:'critical', title:'订单可能已创建,结果不明确', text:'保持金额预留和永久围栏,只核查 SUB-DEMO-0042;禁止重新领取、释放授权或再次点击。', web:'ok', device:'ok', version:'ok', leg:'RECONCILIATION_REQUIRED', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'核查不会发起新的下单。可另行导出假证据摘要。', session:'已停止', step:'人工核查同一提交', kind:'single', active:7, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · 结果不明确' }, uncertain: { tone:'critical', title:'订单可能已创建,结果不明确', text:'保持金额预留和永久围栏,只核查 SUB-DEMO-0042;禁止重新领取、释放授权或再次点击。', web:'ok', device:'ok', version:'ok', leg:'RECONCILIATION_REQUIRED', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'核查不会发起新的下单。可另行导出假证据摘要。', session:'已停止', step:'人工核查同一提交', kind:'single', active:7, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · 结果不明确' },
security: { tone:'critical', title:'检测到安全校验,已停止', text:'不绕过验证码、风控、人脸或短信。若已围栏,只调和同一提交;围栏前转人工。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 安全校验', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'只显示核查说明,不提供绕过或继续。', session:'已停止', step:'等待人工处理', kind:'single', active:7, countdown:'—', fail:'1 / 3', submission:'SUB-DEMO-0042 · RECONCILIATION_REQUIRED' }, security: { tone:'critical', title:'检测到安全校验,已停止', text:'不绕过验证码、风控、人脸或短信。若已围栏,只调和同一提交;围栏前转人工。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 安全校验', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'只显示核查说明,不提供绕过或继续。', session:'已停止', step:'等待人工处理', kind:'single', active:7, countdown:'—', fail:'1 / 3', submission:'SUB-DEMO-0042 · RECONCILIATION_REQUIRED' },
payment_handoff: { tone:'critical', title:'进入外部支付交接,已停止', text:'订单可能已创建、支付未完成。不会读取或输入支付凭据,也没有“继续付款”按钮。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 外部支付', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'paymentInfo', reason:'只显示人工核查与付款边界。', session:'已停止', step:'人工核查同一提交', kind:'single', active:7, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · HANDED_OFF' }, payment_handoff: { tone:'critical', title:'进入外部支付交接,已停止', text:'订单可能已创建、支付未完成。不会读取或输入支付凭据,也没有“继续付款”按钮。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 外部支付', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'paymentInfo', reason:'只显示人工核查与付款边界。', session:'已停止', step:'人工核查同一提交', kind:'single', active:7, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · HANDED_OFF' },
manual: { tone:'caution', title:'任务已转人工:闸门二价格变化', text:'闸门一 CNY 32.50,数量设置后重读 CNY 35.00。围栏未建立,没有创建订单;需管理员核查后重置。', web:'ok', device:'ok', version:'ok', leg:'NEEDS_MANUAL', legTone:'caution', action:'导出假诊断摘要', enabled:true, safeAction:'export', reason:'导出只生成本地假文本,不含完整 XML、页面全文或敏感字段。', session:'继续轮询其他任务', step:'当前任务已移交', kind:'single', active:3, countdown:'00:11', fail:'0 / 3', submission:'未建立' }, manual: { tone:'caution', title:'任务已转人工:闸门二总额超限', text:'目标数量面板总额 CNY 85.00 超过最高总价 CNY 80.00。围栏未建立,没有创建订单;需管理员核查后重置。', web:'ok', device:'ok', version:'ok', leg:'NEEDS_MANUAL', legTone:'caution', action:'导出假诊断摘要', enabled:true, safeAction:'export', reason:'导出只生成本地假文本,不含完整 XML、页面全文或敏感字段。', session:'继续轮询其他任务', step:'当前任务已移交', kind:'single', active:3, countdown:'00:11', fail:'0 / 3', submission:'未建立' },
failed_stop: { tone:'critical', title:'连续失败达到阈值,轮询已停止', text:'最近三次均为服务连接失败。检查设置和网络;修复后重新开始普通轮询,不会重试任何提交点击。', web:'bad', device:'ok', version:'ok', leg:'自动停止', legTone:'critical', action:'前往配置', enabled:true, safeAction:'settings', reason:'先修复连接;不会自动恢复轮询。', session:'已停止', step:'等待修复', kind:'idle', active:-1, countdown:'—', fail:'3 / 3', count:4 } failed_stop: { tone:'critical', title:'连续失败达到阈值,轮询已停止', text:'最近三次均为服务连接失败。检查设置和网络;修复后重新开始普通轮询,不会重试任何提交点击。', web:'bad', device:'ok', version:'ok', leg:'自动停止', legTone:'critical', action:'前往配置', enabled:true, safeAction:'settings', reason:'先修复连接;不会自动恢复轮询。', session:'已停止', step:'等待修复', kind:'idle', active:-1, countdown:'—', fail:'3 / 3', count:4 }
}; };
const sessionControls = { const sessionControls = {
@@ -834,7 +835,9 @@
$('task-id').textContent = hasTask ? task.id : '—'; $('lease').textContent = s.lease || '—'; $('task-id').textContent = hasTask ? task.id : '—'; $('lease').textContent = s.lease || '—';
$('product').textContent = hasTask ? task.product : '暂无商品;轮询会在安全就绪后领取一条任务'; $('product').textContent = hasTask ? task.product : '暂无商品;轮询会在安全就绪后领取一条任务';
$('color').textContent = hasTask ? task.color : '—'; $('size-qty').textContent = hasTask ? task.sizeQty : '—'; $('color').textContent = hasTask ? task.color : '—'; $('size-qty').textContent = hasTask ? task.sizeQty : '—';
$('gate1-price').textContent = hasTask && ['gate1','quantity_gate2','confirm_gate3','fencing','fence_failed','fenced','waiting_payment','uncertain','security','payment_handoff','manual'].includes($('scenario').value) ? task.gate1Price : '—'; $('price-cap').textContent = hasTask ? task.cap : '—'; $('gate1-price').textContent = hasTask && ['gate1','quantity_gate2','confirm_gate3','fencing','fence_failed','fenced','waiting_payment','uncertain','security','payment_handoff','manual'].includes($('scenario').value) ? task.gate1Price : '—';
$('gate2-total').textContent = hasTask && ['quantity_gate2','confirm_gate3','fencing','fence_failed','fenced','waiting_payment','uncertain','security','payment_handoff'].includes($('scenario').value) ? task.gate2Total : '—';
$('price-cap').textContent = hasTask ? task.cap : '—';
$('submission').textContent = s.submission || '未建立'; $('submission').textContent = s.submission || '未建立';
$('preview-empty').hidden = hasTask; $('product-preview-image').hidden = !hasTask; $('preview-empty').hidden = hasTask; $('product-preview-image').hidden = !hasTask;
$('preview-source').textContent = hasTask ? '来源:内部原始商品截图(内联假证据)' : '来源:尚无可信图片'; $('preview-source').textContent = hasTask ? '来源:内部原始商品截图(内联假证据)' : '来源:尚无可信图片';
+7 -7
View File
@@ -26,22 +26,22 @@
<article class="state-panel" data-panel="draft"> <article class="state-panel" data-panel="draft">
<header class="panel-head"><h2>待开始</h2><p>任务已保存,但尚未授权采购工具领取或创建订单。</p></header> <header class="panel-head"><h2>待开始</h2><p>任务已保存,但尚未授权采购工具领取或创建订单。</p></header>
<div class="panel-body"><div class="notice"><strong>创建不等于授权</strong>请返回任务列表勾选任务,并点击“开始采购(只创建待付款订单)”。</div><dl class="summary"><div><dt>商品</dt><dd>goods_id 937122477375</dd></div><div><dt>颜色 / 尺码</dt><dd>黑色CHA(纯棉) / M(建议100-115)</dd></div><div><dt>数量</dt><dd>2 件</dd></div><div><dt>最高总价</dt><dd>¥30.00</dd></div></dl><div class="actions"><a class="button primary" href="web-task-workbench.html">返回列表选择并开始采购</a></div></div> <div class="panel-body"><div class="notice"><strong>创建不等于授权</strong>请返回任务列表勾选任务,并点击“开始采购(只创建待付款订单)”。</div><dl class="summary"><div><dt>商品</dt><dd>goods_id 937122477375</dd></div><div><dt>颜色 / 尺码</dt><dd>黑色CHA(纯棉) / M(建议100-115)</dd></div><div><dt>数量</dt><dd>2 件</dd></div><div><dt>最高总价</dt><dd>¥40.00</dd></div></dl><div class="actions"><a class="button primary" href="web-task-workbench.html">返回列表选择并开始采购</a></div></div>
</article> </article>
<article class="state-panel" data-panel="pending" hidden> <article class="state-panel" data-panel="pending" hidden>
<header class="panel-head"><h2>已授权,等待采购工具领取</h2><p>管理员的开始采购动作已锁定任务字段和最高总价。</p></header> <header class="panel-head"><h2>已授权,等待采购工具领取</h2><p>管理员的开始采购动作已锁定任务字段和最高总价。</p></header>
<div class="panel-body"><div class="notice ok"><strong>一次性授权已创建</strong>允许采购工具按本任务创建一笔待付款订单;不授权付款。</div><dl class="summary"><div><dt>授权编号</dt><dd class="mono">AUTH-018-V2</dd></div><div><dt>任务版本</dt><dd>2</dd></div><div><dt>最高总价</dt><dd>¥30.00</dd></div><div><dt>提交围栏</dt><dd>尚未建立</dd></div></dl><p>本状态没有“机器选对了吗”按钮。规格与价格将在设备执行时实时校验。</p></div> <div class="panel-body"><div class="notice ok"><strong>一次性授权已创建</strong>允许采购工具按本任务创建一笔待付款订单;不授权付款。</div><dl class="summary"><div><dt>授权编号</dt><dd class="mono">AUTH-018-V2</dd></div><div><dt>任务版本</dt><dd>2</dd></div><div><dt>最高总价</dt><dd>¥40.00</dd></div><div><dt>提交围栏</dt><dd>尚未建立</dd></div></dl><p>本状态没有“机器选对了吗”按钮。规格与价格将在设备执行时实时校验。</p></div>
</article> </article>
<article class="state-panel" data-panel="ordering" hidden> <article class="state-panel" data-panel="ordering" hidden>
<header class="panel-head"><h2>单趟采购执行中</h2><p>采购工具正在同一设备会话中完成规格、价格、数量和确认页校验。</p></header> <header class="panel-head"><h2>单趟采购执行中</h2><p>采购工具正在同一设备会话中完成规格、价格、数量和确认页校验。</p></header>
<div class="panel-body"><ol class="timeline"><li><strong>已打开目标商品</strong>App 8.17.0;goods_id 已核对</li><li><strong>已精确选择规格</strong>黑色CHA(纯棉) / M(建议100-115)</li><li><strong>闸门一通过</strong>SKU 单价 ¥12.88;预计总额 ¥25.76</li><li><strong>数量已读回</strong>2 件;正在执行闸门二</li><li><strong>尚未建立提交围栏</strong>不会点击提交订单</li></ol><div class="notice warn"><strong>无需中途人工确认</strong>任一读数变化或有歧义,系统会停止并转人工。</div></div> <div class="panel-body"><ol class="timeline"><li><strong>已打开目标商品</strong>App 8.17.0;goods_id 已核对</li><li><strong>已精确选择规格</strong>黑色CHA(纯棉) / M(建议100-115)</li><li><strong>闸门一通过</strong>SKU 单价 ¥12.88;数量前预检通过</li><li><strong>闸门二通过</strong>数量读回 2 件;面板总额 ¥32.76 ≤ ¥40.00</li><li><strong>尚未建立提交围栏</strong>不会点击提交订单</li></ol><div class="notice warn"><strong>无需中途人工确认</strong>任一读数变化或有歧义,系统会停止并转人工。</div></div>
</article> </article>
<article class="state-panel" data-panel="fenced" hidden> <article class="state-panel" data-panel="fenced" hidden>
<header class="panel-head"><h2>提交围栏已建立</h2><p>只允许处理这一个 submission,不允许释放或再次提交。</p></header> <header class="panel-head"><h2>提交围栏已建立</h2><p>只允许处理这一个 submission,不允许释放或再次提交。</p></header>
<div class="panel-body"><div class="notice danger"><strong>禁止重试</strong>唯一提交记录 <span class="mono">SUB-018-5A7C</span> 已占用本授权;关闭页面也不会释放。</div><table class="gates"><thead><tr><th>门禁</th><th>读取值</th><th>结论</th></tr></thead><tbody><tr><td>闸门一</td><td>¥12.88 × 2 = ¥25.76</td><td class="good">通过</td></tr><tr><td>闸门二</td><td>规格不变;¥12.88</td><td class="good">通过</td></tr><tr><td>闸门三</td><td>确认页 ¥25.76 ≤ ¥30.00</td><td class="good">通过</td></tr><tr><td>提交控件</td><td>精确唯一 1 个</td><td class="good">通过</td></tr></tbody></table><div class="actions"><button class="button primary" type="button" data-state="reconcile">查看同一提交的调和页</button></div></div> <div class="panel-body"><div class="notice danger"><strong>禁止重试</strong>唯一提交记录 <span class="mono">SUB-018-5A7C</span> 已占用本授权;关闭页面也不会释放。</div><table class="gates"><thead><tr><th>门禁</th><th>读取值</th><th>结论</th></tr></thead><tbody><tr><td>闸门一</td><td>单价 ¥12.88;数量前预检通过</td><td class="good">通过</td></tr><tr><td>闸门二</td><td>规格 / 数量不变;面板总额 ¥32.76 ≤ ¥40.00</td><td class="good">通过</td></tr><tr><td>闸门三</td><td>确认页 ¥32.76 = 闸门二;≤ ¥40.00</td><td class="good">通过</td></tr><tr><td>提交控件</td><td>精确唯一 1 个</td><td class="good">通过</td></tr></tbody></table><div class="actions"><button class="button primary" type="button" data-state="reconcile">查看同一提交的调和页</button></div></div>
</article> </article>
<article class="state-panel" data-panel="reconcile" hidden> <article class="state-panel" data-panel="reconcile" hidden>
@@ -51,12 +51,12 @@
<article class="state-panel" data-panel="payment" hidden> <article class="state-panel" data-panel="payment" hidden>
<header class="panel-head"><h2>待付款</h2><p>订单已创建,系统尚未付款;请人工核对。</p></header> <header class="panel-head"><h2>待付款</h2><p>订单已创建,系统尚未付款;请人工核对。</p></header>
<div class="panel-body"><figure class="evidence"><svg viewBox="0 0 760 260" role="img" aria-labelledby="ev-title ev-desc"><title id="ev-title">内部订单截图假数据</title><desc id="ev-desc">只表示证据位置,不代表真实拼多多布局。</desc><rect width="760" height="260" fill="#e8eef7"/><rect x="30" y="25" width="700" height="210" rx="10" fill="#fff" stroke="#b8c4d5"/><text x="55" y="65" fill="#526079" font-size="18">INTERNAL_RAW 截图占位 · 假数据</text><text x="55" y="115" fill="#172033" font-size="19">黑色CHA(纯棉) · M(建议100-115) · 2 件</text><text x="55" y="165" fill="#172033" font-size="24">确认页金额 ¥25.76</text><text x="55" y="205" fill="#9a5b00" font-size="18">订单已创建,尚未付款</text></svg><figcaption>原始截图只供已登录管理员查看;完整 XML 和支付凭据不上传。</figcaption></figure><div class="checks"><label><input type="checkbox" data-pay-check>商品与任务一致</label><label><input type="checkbox" data-pay-check>规格和数量一致</label><label><input type="checkbox" data-pay-check>金额不超过授权上限 ¥30.00</label><label><input type="checkbox" data-pay-check>我已在拼多多人工完成付款</label></div><div class="actions"><button class="button primary" id="mark-paid" type="button" disabled>标记为已付款并完成</button></div></div> <div class="panel-body"><figure class="evidence"><svg viewBox="0 0 760 260" role="img" aria-labelledby="ev-title ev-desc"><title id="ev-title">内部订单截图假数据</title><desc id="ev-desc">只表示证据位置,不代表真实拼多多布局。</desc><rect width="760" height="260" fill="#e8eef7"/><rect x="30" y="25" width="700" height="210" rx="10" fill="#fff" stroke="#b8c4d5"/><text x="55" y="65" fill="#526079" font-size="18">INTERNAL_RAW 截图占位 · 假数据</text><text x="55" y="115" fill="#172033" font-size="19">黑色CHA(纯棉) · M(建议100-115) · 2 件</text><text x="55" y="165" fill="#172033" font-size="24">确认页金额 ¥32.76</text><text x="55" y="205" fill="#9a5b00" font-size="18">订单已创建,尚未付款</text></svg><figcaption>原始截图只供已登录管理员查看;完整 XML 和支付凭据不上传。</figcaption></figure><div class="checks"><label><input type="checkbox" data-pay-check>商品与任务一致</label><label><input type="checkbox" data-pay-check>规格和数量一致</label><label><input type="checkbox" data-pay-check>金额不超过授权上限 ¥40.00</label><label><input type="checkbox" data-pay-check>我已在拼多多人工完成付款</label></div><div class="actions"><button class="button primary" id="mark-paid" type="button" disabled>标记为已付款并完成</button></div></div>
</article> </article>
<article class="state-panel" data-panel="manual" hidden> <article class="state-panel" data-panel="manual" hidden>
<header class="panel-head"><h2>围栏前需要人工处理</h2><p>系统已经停止,未申请围栏、未点击提交订单。</p></header> <header class="panel-head"><h2>围栏前需要人工处理</h2><p>系统已经停止,未申请围栏、未点击提交订单。</p></header>
<div class="panel-body"><div class="notice danger"><strong>闸门二未通过</strong>闸门一 ¥12.88,数量设置后重读 ¥13.20;不使用新价格继续。</div><dl class="summary"><div><dt>失败阶段</dt><dd>规格面板 · 闸门二</dd></div><div><dt>原因码</dt><dd class="mono">SKU_PRICE_CHANGED</dd></div><div><dt>安全处置</dt><dd>停止并保留证据</dd></div><div><dt>可用动作</dt><dd>人工核查后重置为待开始或取消</dd></div></dl><div class="actions"><button class="button danger" id="reset-button" type="button">关闭旧授权并重置为待开始</button></div></div> <div class="panel-body"><div class="notice danger"><strong>闸门二未通过</strong>目标数量面板总额 ¥42.00 超过最高总价 ¥40.00;围栏未建立。</div><dl class="summary"><div><dt>失败阶段</dt><dd>规格面板 · 闸门二</dd></div><div><dt>原因码</dt><dd class="mono">GATE2_TOTAL_OVER_CAP</dd></div><div><dt>安全处置</dt><dd>停止并保留证据</dd></div><div><dt>可用动作</dt><dd>人工核查后重置为待开始或取消</dd></div></dl><div class="actions"><button class="button danger" id="reset-button" type="button">关闭旧授权并重置为待开始</button></div></div>
</article> </article>
<article class="state-panel" data-panel="succeeded" hidden><header class="panel-head"><h2>任务已完成</h2><p>人工付款并核对后才进入终态。</p></header><div class="panel-body"><div class="notice ok"><strong>人工付款事实已记录</strong>系统没有执行付款,也没有读取支付凭据。</div><div class="actions"><a class="button primary" href="web-task-workbench.html">返回任务列表</a></div></div></article> <article class="state-panel" data-panel="succeeded" hidden><header class="panel-head"><h2>任务已完成</h2><p>人工付款并核对后才进入终态。</p></header><div class="panel-body"><div class="notice ok"><strong>人工付款事实已记录</strong>系统没有执行付款,也没有读取支付凭据。</div><div class="actions"><a class="button primary" href="web-task-workbench.html">返回任务列表</a></div></div></article>
@@ -66,7 +66,7 @@
<aside class="panel side" aria-label="原型状态与任务摘要"> <aside class="panel side" aria-label="原型状态与任务摘要">
<section><h2>原型状态</h2><p>只切换假数据,不发送请求。</p><label class="field-label" for="state-select">业务状态</label><select id="state-select"><option value="draft">DRAFT</option><option value="pending">PENDING</option><option value="ordering">ORDERING</option><option value="fenced">FENCED</option><option value="reconcile">RECONCILIATION_REQUIRED</option><option value="payment">WAITING_PAYMENT</option><option value="manual">NEEDS_MANUAL</option><option value="succeeded">SUCCEEDED</option><option value="failed">FAILED</option><option value="canceled">CANCELED</option></select></section> <section><h2>原型状态</h2><p>只切换假数据,不发送请求。</p><label class="field-label" for="state-select">业务状态</label><select id="state-select"><option value="draft">DRAFT</option><option value="pending">PENDING</option><option value="ordering">ORDERING</option><option value="fenced">FENCED</option><option value="reconcile">RECONCILIATION_REQUIRED</option><option value="payment">WAITING_PAYMENT</option><option value="manual">NEEDS_MANUAL</option><option value="succeeded">SUCCEEDED</option><option value="failed">FAILED</option><option value="canceled">CANCELED</option></select></section>
<section><h2>任务要求</h2><p>黑色CHA(纯棉)<br>M(建议100-115)<br>2 件<br>最高总价 ¥30.00</p></section> <section><h2>任务要求</h2><p>黑色CHA(纯棉)<br>M(建议100-115)<br>2 件<br>最高总价 ¥40.00</p></section>
<section><h2>固定边界</h2><p>单趟实时三闸门;服务端围栏;提交一次;系统不付款。</p></section> <section><h2>固定边界</h2><p>单趟实时三闸门;服务端围栏;提交一次;系统不付款。</p></section>
</aside> </aside>
</div> </div>
+7 -4
View File
@@ -75,8 +75,9 @@
1. 任务要求:商品链接 / goods_id、颜色、尺码、数量、最高总价、版本。 1. 任务要求:商品链接 / goods_id、颜色、尺码、数量、最高总价、版本。
2. 开始采购授权:授权 id、授权人、锁定任务版本、创建/有效期、当前状态。 2. 开始采购授权:授权 id、授权人、锁定任务版本、创建/有效期、当前状态。
3. 设备执行:attempt、设备、App 版本、步骤时间线和失败 code。 3. 设备执行:attempt、设备、App 版本、步骤时间线和失败 code。
4. 三道闸门:两次规格面板单价、规格/数量读回、确认页总额与判定。 4. 三道闸门:闸门一规格面板单价、规格/数量读回、闸门二面板总额、确认页同额校验与判定。
5. 内部截图:规格面板、确认页和结果页;只经受保护端点读取。 5. 内部截图:三道闸门已批准的规格面板/确认页证据;只经受保护端点读取。MVP 不存在提交后结果
截图类型,不展示外部支付页,也不把 Gate3 图片冒充结果图。
6. 提交围栏:submission id、是否首次明确许可、唯一点击和调和记录。 6. 提交围栏:submission id、是否首次明确许可、唯一点击和调和记录。
7. 待付款收口:明确写系统尚未付款;人工核对后记录完成。 7. 待付款收口:明确写系统尚未付款;人工核对后记录完成。
@@ -122,10 +123,12 @@ T-204 只显示数据库中当前实际存在的任务、授权、attempt、subm
### 配置 tab ### 配置 tab
- 采购服务 URL、设备 token(密码框,不回显完整值); - 采购服务 URL、设备 token(密码框,不回显完整值;首次必填,已有凭据时留空表示保留,非空表示替换);
- ADB 路径、设备 serial、USB/WiFi 通道选择; - ADB 路径、设备 serial、USB/WiFi 通道选择;
- 轮询间隔与连续失败停止阈值; - 轮询间隔与连续失败停止阈值;
- 连接检查:服务、设备身份、拼多多安装和已取证版本。 - 只做本地字段与路径格式检查,并提示服务身份、设备连接、拼多多安装和已取证版本将在首次真实领取/
后续已批准真机流程中验证。配置页不调用 `claim-next`、renew、evidence 等业务 API,也不运行 ADB/PDD
作为“连接检查”;MVP 不提供清除已保存设备凭据的命令。
配置凭据进入系统安全存储;日志和界面不显示完整 token。 配置凭据进入系统安全存储;日志和界面不显示完整 token。
+250 -31
View File
@@ -3,7 +3,7 @@ id: T-103
title: 验证规格面板精确选择与 SKU 单价 title: 验证规格面板精确选择与 SKU 单价
phase: 1 phase: 1
deps: [T-102, T-110, T-111] deps: [T-102, T-110, T-111]
status: DOING status: DONE
created: 2026-08-04 created: 2026-08-04
vikunja_task_id: 23 vikunja_task_id: 23
context_ref: 1dc8308 context_ref: 1dc8308
@@ -17,45 +17,52 @@ write_paths:
- client/tests/pdd/** - client/tests/pdd/**
- client/tests/device/** - client/tests/device/**
- client/scripts/capture_sku_panel_spike.py - client/scripts/capture_sku_panel_spike.py
- client/scripts/capture_sku_reveal_spike.py
- client/scripts/run_t103_sku_selection.py - client/scripts/run_t103_sku_selection.py
- client/scripts/sanitize_sku_panel_evidence.py - client/scripts/sanitize_sku_panel_evidence.py
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/current-state.md - docs/current-state.md
--- ---
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T10:47:47Z sha256=5471bdfeed1fffe4f0abd25201b9d5bf7bf55fa9500c11aa297eb4c422da5f02 --> <!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T09:03:05Z sha256=2a81aa73a233e09b2b9418aba9a6f298428bba12371110205a0f782fb90787ca -->
## 问题 / 背景 ## 问题 / 背景
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。 T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:详情页底部购买区第一行“快要抢光 + 金额”是唯一获准的受控规格入口;商品内容区同名小字和第二行“免拼购买”都不得点击。
项目所有者已把 MVP 改为单趟:管理员点击“开始采购”即授权采购工具按锁定的商品、规格、数量和最高总价创建待付款订单,不再有试选后确认。T-103 仍只验证规格选择与读价,是生产单趟的窄前置;数量、确认页、提交围栏和提交订单由后续取证任务开放。系统绝不自动付款。 2026-08-05 的五组最新真机证据纠正了旧结论:规格面板刚打开时颜色和尺码均未选中,摘要为“请选择:颜色分类 尺码”;选择目标颜色后尺码仍未选且不在视口;滚动后尺码选项可见。人工已补齐 S 非目标值与恢复目标 M 的前后证据,但现有滚动后证据中的 M 已选中,尚不能证明滚动动作本身不会误选尺码。
`t103-privacy-v5` 派生 XML 已由人确认保留目标规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”,且不含实际地址/手机号;底部“提交订单 ¥12.88”是硬拒绝区。项目已停止遮罩器开发。正式流程允许把规格/确认页内部原始截图上传采购服务,完整 XML 只留客户端本机。 项目为单趟 MVP:管理员点击“开始采购”授权采购工具创建待付款订单,但 T-103 仍只是规格选择与闸门一读价的窄前置。数量、确认页、提交围栏、提交订单和支付能力不属于本任务;系统绝不自动付款。
## 关联需求与交互 ## 关联需求与交互
- 功能:F-006 单趟采购的规格面板前置;覆盖 `open_sku_panel()`、`select_sku_options()`、`read_sku_unit_price()` 和安全退出的真机 spike。 - 功能:F-006 单趟采购的规格面板前置;覆盖 `open_sku_panel()`、精确颜色/尺码选择、`read_sku_unit_price()`、本机原始截图与一次安全 Back。
- 架构/API:`docs/04-architecture.md` 第三、四、5.4、六节;`docs/api.md` 的 `SkuSelectionFlow`;T-104、T-105 与 T-205 的前置。 - 架构/API:`docs/04-architecture.md` 第三、四、5.4、六节;`docs/api.md` 的 `SkuSelectionFlow`;T-104、T-105 与 T-205 的前置。
- 本任务没有生产 GUI,也不调用 T-204 的 HTTP 截图链路。 - 本任务没有生产 GUI,不调用 T-204 的 HTTP 截图链路;完整 XML 只留本机。
## 方案 ## 方案
1. 停止修改、调用或扩展截图遮罩器。T-103 原始截图保存在本机产物目录完成真机验收;T-204 以后显式上传 `privacy_tier=INTERNAL_RAW` 的截图。完整 XML 不上传、不进日志、Vikunja 或 Git。 1. 以五组 2026-08-05 真机证据替换“面板打开即自动预选”的旧事实。证据均绑定 PKG110 / Android 16 / 拼多多 8.17.0 / Wi-Fi / goods_id `937122477375`;从中提取不含地址、手机号或支付凭据的最小白名单 fixture,不提交原始截图或完整 XML。
2. 从已确认 v5 派生 XML 提取只含页面判据节点的最小 fixture;fixture 不含地址、手机号或支付凭据。 2. 把面板状态拆为整体 profile,禁止跨 profile 拼装:`PANEL_OPEN_EMPTY`(两维未选、尺码隐藏)、`COLOR_SELECTED_SIZE_HIDDEN`(仅目标颜色已选)、`SIZE_VISIBLE_NON_TARGET`(目标颜色 + S)、`TARGETS_SELECTED`(目标颜色 + M)。摘要、容器、节点类型、选中态和相对结构任一漂移均 fail closed。
3. 实现 `SkuSelectionFlow` 窄 capability。`open_sku_panel()` 只允许拼多多 8.17.0、goods_id `937122477375` 上证据绑定、精确唯一的“快要抢光”;缺失、重复、版本失配或面板不唯一时零后续点击。其他购买文案不得复用。 3. `open_sku_panel()` 沿用已审底部入口链,只点击第一行文本中心一次且不重试;打开后的初态判据不得要求尺码 option 已在视口或已有 selected。
4. 本任务只含打开商品、受控打开面板、维度内精确选择、选中态读回、读取 SKU 单价、原始截图和安全退出。不得暴露通用 `click`、数量、确认页、授权、提交围栏、提交订单或支付能力。 4. 目标颜色只在颜色容器内按 exact content-desc 唯一匹配;从未选到选中只点击一次,并以目标颜色 selected=true、摘要 exact“请选择:尺码”双重读回。
5. 规格选项按维度容器隔离后精确唯一匹配,防“红/粉红”“1/10”前缀碰撞;缺失、重复、禁用、维度不明或选中态不唯一均停止。 5. 尺码显示只允许私有、命名的 `reveal_size_options()` 窄能力:仅可从“目标颜色已选、尺码隐藏且未选”状态调用,绑定已取证的嵌套滚动容器与列间安全通道,最多一次、不重试,不暴露通用 swipe/scroll/任意坐标。生产动作落地前,只允许独立 `capture_sku_reveal_spike.py` 在严格前置成立后执行一次固定无参手势并采集 before/after;生产 Flow 不得调用该 spike。只有人确认动作前后 M 均未选、尺码已显示且提交区未触发后,才能把后置固化为生产判据。
6. 单价只从已取证规格面板当前价角色读取,严格解析人民币十进制字符串。促销前缀可变,不能把“快卖完”作为主判据;必须限定 PDD package、面板结构/相对位置、非点击价格叶节点、唯一当前价并排除原价、提交/支付/优惠语义。歧义返回 unreadable,不从详情页或底部按钮补值。 6. 尺码可见时,S/M 必须是证据绑定的 exact clickable TextView;恢复流程只允许 S=true/M=false → 点击 M 一次 → S=false/M=true,并用 exact 已选摘要双重读回。生产未选态也必须证明 M=false 后才点击。
7. 真机运行只返回规格、选中态、价格和页面状态摘要;地址/手机号不解析为字段。截图上传器不属于本任务。 7. 闸门一单价只在目标颜色和目标 M 都精确选中后读取。只接受已取证滚动态面板价格行中、PDD package、只读、无可点击祖先、唯一的当前价;严格返回十进制字符串 `12.88`,明确区分原价 `29.88` 和硬拒绝区“提交订单 ¥12.88”。未知前缀、候选重复、节点拆分或结构漂移均 unreadable,不从初态、详情页或按钮补值。
8. 离线主审通过后,由人先把颜色和尺码改成记录的非目标值,再运行脚本恢复目标规格、读取 12.88、保存原始截图并安全退出。`needs_device=true`,agent 保持 DOING 等人工验收。 8. 一次 Back 在发送前封存且不重试;“面板消失”不得自动升级为生产安全退出结论。精确同商品退出判据由依赖任务 T-104 另行真机取证并收紧。
9. 真机 runner 只返回规格、选中态、价格、截图路径和固定阶段摘要;不解析地址/手机号为业务字段。T-103 不再修改或调用截图遮罩器。
## 验收要点 ## 验收要点
- 最小 fixture 只来自本项目拼多多 8.17.0、goods_id `937122477375` 已验收的 v5 派生 XML,不含地址、手机号或支付凭据。 - fixture 分别来自本项目 8.17.0 的未选初态、仅目标颜色态、S 态和恢复 M 态;旧 v5 全选 fixture 不得冒充初态,fixture 不含地址、手机号或支付凭据。
- 受控入口、规格容器、精确选择、选中态和当前价任一歧义均 fail closed;价格能区分 12.88、原价 29.88 与硬拒绝区“提交订单 ¥12.88”。 - 四态 classifier 正例通过;package、profile、父子关系、节点类型、摘要、选中态、坐标/相对结构任一漂移和跨 profile 拼装全部拒绝。
- 静态 API/导入测试证明 `SkuSelectionFlow` 无通用点击、数量、确认页、授权、围栏、提交或支付能力。 - 颜色 exact 唯一点击一次;缺失、重复、禁用、近似文本、读回不一致均零后续动作。
- T-103 不再开发遮罩。T-204 的原始截图上传必须限制为显式截图文件、设备鉴权、SHA-256 和管理员会话访问,不上传完整 XML 或支付凭据。 - 在生产 reveal 落地前,独立 spike 必须补齐“请选择尺码 / M=false / 隐藏 → 单次受控 reveal → 请选择尺码 / M=false / 可见”的真机动作证据;CLI 不接受颜色、尺码、坐标或手势参数。RPC 正常或不明后都只读调和,页面不变、颜色/摘要漂移、M 意外选中、尺码仍隐藏或提交区风险均不重试、不发布;成功后不 Back,等待人核对。
- S=true/M=false → 仅点击 exact M 一次 → S=false/M=true 且摘要 exact;任何歧义零点击。
- 价格测试能区分当前价 12.88、原价 29.88、初态价格与底部提交按钮价;未知前缀、可点击祖先、多个候选、拆分货币全部 unreadable。
- 静态 API/AST/导入测试证明 Flow/Protocol/runner 无公开通用 swipe/scroll/click、数量、确认页、授权、围栏、提交或支付能力,T-103 调用链不可达下单函数。
- client 全部单测、compileall、wheel metadata、上下文校验、完整 init 与 diff-check 通过。 - client 全部单测、compileall、wheel metadata、上下文校验、完整 init 与 diff-check 通过。
- 只有人完成受控入口、精确规格选择、SKU 单价语义、页面对应性、原始截图与安全退出验收后才能 DONE。 - 只有人完成入口 → 目标颜色 → 单次受控 reveal → 目标 M → 读取 12.88 → 一次安全 Back 的真机验收后才能 DONE;agent 始终保持 Doing 等人工结论。
## 执行记录 ## 执行记录
@@ -182,25 +189,235 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
### 2026-08-04T10:47:38Z · ila ### 2026-08-04T10:47:38Z · ila
2026-08-04 T-103 入口父链收紧完成:提交 44586fe,合入主分支 ce9d6ca。入口只允许精确唯一“快要抢光”子节点及五层已取证可点击 PDD 祖先链,实际点击仍取子节点中心;“免拼购买”独立容器为硬拒绝。独立审计复算证据 hash、验证 focused 32 / full 112 tests 和完整 init 均 PASS。最终真机运行曾安全失败且未发布输出目录;随后只读诊断发现系统通知栏覆盖,app_current 虽仍报告 PDD,但节点树 64 个节点全部属于 SystemUI。下一次必须由人先完全收起通知栏并停在 PDD 首页再运行;T-103 保持 DOING,不把这次失败或入口截图确认误记为完整验收。 2026-08-04 T-103 入口父链收紧完成:提交 44586fe,合入主分支 ce9d6ca。入口只允许精确唯一“快要抢光”子节点及五层已取证可点击 PDD 祖先链,实际点击仍取子节点中心;“免拼购买”独立容器为硬拒绝。独立审计复算证据 hash、验证 focused 32 / full 112 tests 和完整 init 均 PASS。最终真机运行曾安全失败且未发布输出目录;随后只读诊断发现系统通知栏覆盖,app_current 虽仍报告 PDD,但节点树 64 个节点全部属于 SystemUI。下一次必须由人先完全收起通知栏并停在 PDD 首页再运行;T-103 保持 DOING,不把这次失败或入口截图确认误记为完整验收。
### 2026-08-05T01:19:13Z · ila
2026-08-05 T-103 人工复跑与失败阶段诊断收口:
- 人工复跑先因 NotificationShade 覆盖和 USB/Wi-Fi 双 ADB 通道同时在线而安全失败;收敛为仅保留 Wi-Fi ADB、拼多多前台为 HomeActivity 后,仍以 generic failure 安全停止且未发布证据目录。
- 主线提交 `870e5f8` 为真机 CLI 增加只来自固定白名单的 failure stage;异常正文、serial、路径与 XML 均不回显,hostile getter、`str` 子类和 setter 全部失败闭合为 `unknown`,原异常类型、finally 安全调和、单击不重试与失败不发布语义保持不变。
- 独立终审 PASS;T-103 33 tests PASS,client 全量 233 tests PASS,仓库根 `init.ps1` PASS。
- 尚待项目所有者使用新 CLI 再次真机复跑;T-103 保持 done=false / Doing,并继续保留 needs_device 与 needs_human_review 门禁。
### 2026-08-05T02:14:16Z · ila
2026-08-05 T-103 真机复跑与入口稳定性证据:
- 项目所有者复跑返回 `stage=sku_entry`,流程安全停止且未发布本地证据目录。
- root 只读保存本机证据 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-entry-stability-937122477375-20260805-093541`,manifest 位于同目录。证据绑定 PKG110 / Android 16 / 拼多多 8.17.0 / goods_id `937122477375` / 1080×2376;5 份完整 XML 的 hash 与节点数发生变化,但完整、精确的入口动作子树投影连续一致,截图由项目所有者确认是目标商品页。
- 同时确认 SystemUI“肉包采购辅助”浮窗覆盖入口中心,会截获入口点击;因此真机复跑前必须先关闭该浮窗。
- 提交 `a09c58b`:pre-intent 只按完整物理旧链防止误点;动态整页稳定性改为精确入口动作子树连续稳定;任何覆盖入口中心的 live clickable 节点一律拒绝;危险语义和重复入口继续拒绝。入口仍只点击一次且不重试,不包含数量、确认页、提交或支付能力。
- 验证通过:聚焦测试 9/9、T-103 40/40、仓库根 `init.ps1` PASS(client 240 项)。
- `needs_device=true` / `needs_human_review=true`,T-103 继续 Doing;等待项目所有者关闭浮窗、返回拼多多首页并再次真机复跑。
### 2026-08-05T02:15:29Z · ila
更正 comment 105:最新 sku-entry-stability 证据的 screenshot.png 由 root 只读视觉核对为目标商品页;项目所有者尚未对本次证据做最终人工页面对应性验收。该更正不改变 T-103 Doing/needs_device/needs_human_review。
### 2026-08-05T02:33:17Z · ila
2026-08-05 T-103 第二次 sku_entry 复跑与安全子阶段诊断:
- 项目所有者关闭“肉包采购辅助”浮窗、force-stop `com.oplus.games`、仅保留 Wi-Fi ADB,并确认拼多多 `MainFrameActivity` 首页后再次复跑;仍返回 `stage=sku_entry`,安全停止且未发布目录。
- root 只读核对失败后的商品页:拼多多 `NewPageActivity`,`physical=1`、`safe projection=true`、`eligible=1`、`panel=false`;点击中心仅存在唯一 PDD action,连续 3 帧入口动作子树 projection hash 一致。
- 项目所有者返回首页后,root 只读核对 `physical=0`。因此排除当前首页旧链误判、商品入口结构漂移和浮窗覆盖,剩余待区分范围为 click RPC/点击生效与 panel postcondition 两段。
- 提交 `b754cc9` 新增固定、安全且带 Flow provenance 的四个子阶段:`sku_entry_pre_intent`、`sku_entry_discovery`、`sku_entry_click`、`sku_entry_panel_verify`。本次不修改页面判据;入口仍单击且不重试,finally 安全调和、失败不发布和 T-103 能力边界保持不变。
- 验证通过:聚焦 5/5、T-103 43/43、compileall、agent-context、diff-check。依项目所有者“最小验收即停止”的要求,本次未重复完整全量门禁。
- T-103 继续 Doing,保留 `needs_device` / `needs_human_review`;等待更新代码后从拼多多首页再次复跑并反馈新的安全子阶段。
### 2026-08-05T02:57:05Z · ila
2026-08-05 T-103 `sku_entry_panel_verify` 根因取证与入口修正:
- 项目所有者复跑返回 `stage=sku_entry_panel_verify`;流程安全停止且未发布目录。
- root 只读取证目录 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\panel-verify-failure-20260805-103948`。manifest 绑定 PKG110 / Android 16 / 拼多多 8.17.0 / Wi-Fi;截图 SHA-256 为 `4814084b…310eed`,XML SHA-256 为 `bf566a0e…f88e2`。
- 根因:旧实现误点内容区小字 `[900,1312][1056,1355]`,中心 `(978,1333)`。正确入口是底部第一行 leaf `[688,2184][1042,2253]`,中心 `(865,2218)`,直接可点击父节点为 `[446,2166][1080,2328]`;同父不可点击 sibling“免拼购买”为 `[688,2256][856,2305]`。
- 架构边界先由 commit `81a44ca` 更正并推送。代码已修正为只点击底部第一行;旧 header 必须零点击,sibling 只参与结构校验,按钮金额不作为价格来源,入口仍只点击一次且不重试。
- root 独立验收通过:T-103 模块 46/46、compileall、真实证据 selector `entries=1 center=(865,2218)`、agent-context、diff-check。
- T-103 继续 Doing,并保留 `needs_device` / `needs_human_review`;等待项目所有者从拼多多首页再复跑一次。
### 2026-08-05T03:23:09Z · ila
2026-08-05 正确底部入口的实时规格面板新证据:
- 初态目录:`C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-panel-live-20260805-111435`。环境绑定拼多多 8.17.0 / PKG110 / Android 16 / Wi-Fi;screenshot SHA-256 `b06872579f094ac714d7b1b1fc52d489d844707f5a9f62f2b4000e1042f8a997`,XML SHA-256 `24a668e5ba8a2f9b95208ef224cdf548aaee610caf516c5c524a9c8f1fefec5c`。
- 初始 `selected-count=0`;summary 为 `请选择: 颜色分类 尺码`,bounds `[396,731][1053,793]`;目标颜色是唯一 ViewGroup,`desc='黑色 CHA (纯棉)'`,bounds `[372,1188][684,1587]`,`selected=false`。
- 项目所有者人工点击目标颜色后的目录:`C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-color-selected-20260805-111739`。screenshot SHA-256 `1ce89d0d3d4d663e31de467a84472d5cd66b945c7df5ba606592e928431a719f`,XML SHA-256 `e1c31eaef8d6c2af8791a999508909bb4ce6d1445128354107a5a7672c9ba3b7`。
- 点击后目标颜色 parent 为 `selected=true`,summary 为 `请选择: 尺码`;目标尺码尚未出现在节点树中,尺码标签仅位于底部边缘。
- 当前证据否定了旧的“正确入口打开后目标预选、两个维度同屏可见、沿用旧绝对坐标”假设。在取得滚动后的尺码态与全部选中态证据前,不得修改规格选择判据。
- T-103 继续 Doing,并保留 `needs_device` / `needs_human_review`。
### 2026-08-05T03:33:56Z · ila
2026-08-05 五态真机证据补齐与结论纠正:
- 初始未选:C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-panel-live-20260805-111435;screenshot SHA-256 b06872579f094ac714d7b1b1fc52d489d844707f5a9f62f2b4000e1042f8a997;XML SHA-256 24a668e5ba8a2f9b95208ef224cdf548aaee610caf516c5c524a9c8f1fefec5c。
- 目标颜色已选、尺码隐藏:...\sku-color-selected-20260805-111739;screenshot 1ce89d0d3d4d663e31de467a84472d5cd66b945c7df5ba606592e928431a719f;XML e1c31eaef8d6c2af8791a999508909bb4ce6d1445128354107a5a7672c9ba3b7。
- 尺码可见、M 已选:...\sku-size-visible-20260805-112300;screenshot 74acd371444e64f95358c5d2dad2b3aecc7c5531da1b405af574becf19fd0fd7;XML fbbd30f78794d1936d06d613fa6e5989b1d1f29f535743db8207e416aab5a5a3。
- 人工选择非目标 S:...\sku-size-s-selected-20260805-112602;screenshot de9307683128ec2f806e6e1a69b6b6dace757d2b89ea1163e8ec7058d01c33e4;XML 1c79eb2be3b26af53ad68b2fa1c30b51ea320efeabdadf3c3f53a50a6359e6d1。
- 人工恢复目标 M:...\sku-size-m-restored-20260805-112704;screenshot fbab377aa040bca42b386680729b12d518bfc32e1c7efa595dac98fcdb5f5675;XML bc97e2cccfd4463a957fac2e246429755bd557fb95f05b8cc599b84b546a5e36;项目所有者已明确确认“M尺码已恢复”。
五组均为 PKG110 / Android 16 / Wi-Fi / 拼多多 8.17.0。由此否定旧“打开即目标预选”事实并证明 S→M 精确恢复前后态。仍缺唯一动作证据:颜色已选、摘要仍请选择尺码且 M=false/隐藏,经一次受控 reveal 后 M 仍 false 且可见;当前滚动后证据中 M 已经 selected,不能据此猜测通用 swipe。T-103 继续 Doing,保留 needs_device / needs_human_review。
### 2026-08-05T03:36:51Z · ila
补充 comment 111 中省略的完整本机证据路径(只记录路径与 SHA,不上传证据正文):
- C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-size-visible-20260805-112300
- C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-size-s-selected-20260805-112602
- C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-size-m-restored-20260805-112704
其余环境、哈希、人工 M 恢复结论与 comment 111 一致。
### 2026-08-05T03:44:34Z · ila
2026-08-05 reveal 取证方案收口:不改造只读 capture_sku_panel_spike.py,也不把未经验证的滑动放进生产 SkuSelectionFlow。新增独立 capture_sku_reveal_spike.py:无颜色/尺码/坐标/手势参数,只在精确 COLOR_SELECTED_SIZE_HIDDEN 前置成立后沿已取证列间安全通道发出一次固定手势;发出前封存,RPC 正常/超时/异常后均只读调和且绝不重试。后置必须颜色仍选中、摘要仍请选择尺码、S/M 可见且全部 selected=false、未触发提交区;成功后不 Back,由人核对 before/after 后才允许固化生产 reveal 判据。T-103 继续 Doing。
### 2026-08-05T04:33:56Z · ila
2026-08-05 T-103 reveal 离线实现与终审通过:新增独立 `capture_sku_reveal_spike.py`,只在拼多多 8.17.0 / PKG110 / Android 16 / 1080×2376、goods_id `937122477375`、完整 `COLOR_SELECTED_SIZE_HIDDEN` 前置两次重证后,封存并发出唯一一次固定 reveal 手势;RPC 正常或结果不明均只读调和且绝不重试。后置要求目标颜色保持精确 selected、S/M 均可见且 selected=false、摘要仍为“请选择:尺码”、提交硬拒绝区仍在面板下方;成功不 Back,等待人工核对。四态 classifier 已绑定 A/B/S/M 真机证据的完整父链与 exact selected set;未知 clickable 祖先、窄浮层、SystemUI 同 bounds、坏 bounds 均失败关闭。独立终审 PASS;聚焦 55/55、client 全量 255/255、compileall、agent-context、diff-check 全部 PASS。未引用数量、确认页、围栏、提交或支付能力。`needs_device=true`,T-103 保持 Doing,等待项目所有者从拼多多首页、仅 Wi-Fi ADB、使用全新 output-dir 执行唯一一次真机 reveal 并人工确认 before/after。
### 2026-08-05T04:45:40Z · ila
2026-08-05 T-011 方案 B 由 T-103 所有者就地完成 pdd/device 测试提速:仅修改测试。`test_sku_selection` 保持 47 项,无删除/合并/skip,使用生产既有时钟注入缝和 runner 测试工厂,把 4.029s 降至 1.725s(≤2s);`test_sku_evidence_sanitizer` 保持 25 项,确认根因不是时钟而是重复编码 1080×2376 合成 PNG,改为按尺寸缓存同一确定性 PNG bytes,把 5.383s 降至 4.360s。两模块合计 72/72 PASS;与 T-304 所有者的 polling 改动合并后 client 全量仍为 255/255,12.892s(≤15s),compileall/diff-check/agent-context 均通过。未修改生产代码或任何规格/价格/提交安全断言。T-103 仍因真机 reveal 与最终完整链路人工验收保持 Doing。
### 2026-08-05T04:56:30Z · ila
2026-08-05 T-103 reveal 放行前独立审计返修:实现未发现生产缺陷,但原测试未永久覆盖验收条目中的四类后置失败。仅修改 client/tests/pdd/test_sku_reveal_spike.py,新增端到端反例:swipe 后页面不变/尺码仍隐藏、目标颜色 selected 漂移、提交硬拒绝区文本缺失/重复/上移、RPC 结果不明且页面不变。每例均断言 swipe 恰好一次且不重试、target/manifest 不发布、staging 无残留。受托 agent 聚焦 59/59 PASS;主 agent 独立逐行审查并复跑 client 全量 259/259(13.883s)、compileall、agent-context、diff-check 全部 PASS。未改生产代码,未引入数量、确认页、围栏、提交或支付能力。T-103 继续保持 Doing;可在该修复提交推送后放行项目所有者执行唯一一次真机 reveal。
### 2026-08-05T06:19:03Z · ila
2026-08-05 T-103 第二次真机失败诊断:项目所有者确认从拼多多首页运行后已进入目标商品并弹出规格面板,但面板保留此前人工选择的“白色 + 其他尺码”,脚本没有选择“黑色CHA(纯棉)”或目标尺码。按调用链,入口后置只接受 PANEL_OPEN_EMPTY;非目标规格使 `sku_entry_panel_verify` 失败,尚未进入 `select_sku_options`,因此 zero target-color click / zero reveal swipe / zero submit。为避免统一错误再次丢失控制流事实,仅修改 capture_sku_reveal_spike.py 复用既有 safe_failure_stage,输出白名单 stage 或 unknown;异常正文、页面文本、serial、XML、路径仍不回显,伪造 stage 无正式 marker 时降级 unknown。独立审计与主审均确认未改 Capturer/Flow/判据/动作;聚焦 60/60、client 全量 260/260(14.788s)、compileall、agent-context、diff-check PASS。T-103 继续 Doing,下一步先由人把该商品规格面板恢复为颜色/尺码均未选,再从首页执行全新 output-dir。
### 2026-08-05T06:50:08Z · ila
2026-08-05 T-103 同版本单价格节点变体真机证据与实施方案(先证据、后判据):第一次正式 reveal 在 `sku_entry_panel_verify` fail closed。项目所有者已确认 force-stop 后颜色/尺码恢复未选;root 只读检查证明 PDD 8.17.0/PKG110/Android16/1080×2376/前台正确、summary=`请选择: 颜色分类 尺码`、selected-count=0、目标颜色 bounds `[372,1188][684,1587]` selected=false。证据目录 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-panel-empty-current-only-20260805-143741`,screenshot SHA-256 `7f3143d7fab42b41a088e27a4b2d9f8dbdf31b5ecc9ba02b148ca021304026f6`,XML SHA-256 `d3dbf217b4567069feccb30ab43124c5fa7da3734659ca5452b98c2e0a7ff158`。根因不是 selected,而是价格行由旧双节点变为 exact 单节点:current 仍为 `限1件 ¥12.88 ` `[396,580][675,647]`,其唯一 LinearLayout 为 `[396,575][693,647]` 且仅一 child,外层 FrameLayout `[396,575][1053,647]`;`券前¥29.88` count=0。项目所有者随后在同一 goods_id 937122477375 面板人工选择目标颜色并明确完成第二态确认;目录 `...\sku-panel-color-only-current-only-20260805-144550`,screenshot `931dc2d31f4e34872fdd97879abd076e414b6494569e8a587353266011610545`,XML `c686737942e5fdcebc76c2bc1d5cd9ae90a45f4a921297119ddaa4313758a034`;summary=`请选择: 尺码`,目标颜色 selected=true,未选尺码,current count=1/original count=0,固定 reveal 通道用现有代码只读验证 PASS,未发送 swipe。实施只允许新增两个 exact 单价格前置 spec(PANEL_OPEN_EMPTY / COLOR_SELECTED_SIZE_HIDDEN),旧双价格 spec 继续保留;不得把 original 泛化为 optional、不得接受任意 row/bounds/child count/price text,也不得修改 rolled candidate、数量、确认页、提交或支付。正式再跑前新增带正式 marker 的 reveal 白名单阶段码,至少能明确 `reveal_not_attempted` 与 `reveal_attempted/candidate/after/publish` 边界,异常正文和页面内容仍不外泄;补 cross-variant/duplicate/unknown/伪造 stage 失败测试。T-103 继续 Doing,先提交此证据计划,再实现并独立主审。
### 2026-08-05T07:19:02Z · ila
2026-08-05 第二态由用户人工确认:目标颜色“黑色 CHA(纯棉)”已选,尺码选项已显示且尚未选尺码。实现已按两组新 raw 证据收紧:新增 PANEL_OPEN_EMPTY / COLOR_SELECTED_SIZE_HIDDEN 的 exact current-only 价格布局;历史 dual 布局继续按已取证嵌套后代关系匹配;颜色点击后的后置条件绑定点击前完整 price_layout,禁止 dual 与 current-only 双向漂移。reveal 失败阶段新增不可伪造 marker,precondition 保证零 swipe,attempted 及以后仅在唯一 RPC 机会已封存后报告。两名独立 agent 审计均 PASS;根代理直接读取六组真实 raw 验证唯一命中:dual empty/color/S/M 与 current-only empty/color 均正确,M 读价 12.88。根代理完整门禁:client unittest 269/269 PASS,compileall PASS,validate_agent_context PASS,git diff --check PASS。未新增数量、确认页、围栏、提交订单或付款能力。T-103 仍保持 DOING,下一步仅允许强停重置后运行一次真机 reveal,并等待人工验收。
### 2026-08-05T07:23:56Z · ila
2026-08-05 15:23(Asia/Shanghai)唯一一次真机 reveal 运行结果:从拼多多 MainFrameActivity 首页、显式 Wi-Fi serial 192.168.0.173:5555、全新 output-dir `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-reveal-current-only-20260805-152300` 启动;CLI 返回 `stage=reveal_precondition`,exit 1,未发布目录。按正式 marker,reveal swipe 未发生;禁止自动重跑。随后准备以 `capture_device_baseline.py` 对保持不动的页面做只读取证,但 `adb devices` 同时出现 Wi-Fi `192.168.0.173:5555` 与 USB `3B65BD02H7F00000`,基线脚本按同一物理手机多通道门禁拒绝,未采集。等待项目所有者拔掉 USB,仅保留 Wi-Fi;手机保持失败后的当前页面。之后只做一次只读截图/XML,不重跑 reveal,再根据首个真实漂移决定最小修复。T-103 保持 DOING。
### 2026-08-05T07:29:22Z · ila
2026-08-05 15:26-15:27(Asia/Shanghai)T-103 唯一真实 reveal 动作与失败后只读取证:仅 Wi-Fi ADB,拼多多 MainFrameActivity 首页,显式 goods_id 937122477375,全新 output-dir `...\sku-reveal-current-only-20260805-152636`。App 版本在本次运行重新检查为 8.17.0。脚本在 47 秒后返回 `stage=reveal_candidate`,证明唯一 reveal RPC 已发出;未发布 staging,未重试。手机保持不动后用只读 baseline 采集到 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\reveal-candidate-failure-20260805-152636`:screenshot SHA-256 `5c86a922b1064c1a3bf175182340703ad0a78c6f96d9ccee25ffbee336afdf68`,XML SHA-256 `840ef3e59aa3f19c5c9521d92ff5af769c9e19d348a68b79cce74e83e5f38295`。只读代码诊断 `_candidate_projection` 的首个错误为“候选价格行不唯一”。新 raw 显示目标颜色 exact selected=true,摘要 `请选择: 尺码`,S/M 可见且 selected=false,提交文字仍在 `[285,2225][795,2284]` 的硬拒绝区;滚动态价格是独立 exact current-only 变体:Frame `[396,498][1053,570]` 唯一 child,LinearLayout `[396,498][693,570]` 唯一 child,current=`限1件 ¥12.88 ` `[396,503][675,570]`,`¥29.88` count=0。旧 rolled dual 证据保持不变,不得把 original 泛化为 optional。等待项目所有者对当前手机画面确认:目标黑色仍选、S/M 均可见且均未选、仍在规格面板而非确认页。确认前不改判据、不再操作手机;T-103 保持 DOING。
### 2026-08-05T07:31:25Z · ila
2026-08-05 项目所有者完成人工确认:真实 reveal 后目标颜色已选中,未选中任何尺码。结合失败后保持不动采集的截图/XML,S/M 已显示,页面仍是规格面板而非订单确认页;因此唯一固定 reveal 动作已证明不会误选尺码,也未触发提交区。实施方案收紧为:只在独立 spike 的 rolled candidate 中新增与 raw 完全一致的 current-only 价格布局(Frame `[396,498][1053,570]` → 唯一 row `[396,498][693,570]` → 唯一 current `限1件 ¥12.88 ` `[396,503][675,570]`,原价 count=0);旧 dual candidate `快卖完 ¥12.88` + `¥29.88` 原样保留。两种布局必须分别完整唯一匹配,禁止 optional original、跨布局拼装、未知文字/坐标/父链/child count;候选其余颜色、摘要、S/M 未选、提交硬拒绝区判据不变。补 dual/current-only 双向混合、duplicate、unknown、parent drift 失败测试;不得改生产 Flow 的 reveal,不得新增数量、确认页、围栏、提交或支付能力。先提交本计划投影,再由唯一 client 写入者实现、独立审计、根代理重跑真实 raw 与完整客户端门禁。T-103 保持 DOING;代码通过后是否需要再次运行 spike 以形成原子 before/after 发布证据,仍以任务验收全集为准。
### 2026-08-05T07:53:16Z · ila
2026-08-05 T-103 rolled current-only candidate 实现与双轮审计完成。唯一 client 写入者只修改 `sku_reveal_spike.py` 与对应测试:candidate 现在绑定两份真实 raw 共有的 exact header→Frame→Relative→ViewGroup→price Frame 链、各层 child count、summary direct parent;旧 dual row/current/original 与新 current-only row/current 是完整互斥 variant,原价绝非 optional,双向 complete/partial/hybrid/duplicate/unknown/parent drift 和已知 `¥29.88`/`券前¥29.88` 残片全部拒绝。首轮审计发现并退回两个 P1(current-only 任意 descendant、dual+current 共存仍通过);返修后根代理及独立审计均复现为 REJECT。真实 current-only after raw projection PASS(14 roles);历史 dual M raw 仅内存机械恢复未选尺码态后 PASS(14 roles)。截图后 current-only→dual 漂移永久测试证明 `stage=reveal_after`、唯一 swipe=1、target/staging 均不发布。根代理完整门禁:client unittest 274/274 PASS,compileall PASS,agent-context PASS,git diff-check PASS;生产新增行只有 clickability 只读检查,无新 tap/swipe/数量/确认页/授权/围栏/提交或支付能力。T-103 仍保持 DOING;下一步需按验收全集形成可发布的原子 before/after spike 证据并由人终验,代码提交后不得复用旧 output-dir。
### 2026-08-05T07:56:33Z · ila
2026-08-05 15:55(Asia/Shanghai)T-103 修复后原子 reveal 证据成功发布。前置:仅 Wi-Fi ADB、从拼多多 MainFrameActivity 首页、force-stop 清除持久选择、脚本本次重新核验 PDD 8.17.0 / PKG110 / Android 16 / 1080×2376。命令使用全新目录 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-reveal-accepted-20260805-155502`,33.5 秒完成;manifest `rpc_outcome=completed`、`reveal_attempts=1`、`candidate_status=human_review_required`。哈希:before screenshot `01d3c5a3c39635389d5ff5ec86809fd10dae897d8bc39c6aa5840da8b25f6e60`,before XML `8e318edf578e404d1e2de317d3c206cb7c481378e0b6b60927047d033b867adc`,after screenshot `fa3024167aaf7d5719b0916de8f22c27b83a0b200bc38c750af410ed85cac7d1`,after XML `539e76856c175a973f81a0e879b8d5c4103767a7464858f7a0977d6ffa47ce0f`。根代理只读重证:before exact COLOR_SELECTED_SIZE_HIDDEN + 固定安全通道 PASS;after candidate projection 14 roles PASS,目标颜色 selected 子树唯一,S 与 M 均可见且 selected=false,未发布外部证据/未 Back。截图显示仍为规格面板,提交硬拒绝区在底部;等待项目所有者查看手机/after screenshot 后最终人工确认“目标颜色保持选中、S/M 均未选、未进入订单确认页”。确认前 T-103 保持 DOING,不推进生产 reveal 或后续规格点击。
### 2026-08-05T08:09:05Z · ila
2026-08-05 项目所有者对第二次原子 reveal 证据完成三项人工终验并明确回复“是的,3个确认”:1) 目标黑色保持选中;2) 未选中任何尺码;3) 仍在规格面板,未进入订单确认页。证据目录 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-reveal-human-review-20260805-160655`;manifest 为 Wi-Fi / PDD 8.17.0 / PKG110 / Android 16 / goods_id 937122477375 / `reveal_attempts=1` / `rpc_outcome=completed`。哈希:before screenshot `7f1fb74ce91d67054deb462a02f4306128889ec7d04194ba2fb07bb90b664169`,before XML `314257333a50cd3be3a9e883d5a33e5b7da763224210f350b95c6593be7cd710`,after screenshot `e9b628821d1e39b8b501efd21a88e43a5f8e6eda9332c44159c38b0a8a5eb38a`,after XML `e17e0a789f53b33fa564b678511ba633bfb969861fc32417b81e5545e309e9a1`。根代理机器重证 before color-only+safe-path PASS,after projection 14 roles PASS,S/M selected=false。reveal 动作证据门禁现已满足。T-103 仍保持 DOING,因为验收全集还要求将已证固定 reveal 收入生产 Flow,并由人完成入口→目标颜色→固定 reveal→目标 M→读取 12.88→一次安全 Back 的最终真机闭环;不得把本次三项确认误当成整个 T-103 DONE。下一步先做只读设计审计与方案记录,再编码;不新增数量、确认页、提交或支付能力。
### 2026-08-05T08:20:30Z · ila
2026-08-05 16:14(Asia/Shanghai)T-103 目标 M 真实后置证据与生产 Flow 实施计划:
项目所有者在原子 reveal 人审通过后的当前规格面板中,手工点击了 exact M(M(建议100-115))。root 随即只读采集全新证据目录 C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-target-m-current-only-20260805-161434;manifest 绑定 Wi-Fi / 拼多多 8.17.0 / PKG110 / Android 16 / goods_id 937122477375 / 1080×2376。screenshot SHA-256=74e006b25c8618f0b283ff2472ef331e9628306877088b760c103f15f629e1e7,XML SHA-256=6887322d2ba9309e82442aa57b9a4ac4540f2850f6931a2631edbf899138aad1。现有代码只读重证:唯一命中 TARGETS_SELECTED / _DualPriceLayout;仅 exact M selected=true,目标颜色保持 selected=true;_unit_price 精确返回十进制字符串 12.88。截图显示仍为规格面板。
该证据补齐的唯一获准真实转移是:CURRENT_ONLY COLOR_SELECTED_SIZE_HIDDEN → 固定无参 reveal 一次 → CURRENT_ONLY SIZE_VISIBLE_UNSELECTED(S/M 均 false)→ exact M 一次 → 既有 DUAL TARGETS_SELECTED → 既有价格节点读取 12.88。不得泛化为任意跨 variant;DUAL color-only 在 reveal 前必须零动作失败;不得新增 current-only TARGETS_SELECTED、不得把原价改为 optional、不得修改既有 target classifier 或读价语义。
生产化方案经两名只读 agent 独立审计后由 root 收敛:
1. sku_selection.py 成为 profile/revealed-unselected 判据唯一权威,新增 current-only 可见未选尺码窄状态和显式转移白名单;从 spike 提取共用纯判据,生产模块不得 import spike。
2. Protocol 只新增无参数 reveal_size_options_once;绝不公开 swipe/scroll/坐标。Flow 在 RPC 前封存 pending/attempt,固定 reveal 最多一次;fresh hierarchy 连续两帧稳定证明 target color=true、summary=请选择尺码、S/M exact 可见且均 false、硬拒绝区未漂移后,才允许 exact M 一次。M 后继续复用既有 _require_target_panel 与 _unit_price。
3. sku_selection_runner.py 的 adapter 固定唯一 reveal 手势并在 RPC 前封存。RPC timeout/结果不明只读调和,resolved 或 unresolved 均不重发;无法证明后置时进入终止态,不得继续 M、读价、Back 或发布。正常完整链才允许 Back 一次。
4. sku_reveal_spike.py 删除重复手势/候选判据并单向复用生产 matcher;改造后不得因 Flow 已生产化而重复 reveal。
5. T-103 退出证据只记录 back_attempts/RPC outcome 和 post_exit_status=human_review_required;“面板消失”不得自动写 safe_exit=completed。同 goods 商品页自动 post-exit 判据仍留给 T-104。
6. 补正反例:dual color-only 零 reveal、variant/hybrid/父链/child-count/summary/selected/硬拒绝区漂移、exact M 缺失/重复/禁用/近似/已有其他尺码、reveal/M delivered-timeout 与 undelivered-timeout、重入二次调用零 RPC、截图后漂移、Back 不明、原子发布竞态、静态 API/AST 不可达数量/确认页/围栏/提交/支付。
实现仅限 T-103 write_paths,任务继续 DOING。代码与完整 client 门禁通过后,必须再从首页、单 Wi-Fi、全新 output-dir 跑一次生产 runner,并由项目所有者人工核对:入口/颜色/reveal/M 各最多一次、仅 M 选中、规格面板价 12.88、Back 一次后仍为同 goods 商品详情页且未进入确认/提交/支付页。
### 2026-08-05T08:55:34Z · ila
2026-08-05 T-103 生产 Flow 离线实现与双重终审完成,提交 60cf05b 已推送 main。
唯一 client 写入者仅修改 5 个 T-103 文件:sku_selection.py、sku_selection_runner.py、sku_reveal_spike.py 及两份对应测试;未操作真机,未修改共享文档或用户文件。
实现结果:
- 生产只放行同一 Flow 的 current-only empty → exact 目标颜色一次 → 固定无参 reveal 一次 → 连续两帧稳定的 current-only S/M 均未选 → exact M 一次 → 复用既有 dual TARGETS_SELECTED / 12.88。dual color-only、非本 Flow 已选颜色和 fresh revealed-unselected 均在后续 mutation 前零动作失败;已验 S=true→exact M 恢复路径保留。
- sku_selection.py 成为 safe path/revealed-unselected 唯一判据;spike 删除重复手势和 300+ 行候选 matcher,单向复用生产判据与正式 adapter,且不调用完整选择流程,避免双 reveal/M。
- reveal 手势只有一份不可变 tuple;Protocol 仅新增无参数命名动作,不暴露 swipe/scroll/坐标。entry/option/reveal/Back 均在 RPC 前封存,结果不明只读调和;Flow terminal 防重入,失败 finally 不再自动 Back。
- reveal 后提交硬拒绝区要求全局所有 PDD TextView 中包含“提交订单”的集合恰好等于唯一 exact 底部证据节点;额外、上移、改文案、混合价格/父链/child-count/selected 漂移均失败关闭。
- 每个 S/reveal/M mutation 前 fresh 重查版本、前台和完整 hierarchy。截图后重证目标规格/12.88;Back 前再用 fresh tree 通过既有 dual TARGETS_SELECTED 与 _unit_price,才封存并发送一次 Back。
- 发布前强制 entry/color/reveal/M/Back 各 exactly 1 且 RPC outcome=completed。manifest 移除 safe_exit=completed,记录五个命名 actions 的 attempts/outcome,以及 post_exit_status=human_review_required;T-104 前不自动宣称同商品安全退出。
- 调用链未引入数量、确认页、授权、围栏、提交订单或支付能力。
审查与验证:
- 实现 agent 首轮后,独立审计先后发现并退回:S→M 回归、手势双常量、动作审计不足、提交硬拒绝全局唯一缺口;root 另退回 Back 前 target/price 不够精确、mutation 前台未 fresh 重证、manifest 缺五类动作明细。全部补测并修复。
- 独立终审最终 PASS,无 P1/P2;额外注入上移/改文案“提交订单”节点均 REJECT。
- root 使用 client/.venv 独立复跑:聚焦 84/84 PASS;client 全量 284/284 PASS,13.434 秒(≤15 秒);compileall(src/tests/scripts) PASS;wheel build 与 requirements metadata PASS;validate_agent_context PASS;git diff-check PASS。测试提速只缓存不可变的同一白色 PNG Base64,未删/合并/skip 测试。
T-103 仍保持 DOING。下一步必须从拼多多首页、仅 Wi-Fi ADB、全新 output-dir 运行生产 run_t103_sku_selection.py,并由项目所有者人工核对:目标颜色与 M、规格面板价 12.88、五类动作均一次、Back 后仍为 goods_id 937122477375 商品详情且未进入确认/提交/支付页。
### 2026-08-05T08:59:04Z · ila
2026-08-05 16:57(Asia/Shanghai)T-103 生产 runner 最终真机闭环已单次执行成功,等待项目所有者最终人工确认;任务继续 DOING。
运行前只读门禁:adb devices 仅有 Wi-Fi serial 192.168.0.173:5555;本次重新读取拼多多 versionName=8.17.0;force-stop 清除手工 M 状态后显式启动 MainFrameActivity,最终前台为 HomeActivity。随后从首页以 canonical goods_id 937122477375、任务颜色“黑色CHA(纯棉)”、尺码“M(建议100-115)”、全新目录执行 production run_t103_sku_selection.py,一次运行 53.9 秒、exit 0;未重跑。
生产证据目录:C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-selection-production-20260805-final。runner 返回目标规格正确、规格面板单价 12.88。manifest 的五类 actions 全部为 attempts=1 / rpc_outcome=completed:sku_entry、target_color、size_reveal、target_size、back;channel=wifi、PDD 8.17.0、PKG110、Android16、unit_price=12.88、post_exit_status=human_review_required。Back 前 screenshot SHA-256=c0f6a4225c1a59195672ea6ff45a360f33886571c9566b6b6b66c4d7a59abb53,复算一致。root 只读查看该截图:规格面板仍在,已选摘要显示目标黑色与 M,M 有唯一红色选中框,顶部规格面板现价 12.88;底部“提交订单 ¥12.88”仅可见,未点击。
Back 后不再进行任何 mutation,只用 baseline 只读采集:C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-selection-after-back-20260805-final。前台为 com.xunmeng.pinduoduo/.activity.NewPageActivity;screenshot SHA-256=477fdf4202a6cef9d615bd37defd473b0e18fc6312469b1061b45fb8c00034f4,XML SHA-256=1aefe2b73ec27aa32c00b601c74f208b69675c8807ac91ce28e6997a75624da2,均与 manifest 一致。root 只读查看截图:已返回商品详情页,商品主图/标题与 goods_id 937122477375 证据一致,底部仍是“快要抢光 ¥12.88 / 免拼购买”,没有进入订单确认、提交结果或支付页。
按 needs_device=true 规则,以上 agent/机器检查不能替代人验收。等待项目所有者在手机或两张本地截图上明确确认三项:1) Back 前目标颜色与 M 正确且规格面板价为 12.88;2) manifest 五类动作各一次;3) Back 后仍是该商品详情页,未进入确认/提交/支付页。确认前不得将 T-103 标 DONE。
### 2026-08-05T09:00:34Z · ila
2026-08-05 项目所有者完成 T-103 最终人工真机验收并明确回复“三项确认”:
1. Back 前目标颜色与 M 正确,规格面板当前价为 12.88;
2. manifest 中 sku_entry、target_color、size_reveal、target_size、back 五类动作均为 attempts=1 / rpc_outcome=completed;
3. Back 后仍为 goods_id 937122477375 商品详情页,未进入订单确认、提交结果或支付页。
至此 T-103 的验收要点全集全部满足:已取证页面判据、离线 284/284 与构建/元数据/上下文门禁、生产单次真机闭环和人类最终确认均具备。系统未设置数量、未进入确认页、未提交订单、未创建待付款订单、未触及任何付款能力。现将线上 done 与本地 git status 同步为 DONE;T-104 依赖解除。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
- 任何规格面板入口、维度容器、选项、选中态和价格判据都必须来自本项目拼多多 8.17.0 真机证据; - 任何规格面板入口、维度容器、选项、选中态和价格判据都必须来自本项目拼多多 8.17.0 真机证据;
T-102 详情页证据只能证明已到目标商品,不能证明规格面板结构。T-103 只使用已由人确认无实际地址/ T-102 详情页证据只能证明已到目标商品,不能证明规格面板结构。旧 v5 派生 XML 只能作为历史证据,
手机号的 v5 派生 XML 提取最小 fixture;派生截图的上传适用性不再是本任务的代码门禁。 不得覆盖 2026-08-05 已确认的未选初态、仅目标颜色态、S 非目标态与恢复 M 态。只允许从这五组本机
证据提取不含地址、手机号或支付凭据的最小 fixture;原始截图和完整 XML 不提交 Git。
- `SkuSelectionFlow` 只允许 T-110 批准的 `open_sku_panel()` 窄能力点击证据/版本绑定、精确唯一的 - `SkuSelectionFlow` 只允许 T-110 批准的 `open_sku_panel()` 窄能力点击证据/版本绑定、精确唯一的
`快要抢光`;当前事实仅覆盖 goods_id `937122477375`、拼多多 `8.17.0`。不得把“免拼购买 / 底部购买区第一行 `快要抢光 + 金额` 文本叶节点中心;当前事实仅覆盖 goods_id `937122477375`、
单独购买 / 直接拼成”等其他文案加入包含、前缀、同义或坐标兜底,分别取证前一律拒绝。 拼多多 `8.17.0` 与 1080×2376。该叶节点必须是证据绑定底部唯一可点击 PDD 容器的直接子节点;
- 规格选项只能在已确认的面板及对应维度容器内按文本精确唯一匹配;缺失、重复、禁用、维度不明、 商品内容区同名小字不得作为入口。金额后缀只参与入口结构识别,不得返回、解析或用作任一道价格
选中态无法唯一读回或页面版本不符均 fail closed。不得前缀、包含、模糊、相似或跨维度匹配, 闸门的价格来源。
不得用 OCR / 坐标兜底猜选项。 - 底部第二行 `免拼购买` 只允许作为证据绑定、不可点击的精确兄弟节点证明当前结构;不得把它或
- 单价只允许从规格面板证据确认的节点读取,并以十进制字符串表达;不得使用浮点,不得从详情页、 `单独购买 / 直接拼成` 等其他文案加入选择器、点击目标、包含/前缀/同义或坐标兜底。`免拼购买`
搜索卡片或其他页面的数字补值。券后价、原价、区间价、货币符号拆分或多个候选的语义无法唯一 缺失、漂移、变为可点击或出现在其他结构时一律零点击失败关闭。
证明时必须返回 unreadable 并转人工。 - 面板状态必须按完整证据 profile 匹配:未选初态、仅目标颜色且尺码隐藏态、目标颜色 + S 态、
目标颜色 + M 态不得跨状态拼装价格、摘要、容器、节点类型或坐标。真实初态不得要求尺码 option
已经可见,也不得要求任一维度已有唯一 selected。
- 规格选项只能在已确认的面板及对应维度容器内按文本精确唯一匹配;颜色 action 是精确
content-desc 的可点击 ViewGroup,当前 S/M 尺码 action 是精确文本的可点击 TextView。缺失、重复、
禁用、维度不明、选中态无法唯一读回或页面版本不符均 fail closed。不得前缀、包含、模糊、相似、
跨维度或 OCR 匹配,也不得用裸坐标猜选项。
- 显示尺码只允许私有命名的 `reveal_size_options()`:仅从目标颜色已选、尺码未选且隐藏的已证明状态
执行,绑定已取证的嵌套滚动容器与列间安全通道,最多一次且不重试。生产动作落地前必须先取得
“reveal 前后 M 均未选、尺码由隐藏变为可见、提交区未触发”的真机证据;不得暴露通用
`swipe` / `scroll` / 任意坐标能力。
- 生产 reveal 落地前只允许独立的 `capture_sku_reveal_spike.py` 证据脚本执行该固定无参动作:脚本必须
自动走受控商品/面板/目标颜色前置,严格命中仅颜色态后才发出一次固定手势;RPC 正常或不明后都只读
调和,M 已选、尺码仍隐藏、摘要/颜色/版本漂移或提交区风险均不得发布。成功后不 Back,等待人核对。
- 单价只允许在目标颜色与目标 M 均精确读回后,从滚动态规格面板证据确认的只读当前价节点读取,
并以十进制字符串表达;不得使用浮点,不得从未选初态、详情页、搜索卡片、底部购买/提交按钮或
其他页面的数字补值。未知促销前缀、券后价、原价、区间价、货币符号拆分、可点击祖先或多个候选
无法唯一证明时必须返回 unreadable 并转人工。
- T-103 代码路径不得引用或实现 `set_quantity_and_readback()`、`go_to_order_confirm()`、 - T-103 代码路径不得引用或实现 `set_quantity_and_readback()`、`go_to_order_confirm()`、
`create_submission_fence()`、`submit_order_once()`、 `create_submission_fence()`、`submit_order_once()`、
通用 `click`、创建订单、授权、提交围栏或任何支付能力;不得进入订单确认页,不得创建待付款订单。 公开通用 `click` / `swipe` / `scroll`、创建订单、授权、提交围栏或任何支付能力;不得进入订单确认页,
不得创建待付款订单。
规格面板中的“提交订单”、微信支付、先用后付、0 元下单只可作为硬拒绝判据,不能返回可点击对象。 规格面板中的“提交订单”、微信支付、先用后付、0 元下单只可作为硬拒绝判据,不能返回可点击对象。
- 不把 `/mnt/d/chengma/cmroubao`、`/mnt/d/chengma/cmpdd` 或任何旧版本的节点、Activity、选择器、 - 不把 `/mnt/d/chengma/cmroubao`、`/mnt/d/chengma/cmpdd` 或任何旧版本的节点、Activity、选择器、
坐标、文本形态和页面结论当作事实;前序项目只可用于理解为什么必须 fail closed。 坐标、文本形态和页面结论当作事实;前序项目只可用于理解为什么必须 fail closed。
@@ -210,5 +427,7 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
- T-103 不再扩展截图遮罩器;本任务先以本机原始截图完成真机验收。生产单趟原始截图上传由 T-204 - T-103 不再扩展截图遮罩器;本任务先以本机原始截图完成真机验收。生产单趟原始截图上传由 T-204
接入,允许截图包含页面已显示的地址和手机号,但只允许已认证设备上传、已登录管理员查看;完整 XML、 接入,允许截图包含页面已显示的地址和手机号,但只允许已认证设备上传、已登录管理员查看;完整 XML、
外部支付页和支付凭据不得上传。 外部支付页和支付凭据不得上传。
- 一次 Back 在调用前即封存且不得重试;仅“面板消失”不得自动宣称生产安全退出成功。同一目标商品
详情页的严格 post-exit 判据由 T-104 取得独立真机证据后开放。
- `needs_device: true`:agent 只能完成离线实现并保持 `DOING`;只有人完成安全入口、精确规格选择、 - `needs_device: true`:agent 只能完成离线实现并保持 `DOING`;只有人完成安全入口、精确规格选择、
SKU 单价语义、页面对应性与安全退出验收后才能标 `DONE`。HTTP 截图上传实现不属于 T-103 验收。 SKU 单价语义、页面对应性与安全退出验收后才能标 `DONE`。HTTP 截图上传实现不属于 T-103 验收。
+113 -32
View File
@@ -3,10 +3,10 @@ id: T-104
title: 验证规格选择能力安全退出 title: 验证规格选择能力安全退出
phase: 1 phase: 1
deps: [T-103] deps: [T-103]
status: TODO status: DONE
created: 2026-08-04 created: 2026-08-04
vikunja_task_id: 32 vikunja_task_id: 32
context_ref: 9a4d11f context_ref: 29fb528
work_branch: task/t-104-safe-exit work_branch: task/t-104-safe-exit
needs_device: true needs_device: true
needs_human_review: true needs_human_review: true
@@ -21,64 +21,145 @@ write_paths:
- docs/current-state.md - 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-05T09:57:21Z sha256=8469785af6c7ffd0d42dd7f2bb56bad15996a1f696c854823074304739bf652e -->
## 问题 / 背景 ## 问题 / 背景
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 单趟流程中的安全退出边界。 - 功能:F-006 单趟流程中的安全退出边界。
- 架构/API:复用 `SkuSelectionFlow.exit_sku_panel_safely()`;不改变服务端 API。 - 架构/API:复用并收紧 `SkuSelectionFlow.exit_sku_panel_safely()` 与现有 runner;不改变服务端 API,不新建第二套采购流程。
- 依赖:T-103 完成真机规格选择/读价/人眼安全退出验收后才开工。 - 依赖:T-103 完成真机规格选择、读价、原始截图和人工安全退出验收后才可开工;T-105 与 T-306 消费本任务结果。
- `status` 保持 `TODO`,`needs_device` 与 `needs_human_review` 均保持 true;开始后也必须由人完成真机验收。
## 方案 ## 方案
1. 先新增窄取证脚本:只在已验证 PDD 8.17.0 目标规格面板上发送一次 Back,随后采集本机截图/XML/App/设备/goods_id 元数据;不得打开确认页、设置数量或点击任何页面控件。 1. 阶段 A 只新增窄取证脚本。人先把手机停在已验证的拼多多 8.17.0、goods_id `937122477375` 目标规格面板;脚本必须先在内存中核验版本、PDD 前台、已知面板结构、目标颜色/尺码选中态与已取证当前价,任一不符时零动作、零产物。
2. 由人确认 post-exit 截图确为 goods_id `937122477375` 商品详情、规格面板已关闭、未进入确认页/外部支付页,并确认截图/XML 对应;`needs_device=true`,agent 保持 DOING。 2. 阶段 A 唯一状态改变是一次命名 Android Back。脚本不得发送商品 intent,不得调用任何页面 `click`,不得点击关闭坐标、空白、购买、数量、确认、提交或支付控件;Back 调用前即封存本次机会,超时、响应不明或故障调和均不得重试。
3. 从本项目 post-exit XML 提取只含判据节点的最小 fixture。成功判据必须稳定命中该商品详情的证据绑定正结构;可复用 T-103 的“快要抢光”五层入口链,但必须由新的 post-exit 证据再次验证。 3. Back 返回后只读采集 post-exit screenshot/XML/App/设备/goods_id 摘要并原子发布本机证据。此阶段 manifest 只能声明 `human_review_required` 与 `back_attempts=1`,人工确认前绝不能写 `safe_exit=completed`,也不得由 agent 根据“面板消失”自行判定成功。
4. 收紧 `exit_sku_panel_safely()`:版本和前台包正确、节点树仅含 PDD 受控页面、目标商品详情正判据精确唯一并连续稳定;面板仍在、SystemUI/锁屏、PDD 其他页、确认/提交/支付语义、入口缺失/重复或结构漂移均不得记成功。 4. 人必须确认 post-exit 截图确为同一 goods_id `937122477375` 商品详情、规格面板已关闭、未进入确认/提交/支付或外部支付页、未创建订单,且截图/XML 对应。只有完成该确认后,阶段 B 才能从本项目 post-exit XML 提取不含个人信息或支付凭据的最小 `product_exit_8_17_0.xml` fixture。
5. Back 最多发送一次。超时或响应不明不重试;故障调和也不能产生第二次 Back。失败不得发布 `safe_exit=completed` manifest。 5. T-103 的旧 `product_entry_8_17_0.xml` 只有通用“快要抢光”五层入口链和“免拼购买”,不能单独证明是同一商品。它最多作为新证据中的候选结构,必须由本轮 post-exit 证据重新验证,并与新证据里的同商品唯一正锚共同成立;不得把 Activity、旧 XML、前序项目、OCR、裸坐标、相似文本或其他购买文案当作同商品证明。
6. 复用 T-103 runner/flow,不新建第二套采购流程;可增加 post-exit 本机原始截图和无页面正文摘要,完整 XML 仍只留本机,不上传、Git 或日志。 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;只有人能完成页面对应性与无订单创建确认。 - 阶段 A 证据记录 screenshot/XML 路径与 SHA-256、PKG110、Android 16、Wi-Fi/USB、拼多多 8.17.0、goods_id 和 `back_attempts=1`;人工确认前没有 `safe_exit=completed`。
- 离线测试覆盖成功稳定详情页,以及面板未退、SystemUI/锁屏、PDD 其他页、确认/提交/支付页、入口缺失/重复/漂移、版本/前台漂移。 - 用新的 `product_exit_8_17_0.xml` 正例替换旧“空 hierarchy 即成功”的 fake;空 hierarchy 必须失败。成功需连续两次正判据,单次命中后漂移必须重置。
- 所有失败分支 Back 总数最多 1;超时不重试;失败不发布 completed manifest。 - 离线覆盖面板未退、SystemUI/锁屏、非 PDD overlay、PDD 其他页、正锚缺失/重复/漂移、版本/前台漂移和保守危险语义反例;所有分支 Back 调用总数小于等于 1,失败无 target、manifest 或 staging 残留。
- 静态证明没有数量、确认页导航、提交订单、围栏或支付能力。 - runner 成功结果的 `screenshot_path` 精确指向规格面板原始 PNG,`captured_at` 是 UTC-aware 且与 manifest 同值;post-exit 图不能冒充 Gate1。
- client 全量单测、compileall、完整 init、上下文校验与 diff-check 通过。 - 静态 API、AST 和导入检查证明取证脚本零 intent/页面 click,T-104 不可达数量、确认页导航、授权、提交围栏、`submit_order_once()` 或任何支付能力。
- 只有人能确认真实最终页面为同一商品详情、未进入确认/提交/支付页且未创建订单;agent 完成离线实现后仍保持 `DOING`,不得自行标 `DONE`。
## 执行记录 - client focused/full unittest、compileall、完整 init、上下文校验、Vikunja 导出检查与 diff-check 全部通过。
等待 T-103 完成后认领。
## 执行记录 ## 执行记录
### 2026-08-04T13:46:33Z · ila ### 2026-08-04T13:46:33Z · ila
2026-08-04 T-306 预研反向约束 T-104:成功 SkuSelectionRunResult 必须显式携带规格面板原始 screenshot_path 与实际 UTC captured_at;只有规格选择、闸门一读价、截图和严格安全退出全成功才返回。T-306 不枚举目录、不读 manifest 猜文件/时间,post-exit 商品详情截图不得冒充 gate1 证据。 2026-08-04 T-306 预研反向约束 T-104:成功 SkuSelectionRunResult 必须显式携带规格面板原始 screenshot_path 与实际 UTC captured_at;只有规格选择、闸门一读价、截图和严格安全退出全成功才返回。T-306 不枚举目录、不读 manifest 猜文件/时间,post-exit 商品详情截图不得冒充 gate1 证据。
### 2026-08-05T09:04:13Z · ila
2026-08-05 T-103 已在 git/线上完成,T-104 依赖解除,现由 root 领取并转 DOING;领取基线 29fb528,沿用工作路径 task/t-104-safe-exit。
先证据后判据的实施顺序保持不变:
1. 先只读审计 T-103 最新生产 Flow、最终 Back 前/后本地证据与 T-104 任务边界。T-103 的 sku-selection-production-20260805-final 与 sku-selection-after-back-20260805-final 已获人确认,可用于设计窄取证脚本和寻找候选正锚;但它们不是同一 T-104 原子 manifest,不能直接冒充阶段 A 验收证据。
2. 唯一 client 写入者先实现 capture_sku_exit_spike.py:输入只允许显式 serial 与全新 output-dir;无商品 intent、页面 click、坐标、规格动作、数量、确认、提交或支付能力。前置复用 T-103 已证目标颜色/M/12.88 classifier;唯一 mutation 为 RPC 前封存且最多一次的 Android Back。
3. Back 后只读连续采集 screenshot/XML/App/版本并原子发布本机证据;manifest 仅记录 back_attempts=1、RPC outcome、哈希和 human_review_required,人工确认前绝不写 safe_exit=completed。结果不明不重试、不发送任何后续 mutation。
4. 离线脚本、原子性、超时/重入、静态 API/AST 测试经独立审计和 root 门禁通过后,才由项目所有者把手机手工停在已验目标规格面板并执行阶段 A;人确认同 goods 商品详情、面板关闭、未进入确认/提交/支付且未创建订单。
5. 只有上述人验后,才从新 raw 提取 privacy-safe 的 product_exit_8_17_0.xml 最小 fixture,并设计阶段 B。若 raw 不含唯一同商品正锚,保持 DOING,不编造 selector、不放宽为任意 PDD 商品页。
6. 阶段 B 收紧现有 Flow/runner:版本/PDD 包、同 goods 正锚、无危险页与连续两帧稳定全部成立才返回安全退出;空 hierarchy/SystemUI/锁屏/其他 PDD 页/面板未退/锚点漂移全部失败。成功结果同时补齐规格面板 screenshot_path 与截图附近 UTC captured_at,post-exit 图不得冒充 Gate1。
写路径严格限于 T-104 frontmatter;共享 current-state 由 root 合入。needs_device/needs_human_review 保持,agent 不得自行 DONE。
### 2026-08-05T09:27:14Z · ila
2026-08-05 阶段 A 离线实现与独立审查完成,代码 commit 43fd8ce 已推送 main。
仅新增窄取证器/CLI/测试:人工先停驻目标规格面板,内存中连续两次核验 PDD 8.17.0、PKG110/Android 16/1080x2376、目标颜色+M+12.88;唯一 mutation 为 RPC 前封存且最多一次的命名 Android Back。Back 响应不明不重试,只允许发布 ambiguous_reconciled + human_review_required。Back 后在任何截图/XML 前先核验仍为 PDD 8.17.0,采集后复核 package/activity/version 稳定;外部包、版本或页面漂移、PNG/XML/读取/发布失败均清 staging、零 target。
manifest 不含 safe_exit 或 Gate1 资格;post-exit 图/XML 仅本地人工复核。静态检查证明脚本不可达 intent、页面 click、规格动作、数量、确认、围栏、提交或支付能力。
root 独立门禁:focused 71/71、client full 293/293、compileall、validate_agent_context、git diff --check、完整 init.ps1 全部通过。独立 reviewer 终审 PASS,无 P1/P2。
任务保持 DOING,下一步等待项目所有者把手机手工停在 goods_id 937122477375 的目标规格面板(黑色CHA(纯棉)、M(建议100-115)、12.88),执行一次阶段 A 脚本并人工确认 Back 后为同一商品详情、面板关闭、未进入确认/提交/支付、未创建订单。
### 2026-08-05T09:38:22Z · ila
2026-08-05 阶段 A 首次真机尝试在唯一 Back 前置核验处 fail closed,未发布 target/staging。项目所有者随后人工确认规格面板仍打开,因此本次 Back 没有送达;后续仅做只读诊断。
只读诊断在 PDD 8.17.0、PKG110、Android 16、1080x2376、Wi-Fi 设备上证明:目标颜色与 M 均为 selected=true,尺码仍为既有精确 bounds `[439,1582][831,1667]`;同一目标颜色卡片因横向列表滚动,从既有 `[372,1000][684,1024]` 精确移动为 `[126,1000][438,1024]`,其余完整 TARGETS_SELECTED profile 与 12.88 双价格布局均未漂移。
新本地只读证据:`%LOCALAPPDATA%\cmbuyer\artifacts\T-104\sku-exit-precondition-color-shift-937122477375-20260805`。screenshot SHA-256 `aef1281c09e72990f06ef5a301d701efc37eaeb0f3ed5866f617e81313b22028`;hierarchy SHA-256 `3ffd7fa673e3b83e288c37eecaf65ab03480f9471814dd18019480fdeff0a41e`;captured_at `2026-08-05T09:35:40.312763+00:00`。项目所有者在运行前已按要求声明已选“黑色CHA(纯棉)”与“M(建议100-115)”、价格 12.88 并保持面板打开。
最小兼容边界:只新增上述第二个 exact color bounds 的完整 TARGETS_SELECTED spec;文字、selected、价格、摘要、尺码和其余结构继续全部精确匹配,任意相邻/未知 bounds 仍在 Back 前零动作失败。真机只读双 precheck 已通过;尚未再次发送 Back。
### 2026-08-05T09:47:20Z · ila
2026-08-05 T-104 阶段 A 真机取证完成并通过项目所有者人工复核。
最终证据目录:`%LOCALAPPDATA%\cmbuyer\artifacts\T-104\sku-exit-phase-a-937122477375-20260805-final-v3`;captured_at `2026-08-05T09:44:33.891598+00:00`;Wi-Fi;PKG110;Android 16;PDD 8.17.0;goods_id `937122477375`。manifest 明确 `back_attempts=1`、`rpc_outcome=completed`、`post_exit_status=human_review_required`,不含 safe_exit 或 Gate1 资格。
原子产物:post_exit_screenshot.png SHA-256 `2b9d931d8dfa7325629f53a996d65dc2e95bfa185ddeae174110fa1237221e2e`;post_exit_hierarchy.xml SHA-256 `d57e835e9d4b1b7ac1b96d6178ed5d9de09f53b2b64ec2ef76740980fc9243bc`;post_exit_app.json SHA-256 `b86205c3a7a865ce14eb57e4776184fcf7cc8aefe6d5f7442839be6218f6abf5`。
生产 capturer 外仅包了一层只记录方法名的诊断代理,调用序列证明:两次前置核验 → 唯一一次 pressKey → 前台/版本 → screenshot → XML → 前台/版本;没有 intent、页面 click、规格、数量、确认、提交或支付动作,也没有第二次 Back。
项目所有者完成三项人工确认:①手机与截图均为 goods_id 937122477375 的同一商品详情;②规格面板已关闭,未进入确认、提交或支付页面;③未创建订单。截图/XML 由同一 staging 原子发布且前后 package/activity/version 稳定。
阶段 A 达标。任务保持 DOING,下一步只从上述本项目真实 post-exit XML 提取无个人信息/支付凭据的最小 `product_exit_8_17_0.xml`,再实现版本绑定、同商品正锚、连续两帧稳定的阶段 B 判据。
### 2026-08-05T09:56:55Z · ila
2026-08-05 T-104 阶段 B 完成,代码 commit `1bb5492` 已推送 main。
从已人工确认 raw XML 提取最小 `client/tests/pdd/fixtures/product_exit_8_17_0.xml`:同商品正锚为唯一 exact `tv_title` 容器、完整标题 content-desc、两段直接只读文本及固定属性;同时要求已取证底部规格入口完整投影唯一。安全退出不解析或返回商品页价格,规格入口中的金额只保持既有身份常量,Gate1 仍只来自规格面板原始截图。
`exit_sku_panel_safely()` 现在每帧重查 PDD 8.17.0/前台包,要求同商品投影连续两次一致;单次命中后发生漂移会清空稳定计数。空 hierarchy、面板仍在、SystemUI、锁屏、非 PDD overlay、PDD 其他页、正锚缺失/重复/属性漂移、版本/前台漂移与合成危险动作语义全部失败关闭,所有路径 Back≤1。
runner 只有在精确规格、Gate1 读价、原始规格 PNG、严格同商品退出和完整动作审计全部成功后才发布 `safe_exit=completed`;`SkuSelectionRunResult` 明确返回原始规格 `screenshot_path` 与截图 RPC 成功附近的 UTC-aware `captured_at`,manifest 使用同一时间。post-exit 图不进入 runner 结果,也无 Gate1 资格。
验证:真实 post-exit raw 与最小 fixture 均被新投影命中;focused 76/76、client full 298/298、compileall、validate_agent_context、git diff --check、完整 init.ps1 全部通过。静态边界继续证明 T-104 不可达数量、确认、授权、提交围栏、submit_order_once 或支付能力。
结合阶段 A 的最终 manifest 与项目所有者三项人工确认,T-104 全部验收要点已满足,可转 DONE。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
- T-104 必须等待 T-103 完成人工真机验收后才可转 `DOING`。本任务先取证、后写退出成功判据;不得从 - T-104 必须等待 T-103 完成人工真机验收后才可转 `DOING`。本任务先取证、后写退出成功判据;不得从
当前实现、前序项目、旧 XML、Activity 名或推理直接声明商品详情页。 当前实现、前序项目、旧 XML、Activity 名或推理直接声明商品详情页。
- 取证动作只允许在已证明为拼多多 8.17.0、goods_id `937122477375` 的目标规格面板上发送一次 - 阶段 A 取证脚本必须先在内存中核验拼多多 8.17.0、goods_id `937122477375` 的已知目标规格面板、
Android Back,随后只读采集截图/XML/App/设备摘要。不得点击关闭坐标、空白处、购买、数量、确认、 目标颜色/尺码选中态和已取证当前价。唯一状态改变是一次命名 Android Back;不得发送 intent,不得调用
提交或支付控件,也不得在 Back 失败或结果不明时重试。 页面 `click`,不得点击关闭坐标、空白、购买、数量、确认、提交或支付控件。Back 调用前即封存机会,
- “规格面板已消失”不是退出成功。成功必须由本项目 post-exit 真机证据证明:版本和前台包正确、节点树 超时、响应不明和 finally 调和均不得重试。
仅属于 PDD 受控页面、同一目标商品详情正判据精确唯一并连续稳定;SystemUI/锁屏、PDD 其他页面、 - 阶段 A 在 Back 后只能只读采集 post-exit screenshot/XML/App/设备摘要并原子发布本机证据;manifest
确认页、提交页、外部支付页、入口缺失/重复或结构漂移一律停止且不得发布 completed manifest。 只能声明 `human_review_required` 与 `back_attempts=1`。人工确认前绝不能写 `safe_exit=completed`,
- 可以复用 T-103 已取证的“快要抢光”五层入口链作为候选结构,但必须在新的 post-exit 截图/XML 上 agent 也不得把“面板消失”或“前台包仍是 PDD”自行解释为安全退出。
重新验证并由人确认页面对应性;不得使用相似文本、包含/前缀、OCR、裸坐标或其他购买文案兜底。 - 人确认 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`、 - 只收紧现有 `SkuSelectionFlow` 与 runner 的退出后置条件,不新建第二套采购流程,不新增通用 `click`、
数量、确认页导航、授权、提交围栏、创建待付款订单或支付能力。第一趟与任何真实提交函数继续静态隔离。 数量、确认页导航、授权、提交围栏、创建待付款订单或支付能力。第一趟与任何真实提交函数继续静态隔离。
- 原始完整 XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入 - 原始完整 XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入
Vikunja 或日志。Git 中最小 fixture 只能保留退出判据所需结构,并由人确认不含地址、手机号或支付凭据。 Vikunja 或日志。Git 中最小 fixture 只能保留退出判据所需结构,并由人确认不含地址、手机号或支付凭据。
- 供 T-306 消费的成功结果必须显式携带规格面板原始 `screenshot_path` 与实际截图时刻 `captured_at` - 所有正常、失败、超时和 finally 调和路径的 Back 总数都必须小于等于 1;Back 结果不明不重试。
(UTC aware);只有规格选择、闸门一读价、截图和严格安全退出全部成功后才可返回该结果。不得让 只有规格选择、闸门一读价、规格面板截图和严格安全退出全部成功,runner 才能原子发布结果与
T-306 枚举目录或读取 manifest 猜文件/时间;post-exit 商品详情截图也不得冒充规格面板证据。 `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 截图为同一目标商品详情、面板 - `needs_device: true`:agent 不得自行标 `DONE`。只有人确认 post-exit 截图为同一目标商品详情、面板
已关闭、未进入确认/提交/支付页、未创建订单,且截图/XML 对应后,任务才可完成。 已关闭、未进入确认/提交/支付页、未创建订单,且截图/XML 对应后,任务才可完成。
- 本任务不实现、不调用 `set_quantity_and_readback()`、`go_to_order_confirm()`、 - 本任务不实现、不调用 `set_quantity_and_readback()`、`go_to_order_confirm()`、

Some files were not shown because too many files have changed in this diff Show More