Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0b71d5a29 | ||
|
|
acc9f1d964 | ||
|
|
db2b781c8c | ||
|
|
3f09fbff66 | ||
|
|
21628a4dc5 | ||
|
|
1d726dd364 | ||
|
|
e18f7983fd | ||
|
|
ad80a7f23c | ||
|
|
994b6c8054 | ||
|
|
16b2487a2a | ||
|
|
67b427864a | ||
|
|
1bb54927e1 | ||
|
|
0afe9a53b3 | ||
|
|
63bd873c26 | ||
|
|
2e303f3829 | ||
|
|
f97dca95ae | ||
|
|
43fd8ce668 | ||
|
|
44a207df7f | ||
|
|
29fb528823 | ||
|
|
537159445e | ||
|
|
91071d4013 | ||
|
|
60cf05b08b | ||
|
|
fec21ae857 | ||
|
|
3984f399b0 | ||
|
|
251f3d92dd | ||
|
|
5fa8ac6ff2 | ||
|
|
a8570f59af | ||
|
|
ee56249014 | ||
|
|
e552e2e2ca | ||
|
|
e1e534c5d3 | ||
|
|
5a4633fb64 | ||
|
|
5d0fc81891 | ||
|
|
230cfdc697 | ||
|
|
b4ff9096c3 | ||
|
|
ae4b9ef08b | ||
|
|
1bf3acda70 | ||
|
|
e8ca738b33 | ||
|
|
429c2d33db | ||
|
|
a15b6c0911 | ||
|
|
29d1540f7c | ||
|
|
2f1380d084 | ||
|
|
81a44caeef | ||
|
|
757eca39fd | ||
|
|
b754cc9ca9 | ||
|
|
e45d26fe93 | ||
|
|
a09c58beef | ||
|
|
42eac57824 | ||
|
|
c9b39a4e05 | ||
|
|
870e5f861d | ||
|
|
025edaf273 | ||
|
|
584c5601c5 | ||
|
|
3a27225977 | ||
|
|
dfd88c3336 | ||
|
|
20112dbd61 | ||
|
|
404d0d3ca4 | ||
|
|
85e5f1e56e | ||
|
|
6145fc4468 | ||
|
|
f6cd65208d | ||
|
|
7ea1c5349f | ||
|
|
57a5b2e91c | ||
|
|
63c2c59b41 | ||
|
|
cfb238d022 | ||
|
|
31f07ac245 | ||
|
|
488005ac93 | ||
|
|
4186f1315a | ||
|
|
3a0a41db00 | ||
|
|
8600c33391 | ||
|
|
8092431208 | ||
|
|
cd47d0c959 | ||
|
|
ec42640123 | ||
|
|
5f060f60ee | ||
|
|
19f81a5e58 | ||
|
|
c1cee49fb1 | ||
|
|
772e379fd6 | ||
|
|
83b244ff7c | ||
|
|
d9a31cbfa6 | ||
|
|
014405466a | ||
|
|
8c50e1579e | ||
|
|
35d7ce11d6 | ||
|
|
41e54e313e | ||
|
|
e5de76503f | ||
|
|
542b2283f4 | ||
|
|
eb29bcd8b7 | ||
|
|
526af1eb31 | ||
|
|
d9381a644b | ||
|
|
e863a29641 | ||
|
|
5c97954fa1 | ||
|
|
bd4c855ad2 | ||
|
|
ee90d76893 | ||
|
|
4a65138fd7 | ||
|
|
8ec740dcef | ||
|
|
590c84660a | ||
|
|
233188297a | ||
|
|
e674b7f131 |
@@ -13,6 +13,8 @@
|
||||
| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
|
||||
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
|
||||
| `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 |
|
||||
| `CMBUYER_CLAIM_TOKEN_SECRET` | claim token 专用 32 字节密钥的 64 位小写十六进制;不得复用 session 或设备 token。 |
|
||||
| `CMBUYER_CLAIM_LEASE_TTL` | 正 Go duration,且必须严格短于 `CMBUYER_AUTHORIZATION_TTL`。 |
|
||||
|
||||
示例仅展示变量名,不提供可运行凭据:
|
||||
|
||||
@@ -26,6 +28,8 @@ $env:CMBUYER_AUTHORIZATION_TTL = '10m'
|
||||
$env:CMBUYER_MAX_TASK_QUANTITY = '99'
|
||||
$env:CMBUYER_MAX_TOTAL_PRICE = '999.99'
|
||||
$env:CMBUYER_EVIDENCE_DIR = '<内部截图绝对目录>'
|
||||
$env:CMBUYER_CLAIM_TOKEN_SECRET = '<64 位小写十六进制随机值>'
|
||||
$env:CMBUYER_CLAIM_LEASE_TTL = '1m'
|
||||
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"cmbuyer/admin/internal/server"
|
||||
evidencestorage "cmbuyer/admin/internal/storage/evidence"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
@@ -52,6 +53,10 @@ func run() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
claimStore, err := taskclaim.NewStore(database, configuration.ClaimTokenSecret, configuration.ClaimLeaseTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(server.Options{
|
||||
AdminUsername: configuration.AdminUsername,
|
||||
@@ -61,6 +66,7 @@ func run() error {
|
||||
TaskDetails: detailStore,
|
||||
Evidence: evidenceStore,
|
||||
DeviceAuthenticator: deviceAuthenticator,
|
||||
TaskClaims: claimStore,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -23,6 +25,8 @@ const (
|
||||
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
|
||||
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
|
||||
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
|
||||
claimTokenSecretEnv = "CMBUYER_CLAIM_TOKEN_SECRET"
|
||||
claimLeaseTTLEnv = "CMBUYER_CLAIM_LEASE_TTL"
|
||||
minimumSecretLength = 32
|
||||
)
|
||||
|
||||
@@ -37,6 +41,8 @@ type Config struct {
|
||||
MaxTaskQuantity int
|
||||
MaxTotalPrice string
|
||||
EvidenceDirectory string
|
||||
ClaimTokenSecret []byte
|
||||
ClaimLeaseTTL time.Duration
|
||||
}
|
||||
|
||||
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
||||
@@ -112,6 +118,27 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
|
||||
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
|
||||
}
|
||||
claimSecretText, err := required(lookup, claimTokenSecretEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
claimSecret, err := hex.DecodeString(claimSecretText)
|
||||
if err != nil || len(claimSecret) != 32 || hex.EncodeToString(claimSecret) != claimSecretText {
|
||||
return Config{}, fmt.Errorf("%s must be exactly 64 lowercase hexadecimal characters", claimTokenSecretEnv)
|
||||
}
|
||||
// Claim ownership, admin sessions and device authentication are separate security domains.
|
||||
// Reject both identical configuration text and identical effective key bytes.
|
||||
if claimSecretText == secret || bytes.Equal(claimSecret, []byte(secret)) {
|
||||
return Config{}, fmt.Errorf("%s must be isolated from %s", claimTokenSecretEnv, sessionSecretEnv)
|
||||
}
|
||||
claimTTLText, err := required(lookup, claimLeaseTTLEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
claimTTL, err := time.ParseDuration(claimTTLText)
|
||||
if err != nil || claimTTL <= 0 || claimTTL >= ttl {
|
||||
return Config{}, fmt.Errorf("%s must be positive and shorter than %s", claimLeaseTTLEnv, authorizationTTLEnv)
|
||||
}
|
||||
|
||||
return Config{
|
||||
AdminUsername: username,
|
||||
@@ -121,6 +148,8 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
DatabaseSource: databaseSource,
|
||||
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
|
||||
EvidenceDirectory: evidenceDirectory,
|
||||
ClaimTokenSecret: claimSecret,
|
||||
ClaimLeaseTTL: claimTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package config_test
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/config"
|
||||
|
||||
@@ -25,13 +26,15 @@ func TestLoad(t *testing.T) {
|
||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
|
||||
"CMBUYER_CLAIM_LEASE_TTL": "1m",
|
||||
}
|
||||
|
||||
got, err := config.Load(lookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure {
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure || len(got.ClaimTokenSecret) != 32 || got.ClaimLeaseTTL != time.Minute {
|
||||
t.Fatalf("Load returned unexpected public configuration: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +54,8 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
|
||||
"CMBUYER_CLAIM_LEASE_TTL": "1m",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -68,6 +73,12 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
{"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"},
|
||||
{"missing evidence directory", func(values map[string]string) { delete(values, "CMBUYER_EVIDENCE_DIR") }, "CMBUYER_EVIDENCE_DIR"},
|
||||
{"relative evidence directory", func(values map[string]string) { values["CMBUYER_EVIDENCE_DIR"] = "evidence" }, "CMBUYER_EVIDENCE_DIR"},
|
||||
{"invalid claim secret", func(values map[string]string) { values["CMBUYER_CLAIM_TOKEN_SECRET"] = strings.Repeat("A", 64) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"claim secret same raw session secret", func(values map[string]string) {
|
||||
values["CMBUYER_SESSION_SECRET"] = values["CMBUYER_CLAIM_TOKEN_SECRET"]
|
||||
}, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"claim secret same decoded session secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = strings.Repeat("\xab", 32) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"invalid claim lease ttl", func(values map[string]string) { values["CMBUYER_CLAIM_LEASE_TTL"] = "10m" }, "CMBUYER_CLAIM_LEASE_TTL"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertVersion(t, database, 5)
|
||||
assertTableExists(t, database, "tasks", true)
|
||||
assertTableExists(t, database, "spec_trials", false)
|
||||
assertTableExists(t, database, "order_authorizations", true)
|
||||
@@ -34,12 +34,20 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
||||
assertTableExists(t, database, "order_submissions", true)
|
||||
assertTableExists(t, database, "evidence_assets", true)
|
||||
assertTableExists(t, database, "device_credentials", true)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", true)
|
||||
assertTableExists(t, database, "single_pass_upgrade_guard", false)
|
||||
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("reapply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 5)
|
||||
|
||||
if err := migrations.Down(context, database, directory); err != nil {
|
||||
t.Fatalf("roll back task claim migration: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", false)
|
||||
assertTableExists(t, database, "device_credentials", true)
|
||||
|
||||
if err := migrations.Down(context, database, directory); err != nil {
|
||||
t.Fatalf("roll back device credential migration: %v", err)
|
||||
@@ -65,7 +73,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("reapply v2 after rollback: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertVersion(t, database, 5)
|
||||
}
|
||||
|
||||
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
|
||||
@@ -84,7 +92,7 @@ func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("upgrade v1 draft: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertVersion(t, database, 5)
|
||||
var got struct {
|
||||
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
|
||||
quantity, version int
|
||||
@@ -235,9 +243,7 @@ func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
|
||||
|
||||
func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
migrateToV3(t, database)
|
||||
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
|
||||
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
|
||||
@@ -271,9 +277,6 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("roll back empty device credential migration: %v", err)
|
||||
}
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("evidence-bearing schema downgraded successfully")
|
||||
}
|
||||
@@ -287,9 +290,7 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
|
||||
func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
migrateToV4(t, database)
|
||||
deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
hash := make([]byte, 32)
|
||||
for index := range hash {
|
||||
@@ -354,6 +355,132 @@ func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskClaimMigrationGuardsOwnershipConstraintsAndDowngradeFacts(t *testing.T) {
|
||||
t.Run("upgrade rejects unmappable execution facts atomically", func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
migrateToV4(t, database)
|
||||
insertV2Task(t, database, "legacy-task", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "legacy-auth", "legacy-task", 1, "legacy-start")
|
||||
insertV2Attempt(t, database, "legacy-attempt", "legacy-task", "legacy-auth", 1)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("v5 upgrade accepted an attempt without device/session ownership")
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", false)
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("legacy attempt after rejected upgrade = %d, err %v", count, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("schema binds authorization device session generation and token", func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
deviceA := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
deviceB := "23c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
sessionA := "33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
sessionB := "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
taskA := "53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
authA := "63c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
attemptA := "73c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
taskB := "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
authB := "93c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
attemptB := "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
tokenA := make([]byte, 32)
|
||||
for index := range tokenA {
|
||||
tokenA[index] = byte(index + 1)
|
||||
}
|
||||
for index, device := range []string{deviceA, deviceB} {
|
||||
hash := make([]byte, 32)
|
||||
hash[0] = byte(index + 100)
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, device, "device "+strconv.Itoa(index), hash, migrationTime); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
}
|
||||
insertV2Task(t, database, taskA, "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, authA, taskA, 1, "start-a")
|
||||
insertV2Attempt(t, database, attemptA, taskA, authA, 1)
|
||||
insertClaim := `INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 2, 'task', 1, 'goods', 'white', 'XL', 1, '1.00',
|
||||
'2026-08-04T01:00:00Z', ?, ?, '2026-08-04T00:05:00Z', ?, NULL)`
|
||||
if _, err := database.Exec(insertClaim, attemptA, taskA, authA, deviceA, sessionA, 1, make([]byte, 32), tokenA, migrationTime); err != nil {
|
||||
t.Fatalf("insert valid claim: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts
|
||||
(id,task_id,authorization_id,claim_generation,status,started_at)
|
||||
VALUES ('b3c9f507-7473-4fa6-8d71-8786c34c6301', ?, ?, 2, 'CLAIMED', ?)`, taskA, authA, migrationTime); err == nil {
|
||||
t.Fatal("second attempt for one authorization succeeded")
|
||||
}
|
||||
|
||||
insertV2Task(t, database, taskB, "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, authB, taskB, 1, "start-b")
|
||||
insertV2Attempt(t, database, attemptB, taskB, authB, 1)
|
||||
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceB, sessionB, 2, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
|
||||
t.Fatal("claim with generation different from its attempt succeeded")
|
||||
}
|
||||
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceA, sessionB, 1, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
|
||||
t.Fatal("second open claim for one device succeeded")
|
||||
}
|
||||
|
||||
claimRequest := `INSERT INTO task_claim_requests
|
||||
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
|
||||
VALUES (?, ?, ?, 'CLAIMED', ?, '2026-08-04T00:05:00Z', NULL, ?)`
|
||||
if _, err := database.Exec(claimRequest, "c3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionB, attemptA, migrationTime); err == nil {
|
||||
t.Fatal("claim request with another session succeeded")
|
||||
}
|
||||
if _, err := database.Exec(claimRequest, "d3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionA, attemptA, migrationTime); err != nil {
|
||||
t.Fatalf("insert bound claim request: %v", err)
|
||||
}
|
||||
renewal := `INSERT INTO purchase_attempt_lease_renewals
|
||||
(renew_request_id,task_id,attempt_id,device_id,session_id,claim_generation,
|
||||
claim_token_sha256,expected_lease_expires_at,lease_expires_at,created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '2026-08-04T00:05:00Z', '2026-08-04T00:06:00Z', ?)`
|
||||
if _, err := database.Exec(renewal, "e3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 2, tokenA, migrationTime); err == nil {
|
||||
t.Fatal("renewal with another generation succeeded")
|
||||
}
|
||||
wrongHash := append([]byte(nil), tokenA...)
|
||||
wrongHash[0] ^= 0xff
|
||||
if _, err := database.Exec(renewal, "f3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 1, wrongHash, migrationTime); err == nil {
|
||||
t.Fatal("renewal with another token hash succeeded")
|
||||
}
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("claim-bearing schema downgraded successfully")
|
||||
}
|
||||
assertVersion(t, database, 5)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", true)
|
||||
})
|
||||
|
||||
t.Run("empty request alone blocks downgrade", func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
device := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'device', ?, 'ACTIVE', ?, NULL)`, device, make([]byte, 32), migrationTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO task_claim_requests
|
||||
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
|
||||
VALUES ('23c9f507-7473-4fa6-8d71-8786c34c6301', ?,
|
||||
'33c9f507-7473-4fa6-8d71-8786c34c6301', 'EMPTY', NULL, NULL, NULL, ?)`, device, migrationTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("EMPTY request was silently dropped by downgrade")
|
||||
}
|
||||
assertVersion(t, database, 5)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -416,6 +543,24 @@ func migrateToV2(t *testing.T, database *sql.DB) {
|
||||
assertVersion(t, database, 2)
|
||||
}
|
||||
|
||||
func migrateToV3(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
migrateToV2(t, database)
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("apply v3: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 3)
|
||||
}
|
||||
|
||||
func migrateToV4(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
migrateToV3(t, database)
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("apply v4: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
}
|
||||
|
||||
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil {
|
||||
|
||||
@@ -134,6 +134,7 @@ func TestRealDeviceCredentialIdentityIsolationAndMixedCredentials(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("issue credential: %v", err)
|
||||
}
|
||||
insertEvidenceClaim(t, database, issued.DeviceID)
|
||||
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
|
||||
if err != nil {
|
||||
t.Fatalf("new authenticator: %v", err)
|
||||
@@ -353,6 +354,8 @@ func newEvidenceRouter(t *testing.T, authenticator deviceauth.Authenticator) (ht
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
insertEvidenceAttempt(t, database)
|
||||
insertEvidenceClaimDevice(t, database, evidenceDeviceID)
|
||||
insertEvidenceClaim(t, database, evidenceDeviceID)
|
||||
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
|
||||
if err != nil {
|
||||
t.Fatalf("new evidence store: %v", err)
|
||||
@@ -384,6 +387,30 @@ func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
func insertEvidenceClaimDevice(t *testing.T, database *sql.DB, deviceID string) {
|
||||
t.Helper()
|
||||
digest := sha256.Sum256([]byte("fake evidence device"))
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'fake evidence device', ?, 'ACTIVE', '2026-08-04T00:00:00Z', NULL)`, deviceID, digest[:]); err != nil {
|
||||
t.Fatalf("insert evidence device: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertEvidenceClaim(t *testing.T, database *sql.DB, deviceID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, '23c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
|
||||
1, '123', 'black', 'M', 1, '1.00', '2026-08-04T00:00:00Z', ?, ?,
|
||||
'2026-08-04T02:00:00Z', '2026-08-04T00:00:00Z', NULL)`, evidenceAttemptID,
|
||||
evidenceTaskID, evidenceAuthID, deviceID, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32)); err != nil {
|
||||
t.Fatalf("insert evidence claim: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
"cmbuyer/admin/internal/transport/webui"
|
||||
@@ -36,11 +37,12 @@ type Options struct {
|
||||
TaskDetails taskdetail.Store
|
||||
Evidence evidence.Store
|
||||
DeviceAuthenticator deviceauth.Authenticator
|
||||
TaskClaims taskclaim.Service
|
||||
}
|
||||
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter(options Options) (*gin.Engine, error) {
|
||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil {
|
||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil || options.TaskClaims == nil {
|
||||
return nil, errors.New("server authentication options are incomplete")
|
||||
}
|
||||
|
||||
@@ -57,6 +59,8 @@ func NewRouter(options Options) (*gin.Engine, error) {
|
||||
router.POST("/tasks", createTask(options))
|
||||
router.POST("/tasks/start-purchases", startPurchases(options))
|
||||
router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(options))
|
||||
router.POST("/api/v1/tasks/claim-next", claimNext(options))
|
||||
router.POST("/api/v1/tasks/:id/lease/renew", renewLease(options))
|
||||
router.GET("/evidence/:asset_id", readEvidence(options))
|
||||
router.GET("/static/tasks.js", func(context *gin.Context) {
|
||||
context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript())
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/server"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
|
||||
@@ -493,6 +494,10 @@ func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Man
|
||||
}
|
||||
|
||||
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) {
|
||||
return newRouterWithClaimService(t, store, details, evidenceStore, deviceAuthenticator, emptyTaskClaimService{})
|
||||
}
|
||||
|
||||
func newRouterWithClaimService(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator, claims taskclaim.Service) (*gin.Engine, *auth.Manager) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
@@ -508,6 +513,7 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
|
||||
TaskDetails: details,
|
||||
Evidence: evidenceStore,
|
||||
DeviceAuthenticator: deviceAuthenticator,
|
||||
TaskClaims: claims,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
@@ -517,6 +523,16 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
|
||||
|
||||
type emptyDetailStore struct{}
|
||||
|
||||
type emptyTaskClaimService struct{}
|
||||
|
||||
func (emptyTaskClaimService) ClaimNext(context.Context, string, taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
|
||||
return taskclaim.ClaimResponse{}, false, nil
|
||||
}
|
||||
|
||||
func (emptyTaskClaimService) Renew(context.Context, string, taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
|
||||
return taskclaim.RenewResponse{}, taskclaim.ErrNotCurrent
|
||||
}
|
||||
|
||||
func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
|
||||
return taskdetail.Detail{}, taskdetail.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
maxClaimJSONBytes = 4096
|
||||
maxClaimResponseJSONBytes = 32 * 1024
|
||||
)
|
||||
|
||||
func claimNext(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
principal, ok := authenticateDevice(context, options)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var command taskclaim.ClaimCommand
|
||||
if !decodeClaimJSON(context, &command) {
|
||||
return
|
||||
}
|
||||
response, found, err := options.TaskClaims.ClaimNext(context.Request.Context(), principal.ID, command)
|
||||
if err != nil {
|
||||
writeTaskClaimError(context, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
context.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func renewLease(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
principal, ok := authenticateDevice(context, options)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var command taskclaim.RenewCommand
|
||||
if !decodeClaimJSON(context, &command) {
|
||||
return
|
||||
}
|
||||
command.TaskID = context.Param("id")
|
||||
response, err := options.TaskClaims.Renew(context.Request.Context(), principal.ID, command)
|
||||
if err != nil {
|
||||
writeTaskClaimError(context, err)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication precedes path interpretation, Content-Type parsing and every body read. This
|
||||
// keeps rejected devices from using parsing differences as an oracle or making the server buffer data.
|
||||
func authenticateDevice(context *gin.Context, options Options) (deviceauth.Principal, bool) {
|
||||
principal, err := options.DeviceAuthenticator.Authenticate(context.Request)
|
||||
if errors.Is(err, deviceauth.ErrUnauthenticated) {
|
||||
context.Header("WWW-Authenticate", "Bearer")
|
||||
context.Status(http.StatusUnauthorized)
|
||||
return deviceauth.Principal{}, false
|
||||
}
|
||||
if err != nil || !deviceauth.ValidDeviceID(principal.ID) {
|
||||
context.Status(http.StatusServiceUnavailable)
|
||||
return deviceauth.Principal{}, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func decodeClaimJSON(context *gin.Context, target any) bool {
|
||||
if !isJSONContentType(context.GetHeader("Content-Type")) {
|
||||
writeFixedError(context, http.StatusUnsupportedMediaType, "unsupported_media_type")
|
||||
return false
|
||||
}
|
||||
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxClaimJSONBytes)
|
||||
raw, err := io.ReadAll(context.Request.Body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeFixedError(context, http.StatusRequestEntityTooLarge, "request_too_large")
|
||||
} else {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(raw) == 0 || !utf8.Valid(raw) {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
if !hasUniqueTopLevelJSONFields(raw) {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasUniqueTopLevelJSONFields(raw []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
first, err := decoder.Token()
|
||||
if err != nil || first != json.Delim('{') {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for decoder.More() {
|
||||
key, err := decoder.Token()
|
||||
name, ok := key.(string)
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
if _, duplicate := seen[name]; duplicate {
|
||||
return false
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
var value json.RawMessage
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
last, err := decoder.Token()
|
||||
return err == nil && last == json.Delim('}')
|
||||
}
|
||||
|
||||
func writeTaskClaimError(context *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, taskclaim.ErrInvalid):
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
case errors.Is(err, taskclaim.ErrIdempotencyConflict):
|
||||
writeFixedError(context, http.StatusConflict, "idempotency_conflict")
|
||||
case errors.Is(err, taskclaim.ErrRequiresManual):
|
||||
writeFixedError(context, http.StatusConflict, "claim_requires_manual")
|
||||
case errors.Is(err, taskclaim.ErrNotCurrent):
|
||||
writeFixedError(context, http.StatusConflict, "claim_not_current")
|
||||
case errors.Is(err, taskclaim.ErrDeviceInactive):
|
||||
context.Header("WWW-Authenticate", "Bearer")
|
||||
context.Status(http.StatusUnauthorized)
|
||||
default:
|
||||
// Storage and transaction failures are intentionally bodyless: SQL, paths and candidate
|
||||
// details are server-only and must not become a device-facing diagnostic oracle.
|
||||
context.Status(http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFixedError(context *gin.Context, status int, code string) {
|
||||
context.JSON(status, gin.H{"error": code})
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
)
|
||||
|
||||
const (
|
||||
claimDeviceID = "10000000-0000-4000-8000-000000000001"
|
||||
claimSessionID = "20000000-0000-4000-8000-000000000001"
|
||||
claimRequestID = "30000000-0000-4000-8000-000000000001"
|
||||
claimTaskID = "40000000-0000-4000-8000-000000000001"
|
||||
claimAttemptID = "50000000-0000-4000-8000-000000000001"
|
||||
claimRenewID = "60000000-0000-4000-8000-000000000001"
|
||||
)
|
||||
|
||||
func TestTaskClaimEndpointsAuthenticateBeforeBody(t *testing.T) {
|
||||
for _, authentication := range []struct {
|
||||
name string
|
||||
err error
|
||||
status int
|
||||
}{
|
||||
{"unauthenticated", deviceauth.ErrUnauthenticated, http.StatusUnauthorized},
|
||||
{"authentication storage unavailable", deviceauth.ErrUnavailable, http.StatusServiceUnavailable},
|
||||
} {
|
||||
t.Run(authentication.name, func(t *testing.T) {
|
||||
authenticator := &fakeDeviceAuthenticator{err: authentication.err}
|
||||
service := &fakeTaskClaimService{}
|
||||
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
||||
for _, path := range []string{"/api/v1/tasks/claim-next", "/api/v1/tasks/" + claimTaskID + "/lease/renew"} {
|
||||
body := &poisonBody{}
|
||||
request := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
request.Body = body
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != authentication.status || response.Body.Len() != 0 || body.reads != 0 || service.calls != 0 {
|
||||
t.Fatalf("%s = status %d, body %q, reads %d, calls %d", path, response.Code, response.Body.String(), body.reads, service.calls)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimNextStrictJSONSuccessEmptyAndErrors(t *testing.T) {
|
||||
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
||||
service := &fakeTaskClaimService{claimResponse: taskclaim.ClaimResponse{
|
||||
Task: taskclaim.ClaimedTask{ID: claimTaskID, Version: 3, Title: "测试", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", GoodsID: "1", SKUColor: "黑色", SKUSize: "M", Quantity: 1, MaxTotalPrice: "1.00"},
|
||||
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: 2, ExpiresAt: "2026-08-04T01:10:00Z"},
|
||||
Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:03:00Z"},
|
||||
}, claimFound: true}
|
||||
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
||||
valid := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`
|
||||
|
||||
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json; charset=utf-8")
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), strings.Repeat("a", 64)) || service.claimCommand.ClaimRequestID != claimRequestID {
|
||||
t.Fatalf("claim success = %d %q command %#v", response.Code, response.Body.String(), service.claimCommand)
|
||||
}
|
||||
service.claimFound = false
|
||||
response = serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json")
|
||||
if response.Code != http.StatusNoContent || response.Body.Len() != 0 {
|
||||
t.Fatalf("claim empty = %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name, body, contentType, code string
|
||||
status int
|
||||
}{
|
||||
{"unsupported type", valid, "text/plain", "unsupported_media_type", http.StatusUnsupportedMediaType},
|
||||
{"unknown field", strings.TrimSuffix(valid, "}") + `,"device_id":"` + claimDeviceID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
|
||||
{"duplicate session", `{"session_id":"` + claimSessionID + `","session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
|
||||
{"duplicate request", `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
|
||||
{"extra json", valid + `{}`, "application/json", "invalid_request", http.StatusBadRequest},
|
||||
{"too large", strings.Repeat(" ", 4097), "application/json", "request_too_large", http.StatusRequestEntityTooLarge},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", test.body, test.contentType)
|
||||
if response.Code != test.status || response.Body.String() != `{"error":"`+test.code+`"}` {
|
||||
t.Fatalf("response = %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
invalidUTF8 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/claim-next", bytes.NewReader([]byte{'{', 0xff, '}'}))
|
||||
invalidUTF8.Header.Set("Content-Type", "application/json")
|
||||
invalidResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(invalidResponse, invalidUTF8)
|
||||
if invalidResponse.Code != http.StatusBadRequest || invalidResponse.Body.String() != `{"error":"invalid_request"}` {
|
||||
t.Fatalf("invalid UTF-8 = %d %q", invalidResponse.Code, invalidResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
||||
service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}}
|
||||
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
||||
body := `{"renew_request_id":"` + claimRenewID + `","session_id":"` + claimSessionID + `","attempt_id":"` + claimAttemptID + `","claim_generation":1,"claim_token":"` + strings.Repeat("a", 64) + `","expected_lease_expires_at":"2026-08-04T01:03:00Z"}`
|
||||
response := serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
|
||||
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "claim_token") || service.renewCommand.TaskID != claimTaskID {
|
||||
t.Fatalf("renew response = %d %q command %#v", response.Code, response.Body.String(), service.renewCommand)
|
||||
}
|
||||
|
||||
duplicateToken := strings.Replace(body, `"expected_lease_expires_at"`, `"claim_token":"`+strings.Repeat("a", 64)+`","expected_lease_expires_at"`, 1)
|
||||
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", duplicateToken, "application/json")
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("duplicate token status = %d", response.Code)
|
||||
}
|
||||
|
||||
errorsToCodes := []struct {
|
||||
err error
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{taskclaim.ErrIdempotencyConflict, http.StatusConflict, `{"error":"idempotency_conflict"}`},
|
||||
{taskclaim.ErrRequiresManual, http.StatusConflict, `{"error":"claim_requires_manual"}`},
|
||||
{taskclaim.ErrNotCurrent, http.StatusConflict, `{"error":"claim_not_current"}`},
|
||||
{taskclaim.ErrDeviceInactive, http.StatusUnauthorized, ""},
|
||||
{errors.New("database path and SQL must stay private"), http.StatusServiceUnavailable, ""},
|
||||
}
|
||||
for _, test := range errorsToCodes {
|
||||
service.renewErr = test.err
|
||||
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
|
||||
if response.Code != test.status || response.Body.String() != test.body {
|
||||
t.Fatalf("error %v = %d %q", test.err, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTaskClaimService struct {
|
||||
claimResponse taskclaim.ClaimResponse
|
||||
claimFound bool
|
||||
claimErr error
|
||||
renewResponse taskclaim.RenewResponse
|
||||
renewErr error
|
||||
claimCommand taskclaim.ClaimCommand
|
||||
renewCommand taskclaim.RenewCommand
|
||||
calls int
|
||||
}
|
||||
|
||||
func (service *fakeTaskClaimService) ClaimNext(_ context.Context, _ string, command taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
|
||||
service.calls++
|
||||
service.claimCommand = command
|
||||
return service.claimResponse, service.claimFound, service.claimErr
|
||||
}
|
||||
|
||||
func (service *fakeTaskClaimService) Renew(_ context.Context, _ string, command taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
|
||||
service.calls++
|
||||
service.renewCommand = command
|
||||
return service.renewResponse, service.renewErr
|
||||
}
|
||||
|
||||
func serveClaimJSON(router http.Handler, path, body, contentType string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodPost, path, io.NopCloser(strings.NewReader(body)))
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
@@ -185,11 +185,16 @@ func (store *Store) Commit(ctx context.Context, principal deviceauth.Principal,
|
||||
return existing, true, nil
|
||||
}
|
||||
|
||||
var attemptCount int
|
||||
if err := transaction.QueryRowContext(ctx, "SELECT COUNT(*) FROM purchase_attempts WHERE task_id = ? AND id = ?", metadata.TaskID, metadata.AttemptID).Scan(&attemptCount); err != nil {
|
||||
var ownedClaimCount int
|
||||
if err := transaction.QueryRowContext(ctx, `SELECT COUNT(*) FROM purchase_attempt_claims
|
||||
WHERE task_id = ? AND attempt_id = ? AND claimed_by_device_id = ? AND closed_at IS NULL`,
|
||||
metadata.TaskID, metadata.AttemptID, principal.ID).Scan(&ownedClaimCount); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if attemptCount != 1 {
|
||||
// Evidence is auditable only when the authenticated device owns the current attempt. The
|
||||
// idempotent asset lookup above deliberately remains first so closing a claim later cannot
|
||||
// destroy stable replay of an already committed screenshot.
|
||||
if ownedClaimCount != 1 {
|
||||
return core.Asset{}, false, core.ErrInvalid
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,41 @@ func TestStageCommitReplayAndOpen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitRequiresCurrentClaimOwnerButClosedClaimKeepsHistoricalReplay(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
metadata := testMetadata(sha256Hex(pngBytes))
|
||||
stage := func() core.StagedFile {
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
return staged
|
||||
}
|
||||
otherDevice := deviceauth.Principal{ID: "73c9f507-7473-4fa6-8d71-8786c34c6301"}
|
||||
if _, _, err := store.Commit(context.Background(), otherDevice, metadata, stage()); !errors.Is(err, core.ErrInvalid) {
|
||||
t.Fatalf("device B upload to device A attempt error = %v", err)
|
||||
}
|
||||
principal := deviceauth.Principal{ID: testDeviceID}
|
||||
asset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
|
||||
if err != nil || replayed {
|
||||
t.Fatalf("owner first Commit = replayed %v, err %v", replayed, err)
|
||||
}
|
||||
if _, err := database.Exec("UPDATE purchase_attempt_claims SET closed_at='2026-08-04T03:00:00Z' WHERE attempt_id=?", testAttemptID); err != nil {
|
||||
t.Fatalf("close claim: %v", err)
|
||||
}
|
||||
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
|
||||
if err != nil || !replayed || replayedAsset.ID != asset.ID {
|
||||
t.Fatalf("closed claim historical replay = %#v replayed %v err %v", replayedAsset, replayed, err)
|
||||
}
|
||||
newMetadata := metadata
|
||||
newMetadata.UploadKey = "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
if _, _, err := store.Commit(context.Background(), principal, newMetadata, stage()); !errors.Is(err, core.ErrInvalid) {
|
||||
t.Fatalf("closed claim new upload error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
@@ -494,6 +529,12 @@ func newTestStore(t *testing.T) (*sql.DB, *Store) {
|
||||
func insertAttemptFixture(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
timestamp := "2026-08-04T00:00:00Z"
|
||||
tokenHash := sha256.Sum256([]byte("evidence-device-token"))
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'evidence device', ?, 'ACTIVE', ?, NULL)`, testDeviceID, tokenHash[:], timestamp); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, testTaskID, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
@@ -503,6 +544,15 @@ func insertAttemptFixture(t *testing.T, database *sql.DB) {
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, testAttemptID, testTaskID, testAuthID, timestamp); err != nil {
|
||||
t.Fatalf("insert attempt: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, '63c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
|
||||
1, '123', 'black', 'M', 1, '1.00', ?, ?, ?, '2026-08-04T02:00:00Z', ?, NULL)`,
|
||||
testAttemptID, testTaskID, testAuthID, testDeviceID, timestamp, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), timestamp); err != nil {
|
||||
t.Fatalf("insert claim: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testMetadata(hash string) core.UploadMetadata {
|
||||
|
||||
@@ -0,0 +1,823 @@
|
||||
package taskclaim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
taskmodel "cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
|
||||
const writeTimeout = 2 * time.Second
|
||||
|
||||
type Store struct {
|
||||
database *sql.DB
|
||||
secret []byte
|
||||
leaseTTL time.Duration
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
randomMu sync.Mutex
|
||||
writeGate chan struct{}
|
||||
// The unexported linearization hooks let package tests coordinate real SQLite
|
||||
// transactions at the first write. Production construction always leaves them nil.
|
||||
beforeLinearization func()
|
||||
afterLinearization func()
|
||||
}
|
||||
|
||||
func NewStore(database *sql.DB, secret []byte, leaseTTL time.Duration) (*Store, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("task claim database is required")
|
||||
}
|
||||
if len(secret) != sha256.Size {
|
||||
return nil, errors.New("task claim secret must be 32 bytes")
|
||||
}
|
||||
if leaseTTL <= 0 {
|
||||
return nil, errors.New("task claim lease TTL must be positive")
|
||||
}
|
||||
if _, err := database.Exec("SELECT attempt_id, claim_nonce, claim_token_sha256 FROM purchase_attempt_claims LIMIT 1"); err != nil {
|
||||
return nil, errors.New("task claim migration is not available")
|
||||
}
|
||||
store := &Store{
|
||||
database: database, secret: append([]byte(nil), secret...), leaseTTL: leaseTTL,
|
||||
now: time.Now, random: rand.Reader, writeGate: make(chan struct{}, 1),
|
||||
}
|
||||
if err := store.validateSecretIsolation(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.validateStoredClaims(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// validateSecretIsolation ensures the HMAC key cannot also authenticate a device. The session
|
||||
// secret comparison is performed while parsing configuration, before either secret is discarded.
|
||||
func (store *Store) validateSecretIsolation() error {
|
||||
digest := sha256.Sum256(store.secret)
|
||||
var count int
|
||||
if err := store.database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE token_sha256 = ?`, digest[:]).Scan(&count); err != nil {
|
||||
return errors.New("validate task claim secret isolation")
|
||||
}
|
||||
if count != 0 {
|
||||
return errors.New("task claim secret must be isolated from device credentials")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateStoredClaims covers open and closed claims. Replacing the secret must fail startup;
|
||||
// silently signing a new token would destroy idempotent recovery and the ownership audit chain.
|
||||
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,
|
||||
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.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,
|
||||
attempts.claim_generation, attempts.status, authorizations.status, tasks.status
|
||||
FROM purchase_attempt_claims AS claims
|
||||
LEFT JOIN purchase_attempts AS attempts ON attempts.id = claims.attempt_id
|
||||
LEFT JOIN order_authorizations AS authorizations ON authorizations.id = claims.authorization_id
|
||||
LEFT JOIN tasks ON tasks.id = claims.task_id
|
||||
ORDER BY claims.attempt_id`)
|
||||
if err != nil {
|
||||
return errors.New("validate stored task claims")
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var deviceID, taskID, authorizationID, attemptID string
|
||||
var generation, authorizationTaskVersion, quantity int
|
||||
var nonce, storedHash []byte
|
||||
var nonceType, hashType, title, goodsID, color, size, price, expires string
|
||||
var nonceLength, hashLength int
|
||||
var closed, attemptStatus, authorizationStatus, taskStatus sql.NullString
|
||||
var attemptGeneration sql.NullInt64
|
||||
if err := rows.Scan(&deviceID, &taskID, &authorizationID, &attemptID, &generation,
|
||||
&nonce, &nonceType, &nonceLength, &storedHash, &hashType, &hashLength,
|
||||
&title, &authorizationTaskVersion, &goodsID, &color, &size, &quantity, &price, &expires, &closed,
|
||||
&attemptGeneration, &attemptStatus, &authorizationStatus, &taskStatus); err != nil {
|
||||
return errors.New("validate stored task claims")
|
||||
}
|
||||
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(taskID) || !validUUID(authorizationID) || !validUUID(attemptID) ||
|
||||
generation <= 0 || nonceType != "blob" || nonceLength != sha256.Size || len(nonce) != sha256.Size ||
|
||||
hashType != "blob" || hashLength != sha256.Size || len(storedHash) != sha256.Size ||
|
||||
authorizationTaskVersion <= 0 || !taskmodel.ValidTaskWireFields(title, goodsID, color, size, price) || quantity <= 0 ||
|
||||
!validCanonicalTime(expires) || (closed.Valid && !validCanonicalTime(closed.String)) ||
|
||||
!attemptGeneration.Valid || attemptGeneration.Int64 != int64(generation) ||
|
||||
!validAttemptStatus(attemptStatus) || !validAuthorizationStatus(authorizationStatus) || !validTaskStatus(taskStatus) {
|
||||
return errors.New("stored task claim metadata is invalid")
|
||||
}
|
||||
token := deriveToken(store.secret, deviceID, taskID, authorizationID, attemptID, generation, nonce)
|
||||
if !matchingHash(tokenHash(token), storedHash) {
|
||||
return errors.New("task claim secret does not match stored claims")
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return errors.New("validate stored task claims")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) ClaimNext(ctx context.Context, deviceID string, command ClaimCommand) (ClaimResponse, bool, error) {
|
||||
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(command.SessionID) || !validUUID(command.ClaimRequestID) {
|
||||
return ClaimResponse{}, false, ErrInvalid
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(ctx, writeTimeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case store.writeGate <- struct{}{}:
|
||||
defer func() { <-store.writeGate }()
|
||||
case <-writeCtx.Done():
|
||||
return ClaimResponse{}, false, writeCtx.Err()
|
||||
}
|
||||
|
||||
transaction, err := store.database.BeginTx(writeCtx, nil)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
|
||||
// This must be the transaction's first database statement. The no-op conditional UPDATE takes
|
||||
// SQLite's write position and linearizes a concurrent credential revocation before any replay,
|
||||
// EMPTY response, conflict response, candidate read, or other business write is possible.
|
||||
if store.beforeLinearization != nil {
|
||||
store.beforeLinearization()
|
||||
}
|
||||
active, err := transaction.ExecContext(writeCtx, `UPDATE device_credentials SET status = status
|
||||
WHERE device_id = ? AND status = 'ACTIVE' AND revoked_at IS NULL`, deviceID)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if ok, err := exactlyOne(active); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
} else if !ok {
|
||||
return ClaimResponse{}, false, ErrDeviceInactive
|
||||
}
|
||||
if store.afterLinearization != nil {
|
||||
store.afterLinearization()
|
||||
}
|
||||
|
||||
now, err := store.serverNow()
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
request, found, err := findClaimRequest(writeCtx, transaction, command.ClaimRequestID)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if found {
|
||||
if request.DeviceID != deviceID || request.SessionID != command.SessionID {
|
||||
return ClaimResponse{}, false, ErrIdempotencyConflict
|
||||
}
|
||||
switch request.Outcome {
|
||||
case "EMPTY":
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return ClaimResponse{}, false, nil
|
||||
case "BLOCKED":
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return ClaimResponse{}, false, ErrRequiresManual
|
||||
case "CLAIMED":
|
||||
record, found, err := store.loadClaimByAttempt(writeCtx, transaction, request.AttemptID)
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = errors.New("stored claim request has no claim")
|
||||
}
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
response, err := store.responseFor(record, request.ResponseLeaseExpiresAt)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return response, true, nil
|
||||
default:
|
||||
return ClaimResponse{}, false, errors.New("stored claim request outcome is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
existing, found, err := store.loadOpenClaimByDevice(writeCtx, transaction, deviceID)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if found {
|
||||
current := existing.SessionID == command.SessionID && existing.ClosedAt == "" &&
|
||||
existing.LeaseExpiresAt.After(now) && existing.AuthorizationExpiresAt.After(now) &&
|
||||
existing.CurrentAuthorizationExpiresAt.After(now) && existing.AuthorizationStatus == "CLAIMED" &&
|
||||
existing.authorizationConsistent() && existing.recoverableBusinessState()
|
||||
if !current {
|
||||
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "BLOCKED", "", "", "manual_recovery_required", now); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return ClaimResponse{}, false, ErrRequiresManual
|
||||
}
|
||||
response, err := store.responseFor(existing, existing.LeaseExpiresText)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "CLAIMED", existing.AttemptID, existing.LeaseExpiresText, "", now); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return response, true, nil
|
||||
}
|
||||
|
||||
candidate, found, err := findCandidate(writeCtx, transaction, now)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if !found {
|
||||
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "EMPTY", "", "", "", now); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return ClaimResponse{}, false, nil
|
||||
}
|
||||
|
||||
generation, err := nextGeneration(writeCtx, transaction, candidate.TaskID)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
attemptID, err := store.newUUID()
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
nonce, err := store.randomBytes(sha256.Size)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
token := deriveToken(store.secret, deviceID, candidate.TaskID, candidate.AuthorizationID, attemptID, generation, nonce)
|
||||
storedTokenHash := tokenHash(token)
|
||||
leaseExpires := now.Add(store.leaseTTL)
|
||||
if candidate.AuthorizationExpiresAt.Before(leaseExpires) {
|
||||
leaseExpires = candidate.AuthorizationExpiresAt
|
||||
}
|
||||
leaseText := formatTime(leaseExpires)
|
||||
nowText := formatTime(now)
|
||||
|
||||
authorizationUpdate, err := transaction.ExecContext(writeCtx, `UPDATE order_authorizations SET status = 'CLAIMED'
|
||||
WHERE id = ? AND task_id = ? AND status = 'ACTIVE' AND task_version = ?
|
||||
AND goods_id = ? AND sku_color = ? AND sku_size = ? AND quantity = ?
|
||||
AND total_price_cap = ? AND expires_at = ?`,
|
||||
candidate.AuthorizationID, candidate.TaskID, candidate.TaskVersion, candidate.GoodsID,
|
||||
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap,
|
||||
candidate.AuthorizationExpiresText)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if ok, err := exactlyOne(authorizationUpdate); err != nil || !ok {
|
||||
if err == nil {
|
||||
err = errors.New("authorization changed during claim")
|
||||
}
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
taskUpdate, err := transaction.ExecContext(writeCtx, `UPDATE tasks SET status = 'CLAIMED', version = version + 1, updated_at = ?
|
||||
WHERE id = ? AND status = 'PENDING' AND version = ? AND title = ? AND goods_id = ?
|
||||
AND sku_color = ? AND sku_size = ? AND quantity = ? AND max_total_price = ?`,
|
||||
nowText, candidate.TaskID, candidate.TaskVersion, candidate.Title, candidate.GoodsID,
|
||||
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if ok, err := exactlyOne(taskUpdate); err != nil || !ok {
|
||||
if err == nil {
|
||||
err = errors.New("task changed during claim")
|
||||
}
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempts
|
||||
(id, task_id, authorization_id, claim_generation, status, started_at)
|
||||
VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, attemptID, candidate.TaskID, candidate.AuthorizationID, generation, nowText); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempt_claims
|
||||
(attempt_id, task_id, authorization_id, claimed_by_device_id, session_id, claim_generation,
|
||||
task_version, task_title, authorization_task_version, goods_id, sku_color, sku_size, quantity,
|
||||
total_price_cap, authorization_expires_at, claim_nonce, claim_token_sha256,
|
||||
lease_expires_at, claimed_at, closed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
|
||||
attemptID, candidate.TaskID, candidate.AuthorizationID, deviceID, command.SessionID, generation,
|
||||
candidate.TaskVersion+1, candidate.Title, candidate.TaskVersion, candidate.GoodsID,
|
||||
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap,
|
||||
candidate.AuthorizationExpiresText, nonce, storedTokenHash, leaseText, nowText); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "CLAIMED", attemptID, leaseText, "", now); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
response := ClaimResponse{
|
||||
Task: ClaimedTask{ID: candidate.TaskID, Version: candidate.TaskVersion + 1, Title: candidate.Title,
|
||||
ProductURL: productURL(candidate.GoodsID), GoodsID: candidate.GoodsID, SKUColor: candidate.SKUColor,
|
||||
SKUSize: candidate.SKUSize, Quantity: candidate.Quantity, MaxTotalPrice: candidate.TotalPriceCap},
|
||||
Authorization: ClaimedAuthorization{ID: candidate.AuthorizationID, TaskVersion: candidate.TaskVersion, ExpiresAt: candidate.AuthorizationExpiresText},
|
||||
Attempt: ClaimedAttempt{ID: attemptID, ClaimToken: hex.EncodeToString(token), ClaimGeneration: generation, LeaseExpiresAt: leaseText},
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return response, true, nil
|
||||
}
|
||||
|
||||
func (store *Store) Renew(ctx context.Context, deviceID string, command RenewCommand) (RenewResponse, error) {
|
||||
providedToken, tokenOK := decodeToken(command.ClaimToken)
|
||||
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(command.TaskID) || !validUUID(command.RenewRequestID) ||
|
||||
!validUUID(command.SessionID) || !validUUID(command.AttemptID) || command.ClaimGeneration <= 0 ||
|
||||
!tokenOK || !validCanonicalTime(command.ExpectedLeaseExpiresAt) {
|
||||
return RenewResponse{}, ErrInvalid
|
||||
}
|
||||
providedHash := tokenHash(providedToken)
|
||||
writeCtx, cancel := context.WithTimeout(ctx, writeTimeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case store.writeGate <- struct{}{}:
|
||||
defer func() { <-store.writeGate }()
|
||||
case <-writeCtx.Done():
|
||||
return RenewResponse{}, writeCtx.Err()
|
||||
}
|
||||
transaction, err := store.database.BeginTx(writeCtx, nil)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
|
||||
// As in ClaimNext, this is deliberately the first database statement in the transaction.
|
||||
if store.beforeLinearization != nil {
|
||||
store.beforeLinearization()
|
||||
}
|
||||
active, err := transaction.ExecContext(writeCtx, `UPDATE device_credentials SET status = status
|
||||
WHERE device_id = ? AND status = 'ACTIVE' AND revoked_at IS NULL`, deviceID)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if ok, err := exactlyOne(active); err != nil {
|
||||
return RenewResponse{}, err
|
||||
} else if !ok {
|
||||
return RenewResponse{}, ErrDeviceInactive
|
||||
}
|
||||
if store.afterLinearization != nil {
|
||||
store.afterLinearization()
|
||||
}
|
||||
|
||||
renewal, found, err := findRenewal(writeCtx, transaction, command.RenewRequestID)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if found {
|
||||
if renewal.TaskID != command.TaskID || renewal.AttemptID != command.AttemptID || renewal.DeviceID != deviceID ||
|
||||
renewal.SessionID != command.SessionID || renewal.Generation != command.ClaimGeneration ||
|
||||
renewal.ExpectedLeaseExpiresAt != command.ExpectedLeaseExpiresAt || !matchingHash(renewal.TokenHash, providedHash) {
|
||||
return RenewResponse{}, ErrIdempotencyConflict
|
||||
}
|
||||
response := RenewResponse{TaskID: renewal.TaskID, AttemptID: renewal.AttemptID, ClaimGeneration: renewal.Generation, LeaseExpiresAt: renewal.LeaseExpiresAt}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
now, err := store.serverNow()
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
record, found, err := store.loadClaimByAttempt(writeCtx, transaction, command.AttemptID)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if !found || record.TaskID != command.TaskID || record.DeviceID != deviceID || record.SessionID != command.SessionID ||
|
||||
record.Generation != command.ClaimGeneration || !matchingHash(record.TokenHash, providedHash) {
|
||||
return RenewResponse{}, ErrNotCurrent
|
||||
}
|
||||
stateCurrent := record.ClosedAt == "" && record.LeaseExpiresAt.After(now) && record.AuthorizationExpiresAt.After(now) &&
|
||||
record.CurrentAuthorizationExpiresAt.After(now) && record.AuthorizationStatus == "CLAIMED" &&
|
||||
record.authorizationConsistent() && record.recoverableBusinessState()
|
||||
if !stateCurrent || record.LeaseExpiresText != command.ExpectedLeaseExpiresAt {
|
||||
return RenewResponse{}, ErrNotCurrent
|
||||
}
|
||||
leaseExpires := now.Add(store.leaseTTL)
|
||||
if record.AuthorizationExpiresAt.Before(leaseExpires) {
|
||||
leaseExpires = record.AuthorizationExpiresAt
|
||||
}
|
||||
leaseText := formatTime(leaseExpires)
|
||||
updated, err := transaction.ExecContext(writeCtx, `UPDATE purchase_attempt_claims SET lease_expires_at = ?
|
||||
WHERE attempt_id = ? AND lease_expires_at = ? AND closed_at IS NULL`, leaseText, command.AttemptID, command.ExpectedLeaseExpiresAt)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if ok, err := exactlyOne(updated); err != nil || !ok {
|
||||
if err == nil {
|
||||
err = ErrNotCurrent
|
||||
}
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempt_lease_renewals
|
||||
(renew_request_id, task_id, attempt_id, device_id, session_id, claim_generation,
|
||||
claim_token_sha256, expected_lease_expires_at, lease_expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
command.RenewRequestID, command.TaskID, command.AttemptID, deviceID, command.SessionID,
|
||||
command.ClaimGeneration, record.TokenHash, command.ExpectedLeaseExpiresAt, leaseText, formatTime(now)); err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
response := RenewResponse{TaskID: command.TaskID, AttemptID: command.AttemptID, ClaimGeneration: command.ClaimGeneration, LeaseExpiresAt: leaseText}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type claimRequestRecord struct {
|
||||
DeviceID, SessionID, Outcome, AttemptID, ResponseLeaseExpiresAt string
|
||||
}
|
||||
|
||||
func findClaimRequest(ctx context.Context, transaction *sql.Tx, requestID string) (claimRequestRecord, bool, error) {
|
||||
var record claimRequestRecord
|
||||
var attemptID, responseLease sql.NullString
|
||||
err := transaction.QueryRowContext(ctx, `SELECT device_id, session_id, outcome, attempt_id, response_lease_expires_at
|
||||
FROM task_claim_requests WHERE claim_request_id = ?`, requestID).
|
||||
Scan(&record.DeviceID, &record.SessionID, &record.Outcome, &attemptID, &responseLease)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return claimRequestRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return claimRequestRecord{}, false, err
|
||||
}
|
||||
record.AttemptID, record.ResponseLeaseExpiresAt = attemptID.String, responseLease.String
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func insertClaimRequest(ctx context.Context, transaction *sql.Tx, requestID, deviceID, sessionID, outcome, attemptID, responseLease, errorCode string, now time.Time) error {
|
||||
var attempt, lease, code any
|
||||
if attemptID != "" {
|
||||
attempt = attemptID
|
||||
}
|
||||
if responseLease != "" {
|
||||
lease = responseLease
|
||||
}
|
||||
if errorCode != "" {
|
||||
code = errorCode
|
||||
}
|
||||
_, err := transaction.ExecContext(ctx, `INSERT INTO task_claim_requests
|
||||
(claim_request_id, device_id, session_id, outcome, attempt_id, response_lease_expires_at, error_code, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, requestID, deviceID, sessionID, outcome, attempt, lease, code, formatTime(now))
|
||||
return err
|
||||
}
|
||||
|
||||
type renewalRecord struct {
|
||||
TaskID, AttemptID, DeviceID, SessionID string
|
||||
Generation int
|
||||
TokenHash []byte
|
||||
ExpectedLeaseExpiresAt, LeaseExpiresAt string
|
||||
}
|
||||
|
||||
func findRenewal(ctx context.Context, transaction *sql.Tx, requestID string) (renewalRecord, bool, error) {
|
||||
var record renewalRecord
|
||||
err := transaction.QueryRowContext(ctx, `SELECT task_id, attempt_id, device_id, session_id,
|
||||
claim_generation, claim_token_sha256, expected_lease_expires_at, lease_expires_at
|
||||
FROM purchase_attempt_lease_renewals WHERE renew_request_id = ?`, requestID).
|
||||
Scan(&record.TaskID, &record.AttemptID, &record.DeviceID, &record.SessionID, &record.Generation,
|
||||
&record.TokenHash, &record.ExpectedLeaseExpiresAt, &record.LeaseExpiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return renewalRecord{}, false, nil
|
||||
}
|
||||
return record, err == nil, err
|
||||
}
|
||||
|
||||
type claimRecord struct {
|
||||
AttemptID, TaskID, AuthorizationID, DeviceID, SessionID string
|
||||
Generation, TaskVersion, CurrentTaskVersion int
|
||||
TaskTitle string
|
||||
Nonce, TokenHash []byte
|
||||
LeaseExpiresText, ClaimedAt, ClosedAt string
|
||||
LeaseExpiresAt time.Time
|
||||
AuthorizationTaskVersion int
|
||||
GoodsID, SKUColor, SKUSize, TotalPriceCap string
|
||||
Quantity int
|
||||
AuthorizationExpiresText, AuthorizationStatus string
|
||||
AuthorizationExpiresAt time.Time
|
||||
CurrentAuthorizationTaskVersion int
|
||||
CurrentGoodsID, CurrentSKUColor, CurrentSKUSize string
|
||||
CurrentQuantity int
|
||||
CurrentTotalPriceCap, CurrentAuthorizationExpiresText string
|
||||
CurrentAuthorizationExpiresAt time.Time
|
||||
AttemptStatus, TaskStatus string
|
||||
CurrentTaskTitle, CurrentTaskGoodsID string
|
||||
CurrentTaskSKUColor, CurrentTaskSKUSize string
|
||||
CurrentTaskQuantity int
|
||||
CurrentTaskMaxTotalPrice string
|
||||
CurrentAttemptGeneration int
|
||||
}
|
||||
|
||||
const claimSelect = `SELECT claims.attempt_id, claims.task_id, claims.authorization_id,
|
||||
claims.claimed_by_device_id, claims.session_id, claims.claim_generation, claims.task_version,
|
||||
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.claim_nonce, claims.claim_token_sha256, claims.lease_expires_at,
|
||||
claims.claimed_at, claims.closed_at, authorizations.task_version, authorizations.goods_id,
|
||||
authorizations.sku_color, authorizations.sku_size, authorizations.quantity,
|
||||
authorizations.total_price_cap, authorizations.expires_at, authorizations.status,
|
||||
attempts.claim_generation, attempts.status, tasks.status, tasks.version, tasks.title, tasks.goods_id,
|
||||
tasks.sku_color, tasks.sku_size, tasks.quantity, tasks.max_total_price
|
||||
FROM purchase_attempt_claims AS claims
|
||||
JOIN order_authorizations AS authorizations
|
||||
ON authorizations.task_id = claims.task_id AND authorizations.id = claims.authorization_id
|
||||
JOIN purchase_attempts AS attempts ON attempts.id = claims.attempt_id
|
||||
JOIN tasks ON tasks.id = claims.task_id `
|
||||
|
||||
func (store *Store) loadOpenClaimByDevice(ctx context.Context, transaction *sql.Tx, deviceID string) (claimRecord, bool, error) {
|
||||
return store.scanClaim(transaction.QueryRowContext(ctx, claimSelect+`WHERE claims.claimed_by_device_id = ? AND claims.closed_at IS NULL`, deviceID))
|
||||
}
|
||||
|
||||
func (store *Store) loadClaimByAttempt(ctx context.Context, transaction *sql.Tx, attemptID string) (claimRecord, bool, error) {
|
||||
return store.scanClaim(transaction.QueryRowContext(ctx, claimSelect+`WHERE claims.attempt_id = ?`, attemptID))
|
||||
}
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) {
|
||||
var record claimRecord
|
||||
var closed sql.NullString
|
||||
err := row.Scan(&record.AttemptID, &record.TaskID, &record.AuthorizationID, &record.DeviceID,
|
||||
&record.SessionID, &record.Generation, &record.TaskVersion, &record.TaskTitle,
|
||||
&record.AuthorizationTaskVersion, &record.GoodsID, &record.SKUColor, &record.SKUSize,
|
||||
&record.Quantity, &record.TotalPriceCap, &record.AuthorizationExpiresText,
|
||||
&record.Nonce, &record.TokenHash, &record.LeaseExpiresText, &record.ClaimedAt, &closed,
|
||||
&record.CurrentAuthorizationTaskVersion, &record.CurrentGoodsID, &record.CurrentSKUColor,
|
||||
&record.CurrentSKUSize, &record.CurrentQuantity, &record.CurrentTotalPriceCap,
|
||||
&record.CurrentAuthorizationExpiresText,
|
||||
&record.AuthorizationStatus, &record.CurrentAttemptGeneration, &record.AttemptStatus,
|
||||
&record.TaskStatus, &record.CurrentTaskVersion,
|
||||
&record.CurrentTaskTitle, &record.CurrentTaskGoodsID, &record.CurrentTaskSKUColor,
|
||||
&record.CurrentTaskSKUSize, &record.CurrentTaskQuantity, &record.CurrentTaskMaxTotalPrice)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return claimRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return claimRecord{}, false, err
|
||||
}
|
||||
record.ClosedAt = closed.String
|
||||
if !validUUID(record.AttemptID) || !validUUID(record.TaskID) || !validUUID(record.AuthorizationID) ||
|
||||
!deviceauth.ValidDeviceID(record.DeviceID) || !validUUID(record.SessionID) || record.Generation <= 0 ||
|
||||
record.CurrentAttemptGeneration != record.Generation ||
|
||||
record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 ||
|
||||
!taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) ||
|
||||
record.Quantity <= 0 || len(record.Nonce) != sha256.Size || len(record.TokenHash) != sha256.Size {
|
||||
return claimRecord{}, false, errors.New("stored task claim metadata is invalid")
|
||||
}
|
||||
record.LeaseExpiresAt, err = parseCanonicalTime(record.LeaseExpiresText)
|
||||
if err != nil {
|
||||
return claimRecord{}, false, errors.New("stored task claim lease is invalid")
|
||||
}
|
||||
record.AuthorizationExpiresAt, err = parseCanonicalTime(record.AuthorizationExpiresText)
|
||||
if err != nil {
|
||||
return claimRecord{}, false, errors.New("stored authorization expiry is invalid")
|
||||
}
|
||||
record.CurrentAuthorizationExpiresAt, err = parseCanonicalTime(record.CurrentAuthorizationExpiresText)
|
||||
if err != nil {
|
||||
return claimRecord{}, false, errors.New("current authorization expiry is invalid")
|
||||
}
|
||||
derived := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce)
|
||||
if !matchingHash(tokenHash(derived), record.TokenHash) {
|
||||
return claimRecord{}, false, errors.New("task claim secret does not match stored claim")
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (record claimRecord) authorizationConsistent() bool {
|
||||
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.SKUSize == record.CurrentSKUSize && record.Quantity == record.CurrentQuantity &&
|
||||
record.TotalPriceCap == record.CurrentTotalPriceCap &&
|
||||
record.AuthorizationExpiresText == record.CurrentAuthorizationExpiresText &&
|
||||
record.TaskTitle == record.CurrentTaskTitle && record.GoodsID == record.CurrentTaskGoodsID &&
|
||||
record.SKUColor == record.CurrentTaskSKUColor && record.SKUSize == record.CurrentTaskSKUSize &&
|
||||
record.Quantity == record.CurrentTaskQuantity && record.TotalPriceCap == record.CurrentTaskMaxTotalPrice
|
||||
}
|
||||
|
||||
func (record claimRecord) recoverableBusinessState() bool {
|
||||
if record.TaskStatus == "CLAIMED" && record.AttemptStatus == "CLAIMED" {
|
||||
return record.CurrentTaskVersion == record.TaskVersion
|
||||
}
|
||||
// A later server task may advance this same attempt to ORDERING. A valid lease and identical
|
||||
// ownership recover that attempt; claim-next still cannot select another task.
|
||||
return record.TaskStatus == "ORDERING" && record.AttemptStatus == "ORDERING" &&
|
||||
record.TaskVersion < math.MaxInt && record.CurrentTaskVersion == record.TaskVersion+1
|
||||
}
|
||||
|
||||
func (store *Store) responseFor(record claimRecord, responseLease string) (ClaimResponse, error) {
|
||||
// Exact idempotent replay is allowed to ignore later source-row drift, but the
|
||||
// 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)
|
||||
return ClaimResponse{
|
||||
Task: ClaimedTask{ID: record.TaskID, Version: record.TaskVersion, Title: record.TaskTitle,
|
||||
ProductURL: productURL(record.GoodsID), GoodsID: record.GoodsID, SKUColor: record.SKUColor,
|
||||
SKUSize: record.SKUSize, Quantity: record.Quantity, MaxTotalPrice: record.TotalPriceCap},
|
||||
Authorization: ClaimedAuthorization{ID: record.AuthorizationID, TaskVersion: record.AuthorizationTaskVersion, ExpiresAt: record.AuthorizationExpiresText},
|
||||
Attempt: ClaimedAttempt{ID: record.AttemptID, ClaimToken: hex.EncodeToString(token), ClaimGeneration: record.Generation, LeaseExpiresAt: responseLease},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string
|
||||
TaskVersion, Quantity int
|
||||
AuthorizationTaskVersion, AuthorizationQuantity int
|
||||
AuthorizationGoodsID, AuthorizationSKUColor, AuthorizationSKUSize string
|
||||
AuthorizationTotalPriceCap string
|
||||
AuthorizationExpiresText string
|
||||
AuthorizationExpiresAt time.Time
|
||||
}
|
||||
|
||||
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,
|
||||
tasks.title, tasks.goods_id, tasks.sku_color, tasks.sku_size, tasks.quantity,
|
||||
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
|
||||
JOIN tasks ON tasks.id = authorizations.task_id
|
||||
WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING'
|
||||
ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`)
|
||||
if err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item candidate
|
||||
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title,
|
||||
&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 {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
item.AuthorizationExpiresAt, err = parseCanonicalTime(item.AuthorizationExpiresText)
|
||||
if err != nil {
|
||||
return candidate{}, false, errors.New("stored authorization expiry is invalid")
|
||||
}
|
||||
if !validCandidate(item) {
|
||||
return candidate{}, false, errors.New("stored claim candidate is invalid")
|
||||
}
|
||||
if !candidateSnapshotMatches(item) {
|
||||
continue
|
||||
}
|
||||
if item.AuthorizationExpiresAt.After(now) {
|
||||
if err := rows.Close(); err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
return item, true, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
return candidate{}, false, nil
|
||||
}
|
||||
|
||||
func validCandidate(item candidate) bool {
|
||||
return validUUID(item.AuthorizationID) && validUUID(item.TaskID) && item.TaskVersion > 0 && item.TaskVersion < math.MaxInt &&
|
||||
taskmodel.ValidTaskWireFields(item.Title, item.GoodsID, item.SKUColor, item.SKUSize, 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 {
|
||||
return value.Valid && oneOf(value.String, "CLAIMED", "ORDERING", "FAILED", "FENCED", "ABANDONED")
|
||||
}
|
||||
|
||||
func validAuthorizationStatus(value sql.NullString) bool {
|
||||
return value.Valid && oneOf(value.String, "ACTIVE", "CLAIMED", "FENCED", "CONSUMED", "EXPIRED", "ABANDONED")
|
||||
}
|
||||
|
||||
func validTaskStatus(value sql.NullString) bool {
|
||||
return value.Valid && oneOf(value.String, "DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL",
|
||||
"WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED")
|
||||
}
|
||||
|
||||
func oneOf(value string, allowed ...string) bool {
|
||||
for _, item := range allowed {
|
||||
if value == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nextGeneration(ctx context.Context, transaction *sql.Tx, taskID string) (int, error) {
|
||||
var maximum int64
|
||||
if err := transaction.QueryRowContext(ctx, `SELECT COALESCE(MAX(claim_generation), 0) FROM purchase_attempts WHERE task_id = ?`, taskID).Scan(&maximum); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if maximum < 0 || maximum >= int64(math.MaxInt) {
|
||||
return 0, errors.New("task claim generation is exhausted")
|
||||
}
|
||||
return int(maximum) + 1, nil
|
||||
}
|
||||
|
||||
func (store *Store) serverNow() (time.Time, error) {
|
||||
now := store.now().UTC()
|
||||
if now.IsZero() {
|
||||
return time.Time{}, errors.New("task claim clock is invalid")
|
||||
}
|
||||
return now, nil
|
||||
}
|
||||
|
||||
func (store *Store) randomBytes(size int) ([]byte, error) {
|
||||
value := make([]byte, size)
|
||||
store.randomMu.Lock()
|
||||
_, err := io.ReadFull(store.random, value)
|
||||
store.randomMu.Unlock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate task claim randomness: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (store *Store) newUUID() (string, error) {
|
||||
value, err := store.randomBytes(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value[6] = (value[6] & 0x0f) | 0x40
|
||||
value[8] = (value[8] & 0x3f) | 0x80
|
||||
encoded := hex.EncodeToString(value)
|
||||
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil
|
||||
}
|
||||
|
||||
func exactlyOne(result sql.Result) (bool, error) {
|
||||
rows, err := result.RowsAffected()
|
||||
return rows == 1, err
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string { return value.UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
func parseCanonicalTime(value string) (time.Time, error) {
|
||||
if !strings.HasSuffix(value, "Z") || strings.TrimSpace(value) != value {
|
||||
return time.Time{}, ErrInvalid
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil || parsed.Location() != time.UTC || formatTime(parsed) != value {
|
||||
return time.Time{}, ErrInvalid
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validCanonicalTime(value string) bool {
|
||||
_, err := parseCanonicalTime(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
||||
}
|
||||
|
||||
func productURL(goodsID string) string {
|
||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
package taskclaim
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
)
|
||||
|
||||
const tokenDomain = "cmbuyer/task-claim-token/v1\x00"
|
||||
|
||||
func deriveToken(secret []byte, deviceID, taskID, authorizationID, attemptID string, generation int, nonce []byte) []byte {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(tokenDomain))
|
||||
writeTokenField(mac, deviceID)
|
||||
writeTokenField(mac, taskID)
|
||||
writeTokenField(mac, authorizationID)
|
||||
writeTokenField(mac, attemptID)
|
||||
var number [8]byte
|
||||
binary.BigEndian.PutUint64(number[:], uint64(generation))
|
||||
_, _ = mac.Write(number[:])
|
||||
writeTokenBytes(mac, nonce)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func writeTokenField(writer hash.Hash, value string) { writeTokenBytes(writer, []byte(value)) }
|
||||
|
||||
func writeTokenBytes(writer hash.Hash, value []byte) {
|
||||
var size [4]byte
|
||||
binary.BigEndian.PutUint32(size[:], uint32(len(value)))
|
||||
_, _ = writer.Write(size[:])
|
||||
_, _ = writer.Write(value)
|
||||
}
|
||||
|
||||
func tokenHash(token []byte) []byte {
|
||||
sum := sha256.Sum256(token)
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func matchingHash(left, right []byte) bool {
|
||||
return len(left) == sha256.Size && len(right) == sha256.Size && subtle.ConstantTimeCompare(left, right) == 1
|
||||
}
|
||||
|
||||
func decodeToken(value string) ([]byte, bool) {
|
||||
if len(value) != sha256.Size*2 {
|
||||
return nil, false
|
||||
}
|
||||
decoded, err := hex.DecodeString(value)
|
||||
return decoded, err == nil && hex.EncodeToString(decoded) == value
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package taskclaim owns the atomic task-claim and lease-renewal boundary.
|
||||
// A claim token proves only ownership of one attempt; it is never permission to submit an order.
|
||||
package taskclaim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
|
||||
taskmodel "cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("invalid task claim request")
|
||||
ErrIdempotencyConflict = errors.New("task claim idempotency conflict")
|
||||
ErrRequiresManual = errors.New("task claim requires manual recovery")
|
||||
ErrNotCurrent = errors.New("task claim is not current")
|
||||
ErrDeviceInactive = errors.New("task claim device is inactive")
|
||||
)
|
||||
|
||||
type ClaimCommand struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ClaimRequestID string `json:"claim_request_id"`
|
||||
}
|
||||
|
||||
type RenewCommand struct {
|
||||
TaskID string `json:"-"`
|
||||
RenewRequestID string `json:"renew_request_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
ClaimGeneration int `json:"claim_generation"`
|
||||
ClaimToken string `json:"claim_token"`
|
||||
ExpectedLeaseExpiresAt string `json:"expected_lease_expires_at"`
|
||||
}
|
||||
|
||||
type ClaimedTask struct {
|
||||
ID string `json:"id"`
|
||||
Version int `json:"version"`
|
||||
Title string `json:"title"`
|
||||
ProductURL string `json:"product_url"`
|
||||
GoodsID string `json:"goods_id"`
|
||||
SKUColor string `json:"sku_color"`
|
||||
SKUSize string `json:"sku_size"`
|
||||
Quantity int `json:"quantity"`
|
||||
MaxTotalPrice string `json:"max_total_price"`
|
||||
}
|
||||
|
||||
type ClaimedAuthorization struct {
|
||||
ID string `json:"id"`
|
||||
TaskVersion int `json:"task_version"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type ClaimedAttempt struct {
|
||||
ID string `json:"id"`
|
||||
ClaimToken string `json:"claim_token"`
|
||||
ClaimGeneration int `json:"claim_generation"`
|
||||
LeaseExpiresAt string `json:"lease_expires_at"`
|
||||
}
|
||||
|
||||
type ClaimResponse struct {
|
||||
Task ClaimedTask `json:"task"`
|
||||
Authorization ClaimedAuthorization `json:"authorization"`
|
||||
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 {
|
||||
TaskID string `json:"task_id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
ClaimGeneration int `json:"claim_generation"`
|
||||
LeaseExpiresAt string `json:"lease_expires_at"`
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
ClaimNext(context.Context, string, ClaimCommand) (ClaimResponse, bool, error)
|
||||
Renew(context.Context, string, RenewCommand) (RenewResponse, error)
|
||||
}
|
||||
@@ -14,14 +14,20 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailDevice = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
)
|
||||
|
||||
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
|
||||
database := openDetailDatabase(t)
|
||||
timestamp := "2026-08-04T00:00:00Z"
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'detail test device', zeroblob(32), 'ACTIVE', ?, NULL)`, detailDevice, timestamp); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'shirt', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 3, ?, ?)`, detailTask, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
@@ -31,8 +37,17 @@ func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, detailTry, detailTask, detailAuth, timestamp); err != nil {
|
||||
t.Fatalf("insert attempt: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, 'f3c9f507-7473-4fa6-8d71-8786c34c6301', 1, 3, 'shirt',
|
||||
2, '123', 'black', 'M', 2, '30.00', ?, zeroblob(32), zeroblob(32),
|
||||
'2026-08-04T00:05:00Z', ?, NULL)`, detailTry, detailTask, detailAuth, detailDevice, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert claim: %v", err)
|
||||
}
|
||||
hash := strings.Repeat("a", 64)
|
||||
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, 'device', ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", timestamp, timestamp); err != nil {
|
||||
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, ?, ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", detailDevice, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert evidence: %v", err)
|
||||
}
|
||||
store, err := NewSQLiteStore(database)
|
||||
|
||||
@@ -50,7 +50,7 @@ type TaskRow struct {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
parts := strings.Split(value, ".")
|
||||
@@ -71,6 +71,11 @@ func normalizeCents(value string) (string, *big.Int, bool) {
|
||||
return value, cents, true
|
||||
}
|
||||
|
||||
func ValidCanonicalMoney(value string) bool {
|
||||
canonical, _, ok := normalizeCents(value)
|
||||
return ok && canonical == value
|
||||
}
|
||||
|
||||
func startItems(command StartCommand) ([]StartItem, error) {
|
||||
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
|
||||
return nil, ErrInvalidStart
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"math"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -164,12 +165,27 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
|
||||
"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)
|
||||
},
|
||||
"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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
|
||||
@@ -142,7 +142,9 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
|
||||
}
|
||||
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
|
||||
}
|
||||
canonical, cents, ok := normalizeCents(price)
|
||||
@@ -179,20 +181,15 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
|
||||
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) {
|
||||
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 {
|
||||
return StartResult{}, false, err
|
||||
}
|
||||
@@ -201,9 +198,22 @@ func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []Start
|
||||
for rows.Next() {
|
||||
var item AuthorizedTask
|
||||
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
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return StartResult{}, false, err
|
||||
|
||||
@@ -37,6 +37,11 @@ func NewSQLiteStore(database *sql.DB) (*SQLiteStore, 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)
|
||||
defer cancel()
|
||||
// SQLite permits one writer at a time. Serializing this store's short create
|
||||
|
||||
@@ -9,14 +9,21 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTitleLength = 120
|
||||
maxSKUText = 80
|
||||
MaxTitleCodePoints = 120
|
||||
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 {
|
||||
ID string
|
||||
@@ -41,13 +48,13 @@ func Validate(form Form) (Draft, Errors) {
|
||||
if !validUUID(draft.ID) {
|
||||
errors["create_key"] = "创建请求已过期,请重新打开表单。"
|
||||
}
|
||||
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
|
||||
if !validBoundedText(draft.Title, MaxTitleCodePoints) {
|
||||
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
|
||||
}
|
||||
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
|
||||
if !validBoundedText(draft.SKUColor, MaxSKUTextCodePoints) {
|
||||
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
|
||||
}
|
||||
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
|
||||
if !validBoundedText(draft.SKUSize, MaxSKUTextCodePoints) {
|
||||
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
|
||||
}
|
||||
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
|
||||
@@ -93,6 +100,9 @@ func CanonicalGoodsID(value string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
if !ValidGoodsID(goodsIDs[0]) {
|
||||
return "", false
|
||||
}
|
||||
return goodsIDs[0], true
|
||||
}
|
||||
|
||||
@@ -155,5 +165,54 @@ func normalizeMoney(value string) (string, bool) {
|
||||
if whole == "0" && strings.Trim(fraction, "0") == "" {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -38,13 +39,16 @@ func TestValidateNormalizesManualDraft(t *testing.T) {
|
||||
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"}
|
||||
for name, update := range map[string]func(*Form){
|
||||
"empty title": func(form *Form) { form.Title = " " },
|
||||
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
|
||||
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
|
||||
"zero quantity": func(form *Form) { form.Quantity = "0" },
|
||||
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
|
||||
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
|
||||
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
|
||||
"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)) },
|
||||
"invalid utf8 size": func(form *Form) { form.SKUSize = string([]byte{0xff}) },
|
||||
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
|
||||
"zero quantity": func(form *Form) { form.Quantity = "0" },
|
||||
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
|
||||
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
|
||||
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
form := base
|
||||
@@ -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;uin=bad",
|
||||
"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 {
|
||||
t.Fatalf("CanonicalGoodsID accepted %q", value)
|
||||
@@ -75,19 +80,62 @@ func TestValidateRejectsInvalidFieldsAndURLs(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)
|
||||
if !ok || got != 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 {
|
||||
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) {
|
||||
key, err := NewCreateKey()
|
||||
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) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
-- +goose Up
|
||||
-- v4 中的 attempt、submission 或证据没有设备/session/租约归属,不能安全猜测成 claim。
|
||||
-- 在同一迁移事务中拒绝这类数据库,避免补出虚假的所有权审计链。
|
||||
CREATE TABLE task_claim_upgrade_guard (
|
||||
valid INTEGER NOT NULL CHECK (valid = 1)
|
||||
);
|
||||
|
||||
INSERT INTO task_claim_upgrade_guard (valid)
|
||||
SELECT CASE WHEN
|
||||
(SELECT COUNT(*) FROM purchase_attempts) = 0
|
||||
AND (SELECT COUNT(*) FROM order_submissions) = 0
|
||||
AND (SELECT COUNT(*) FROM evidence_assets) = 0
|
||||
THEN 1 ELSE 0 END;
|
||||
|
||||
DROP TABLE task_claim_upgrade_guard;
|
||||
|
||||
-- 该唯一索引把“一条授权只能产生一个 attempt”下沉到数据库;应用层检查不能替代它。
|
||||
CREATE UNIQUE INDEX purchase_attempts_one_per_authorization_idx
|
||||
ON purchase_attempts (authorization_id);
|
||||
|
||||
-- claim_generation 是 attempt lineage 的组成部分,不能只在应用层比较。
|
||||
CREATE UNIQUE INDEX purchase_attempts_claim_lineage_idx
|
||||
ON purchase_attempts (task_id, authorization_id, id, claim_generation);
|
||||
|
||||
CREATE TABLE purchase_attempt_claims (
|
||||
attempt_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
authorization_id TEXT NOT NULL UNIQUE,
|
||||
claimed_by_device_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL CHECK (
|
||||
length(session_id) = 36
|
||||
AND substr(session_id, 9, 1) = '-'
|
||||
AND substr(session_id, 14, 1) = '-'
|
||||
AND substr(session_id, 19, 1) = '-'
|
||||
AND substr(session_id, 24, 1) = '-'
|
||||
AND length(replace(session_id, '-', '')) = 32
|
||||
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
|
||||
AND substr(session_id, 15, 1) = '4'
|
||||
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
|
||||
),
|
||||
claim_generation INTEGER NOT NULL CHECK (
|
||||
typeof(claim_generation) = 'integer' AND claim_generation > 0
|
||||
),
|
||||
task_version INTEGER NOT NULL CHECK (
|
||||
typeof(task_version) = 'integer' AND task_version > 0
|
||||
),
|
||||
task_title TEXT NOT NULL CHECK (trim(task_title) <> ''),
|
||||
authorization_task_version INTEGER NOT NULL CHECK (
|
||||
typeof(authorization_task_version) = 'integer' AND authorization_task_version > 0
|
||||
),
|
||||
goods_id TEXT NOT NULL CHECK (trim(goods_id) <> ''),
|
||||
sku_color TEXT NOT NULL CHECK (trim(sku_color) <> ''),
|
||||
sku_size TEXT NOT NULL CHECK (trim(sku_size) <> ''),
|
||||
quantity INTEGER NOT NULL CHECK (typeof(quantity) = 'integer' AND quantity > 0),
|
||||
total_price_cap TEXT NOT NULL CHECK (trim(total_price_cap) <> ''),
|
||||
authorization_expires_at TEXT NOT NULL CHECK (
|
||||
authorization_expires_at = trim(authorization_expires_at)
|
||||
AND length(authorization_expires_at) >= 20
|
||||
AND substr(authorization_expires_at, 11, 1) = 'T'
|
||||
AND substr(authorization_expires_at, -1, 1) = 'Z'
|
||||
AND julianday(authorization_expires_at) IS NOT NULL
|
||||
),
|
||||
claim_nonce BLOB NOT NULL CHECK (
|
||||
typeof(claim_nonce) = 'blob' AND length(claim_nonce) = 32
|
||||
),
|
||||
claim_token_sha256 BLOB NOT NULL CHECK (
|
||||
typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32
|
||||
),
|
||||
lease_expires_at TEXT NOT NULL CHECK (
|
||||
lease_expires_at = trim(lease_expires_at)
|
||||
AND length(lease_expires_at) >= 20
|
||||
AND substr(lease_expires_at, 11, 1) = 'T'
|
||||
AND substr(lease_expires_at, -1, 1) = 'Z'
|
||||
AND julianday(lease_expires_at) IS NOT NULL
|
||||
),
|
||||
claimed_at TEXT NOT NULL CHECK (
|
||||
claimed_at = trim(claimed_at)
|
||||
AND length(claimed_at) >= 20
|
||||
AND substr(claimed_at, 11, 1) = 'T'
|
||||
AND substr(claimed_at, -1, 1) = 'Z'
|
||||
AND julianday(claimed_at) IS NOT NULL
|
||||
),
|
||||
closed_at TEXT CHECK (
|
||||
closed_at IS NULL OR (
|
||||
closed_at = trim(closed_at)
|
||||
AND length(closed_at) >= 20
|
||||
AND substr(closed_at, 11, 1) = 'T'
|
||||
AND substr(closed_at, -1, 1) = 'Z'
|
||||
AND julianday(closed_at) IS NOT NULL
|
||||
AND julianday(closed_at) >= julianday(claimed_at)
|
||||
)
|
||||
),
|
||||
UNIQUE (task_id, attempt_id),
|
||||
UNIQUE (attempt_id, claimed_by_device_id, session_id),
|
||||
UNIQUE (
|
||||
task_id, attempt_id, claimed_by_device_id, session_id,
|
||||
claim_generation, claim_token_sha256
|
||||
),
|
||||
UNIQUE (task_id, authorization_id, attempt_id),
|
||||
FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation)
|
||||
REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation),
|
||||
FOREIGN KEY (claimed_by_device_id) REFERENCES device_credentials(device_id)
|
||||
);
|
||||
|
||||
-- 过期、撤销或停轮询都不会自动关闭 claim;partial unique 因而阻止另一条开放归属。
|
||||
CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx
|
||||
ON purchase_attempt_claims (claimed_by_device_id)
|
||||
WHERE closed_at IS NULL;
|
||||
|
||||
CREATE TABLE task_claim_requests (
|
||||
claim_request_id TEXT PRIMARY KEY CHECK (
|
||||
length(claim_request_id) = 36
|
||||
AND substr(claim_request_id, 9, 1) = '-'
|
||||
AND substr(claim_request_id, 14, 1) = '-'
|
||||
AND substr(claim_request_id, 19, 1) = '-'
|
||||
AND substr(claim_request_id, 24, 1) = '-'
|
||||
AND length(replace(claim_request_id, '-', '')) = 32
|
||||
AND replace(claim_request_id, '-', '') NOT GLOB '*[^0-9a-f]*'
|
||||
AND substr(claim_request_id, 15, 1) = '4'
|
||||
AND substr(claim_request_id, 20, 1) IN ('8', '9', 'a', 'b')
|
||||
),
|
||||
device_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL CHECK (
|
||||
length(session_id) = 36
|
||||
AND substr(session_id, 9, 1) = '-'
|
||||
AND substr(session_id, 14, 1) = '-'
|
||||
AND substr(session_id, 19, 1) = '-'
|
||||
AND substr(session_id, 24, 1) = '-'
|
||||
AND length(replace(session_id, '-', '')) = 32
|
||||
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
|
||||
AND substr(session_id, 15, 1) = '4'
|
||||
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
|
||||
),
|
||||
outcome TEXT NOT NULL CHECK (outcome IN ('CLAIMED', 'EMPTY', 'BLOCKED')),
|
||||
attempt_id TEXT,
|
||||
response_lease_expires_at TEXT CHECK (
|
||||
response_lease_expires_at IS NULL OR (
|
||||
response_lease_expires_at = trim(response_lease_expires_at)
|
||||
AND length(response_lease_expires_at) >= 20
|
||||
AND substr(response_lease_expires_at, 11, 1) = 'T'
|
||||
AND substr(response_lease_expires_at, -1, 1) = 'Z'
|
||||
AND julianday(response_lease_expires_at) IS NOT NULL
|
||||
)
|
||||
),
|
||||
error_code TEXT CHECK (error_code IS NULL OR error_code = 'manual_recovery_required'),
|
||||
created_at TEXT NOT NULL CHECK (
|
||||
created_at = trim(created_at)
|
||||
AND length(created_at) >= 20
|
||||
AND substr(created_at, 11, 1) = 'T'
|
||||
AND substr(created_at, -1, 1) = 'Z'
|
||||
AND julianday(created_at) IS NOT NULL
|
||||
),
|
||||
CHECK (
|
||||
(outcome = 'CLAIMED' AND attempt_id IS NOT NULL AND response_lease_expires_at IS NOT NULL AND error_code IS NULL)
|
||||
OR (outcome = 'EMPTY' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code IS NULL)
|
||||
OR (outcome = 'BLOCKED' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code = 'manual_recovery_required')
|
||||
),
|
||||
FOREIGN KEY (device_id) REFERENCES device_credentials(device_id),
|
||||
-- EMPTY/BLOCKED 行的 attempt_id 为 NULL,SQLite 会跳过复合 FK;CLAIMED 行则必须
|
||||
-- 同时匹配原 claim 的设备和 session,不能由应用 bug 写成跨设备重放。
|
||||
FOREIGN KEY (attempt_id, device_id, session_id)
|
||||
REFERENCES purchase_attempt_claims(attempt_id, claimed_by_device_id, session_id)
|
||||
);
|
||||
|
||||
CREATE TABLE purchase_attempt_lease_renewals (
|
||||
renew_request_id TEXT PRIMARY KEY CHECK (
|
||||
length(renew_request_id) = 36
|
||||
AND substr(renew_request_id, 9, 1) = '-'
|
||||
AND substr(renew_request_id, 14, 1) = '-'
|
||||
AND substr(renew_request_id, 19, 1) = '-'
|
||||
AND substr(renew_request_id, 24, 1) = '-'
|
||||
AND length(replace(renew_request_id, '-', '')) = 32
|
||||
AND replace(renew_request_id, '-', '') NOT GLOB '*[^0-9a-f]*'
|
||||
AND substr(renew_request_id, 15, 1) = '4'
|
||||
AND substr(renew_request_id, 20, 1) IN ('8', '9', 'a', 'b')
|
||||
),
|
||||
task_id TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL CHECK (
|
||||
length(session_id) = 36
|
||||
AND substr(session_id, 9, 1) = '-'
|
||||
AND substr(session_id, 14, 1) = '-'
|
||||
AND substr(session_id, 19, 1) = '-'
|
||||
AND substr(session_id, 24, 1) = '-'
|
||||
AND length(replace(session_id, '-', '')) = 32
|
||||
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
|
||||
AND substr(session_id, 15, 1) = '4'
|
||||
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
|
||||
),
|
||||
claim_generation INTEGER NOT NULL CHECK (
|
||||
typeof(claim_generation) = 'integer' AND claim_generation > 0
|
||||
),
|
||||
claim_token_sha256 BLOB NOT NULL CHECK (
|
||||
typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32
|
||||
),
|
||||
expected_lease_expires_at TEXT NOT NULL CHECK (
|
||||
expected_lease_expires_at = trim(expected_lease_expires_at)
|
||||
AND length(expected_lease_expires_at) >= 20
|
||||
AND substr(expected_lease_expires_at, 11, 1) = 'T'
|
||||
AND substr(expected_lease_expires_at, -1, 1) = 'Z'
|
||||
AND julianday(expected_lease_expires_at) IS NOT NULL
|
||||
),
|
||||
lease_expires_at TEXT NOT NULL CHECK (
|
||||
lease_expires_at = trim(lease_expires_at)
|
||||
AND length(lease_expires_at) >= 20
|
||||
AND substr(lease_expires_at, 11, 1) = 'T'
|
||||
AND substr(lease_expires_at, -1, 1) = 'Z'
|
||||
AND julianday(lease_expires_at) IS NOT NULL
|
||||
),
|
||||
created_at TEXT NOT NULL CHECK (
|
||||
created_at = trim(created_at)
|
||||
AND length(created_at) >= 20
|
||||
AND substr(created_at, 11, 1) = 'T'
|
||||
AND substr(created_at, -1, 1) = 'Z'
|
||||
AND julianday(created_at) IS NOT NULL
|
||||
),
|
||||
FOREIGN KEY (
|
||||
task_id, attempt_id, device_id, session_id,
|
||||
claim_generation, claim_token_sha256
|
||||
) REFERENCES purchase_attempt_claims(
|
||||
task_id, attempt_id, claimed_by_device_id, session_id,
|
||||
claim_generation, claim_token_sha256
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX order_authorizations_claim_candidate_idx
|
||||
ON order_authorizations (status, created_at, id);
|
||||
|
||||
-- 首次证据写入必须属于认证设备当前未关闭的 claim。历史资产的幂等重放不触发 INSERT,
|
||||
-- 因而未来人工关闭 claim 后仍可稳定返回原资产。
|
||||
-- +goose StatementBegin
|
||||
CREATE TRIGGER evidence_assets_claim_owner_insert
|
||||
BEFORE INSERT ON evidence_assets
|
||||
FOR EACH ROW
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1 FROM purchase_attempt_claims AS claims
|
||||
WHERE claims.task_id = NEW.task_id
|
||||
AND claims.attempt_id = NEW.attempt_id
|
||||
AND claims.claimed_by_device_id = NEW.uploaded_by_device_id
|
||||
AND claims.closed_at IS NULL
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'evidence claim ownership required');
|
||||
END;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- 请求、续租、attempt、submission 和证据都是领取或下游审计事实,回滚不得静默删除。
|
||||
CREATE TABLE task_claim_downgrade_guard (
|
||||
valid INTEGER NOT NULL CHECK (valid = 1)
|
||||
);
|
||||
|
||||
INSERT INTO task_claim_downgrade_guard (valid)
|
||||
SELECT CASE WHEN
|
||||
(SELECT COUNT(*) FROM task_claim_requests) = 0
|
||||
AND (SELECT COUNT(*) FROM purchase_attempt_lease_renewals) = 0
|
||||
AND (SELECT COUNT(*) FROM purchase_attempt_claims) = 0
|
||||
AND (SELECT COUNT(*) FROM purchase_attempts) = 0
|
||||
AND (SELECT COUNT(*) FROM order_submissions) = 0
|
||||
AND (SELECT COUNT(*) FROM evidence_assets) = 0
|
||||
THEN 1 ELSE 0 END;
|
||||
|
||||
DROP TABLE task_claim_downgrade_guard;
|
||||
DROP TRIGGER evidence_assets_claim_owner_insert;
|
||||
DROP INDEX order_authorizations_claim_candidate_idx;
|
||||
DROP TABLE purchase_attempt_lease_renewals;
|
||||
DROP TABLE task_claim_requests;
|
||||
DROP INDEX purchase_attempt_claims_one_open_per_device_idx;
|
||||
DROP TABLE purchase_attempt_claims;
|
||||
DROP INDEX purchase_attempts_claim_lineage_idx;
|
||||
DROP INDEX purchase_attempts_one_per_authorization_idx;
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -15,7 +15,7 @@ from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, Subproce
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
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_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:
|
||||
@@ -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)
|
||||
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||
# Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
|
||||
print("规格恢复失败:已停止,未发布本地证据目录。", file=sys.stderr)
|
||||
print(
|
||||
f"规格恢复失败:stage={safe_failure_stage(error)};已停止,未发布本地证据目录。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
except OSError:
|
||||
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
|
||||
@@ -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())
|
||||
@@ -5,8 +5,9 @@ from __future__ import annotations
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
from .core.errors import StateError
|
||||
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]:
|
||||
@@ -30,8 +31,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
return 1
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
|
||||
from PySide6.QtWidgets import QApplication
|
||||
except ImportError:
|
||||
logger.error("缺少 PySide6,无法启动桌面界面。")
|
||||
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.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()
|
||||
window.setWindowTitle("采购工具")
|
||||
window.setAccessibleName("采购工具")
|
||||
window.setMinimumSize(420, 240)
|
||||
window.resize(560, 320)
|
||||
from .polling.coordinator import PollingCoordinator
|
||||
from .ui.main_window import PurchaseToolWindow
|
||||
|
||||
message = QLabel("应用骨架已初始化。\n采购执行功能尚未启用。")
|
||||
message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
message.setWordWrap(True)
|
||||
message.setAccessibleName("当前状态")
|
||||
window.setCentralWidget(message)
|
||||
|
||||
logger.info("应用已启动;采购执行功能尚未启用。")
|
||||
window.show()
|
||||
return application.exec()
|
||||
settings = None if summary is None else summary.settings
|
||||
has_token = False if summary is None else summary.has_stored_device_token
|
||||
coordinator = PollingCoordinator(
|
||||
profile_id="default",
|
||||
store=runtime.store,
|
||||
gateway_factory=None,
|
||||
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()
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""可安全呈现的客户端错误分类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ClientError(RuntimeError):
|
||||
"""错误文本只使用固定 reason code,不携带凭据、响应或本机路径。"""
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
self.reason = reason
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
class ValidationError(ClientError):
|
||||
"""本地输入或 wire schema 不满足固定契约。"""
|
||||
|
||||
|
||||
class StateError(ClientError):
|
||||
"""本地状态无法安全推进;调用方必须停止而不是绕过。"""
|
||||
|
||||
|
||||
class ProtectionError(ClientError):
|
||||
"""秘密保护失败。"""
|
||||
|
||||
|
||||
class SingleInstanceError(ClientError):
|
||||
"""同一配置已经由另一个采购工具进程持有。"""
|
||||
|
||||
|
||||
class RemoteError(ClientError):
|
||||
"""服务端调用的稳定错误分类。"""
|
||||
|
||||
|
||||
class AmbiguousRemoteError(RemoteError):
|
||||
"""请求结果不明;只允许以原幂等键、原载荷显式恢复。"""
|
||||
|
||||
|
||||
class CredentialRemoteError(RemoteError):
|
||||
"""设备凭据无效或已撤销。"""
|
||||
|
||||
|
||||
class ProtocolRemoteError(RemoteError):
|
||||
"""请求/响应与固定协议不兼容,不得自动重试。"""
|
||||
|
||||
|
||||
class ManualRemoteError(RemoteError):
|
||||
"""服务端要求人工处理的确定性冲突。"""
|
||||
@@ -0,0 +1,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=[已隐藏])"
|
||||
@@ -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
@@ -23,6 +23,8 @@ _KEY_VALUE_PATTERN = re.compile(
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
|
||||
_BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[0-9a-f]{64}\b")
|
||||
_BARE_TOKEN_PATTERN = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
|
||||
|
||||
|
||||
def redact_text(message: str) -> str:
|
||||
@@ -31,7 +33,9 @@ def redact_text(message: str) -> str:
|
||||
def replace_key_value(match: re.Match[str]) -> str:
|
||||
return f"{match.group('key')}{match.group('separator')}{REDACTED}"
|
||||
|
||||
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, message)
|
||||
redacted = _BEARER_PATTERN.sub("Bearer " + REDACTED, message)
|
||||
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, redacted)
|
||||
redacted = _BARE_TOKEN_PATTERN.sub(REDACTED, redacted)
|
||||
return _PHONE_PATTERN.sub(REDACTED, redacted)
|
||||
|
||||
|
||||
@@ -47,6 +51,16 @@ class SensitiveDataFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
|
||||
class RedactingFormatter(logging.Formatter):
|
||||
"""再次处理完整格式化文本,覆盖异常 traceback 中的敏感值。"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return redact_text(super().format(record))
|
||||
|
||||
def formatException(self, exc_info: tuple[type[BaseException], BaseException, object]) -> str:
|
||||
return redact_text(super().formatException(exc_info))
|
||||
|
||||
|
||||
def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
||||
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。"""
|
||||
|
||||
@@ -61,6 +75,6 @@ def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
||||
|
||||
handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
|
||||
handler.addFilter(SensitiveDataFilter())
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
handler.setFormatter(RedactingFormatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
@@ -0,0 +1,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 ..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_url import ProductUrl, parse_product_url
|
||||
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
from .sku_selection import (
|
||||
EXPECTED_GOODS_ID,
|
||||
EXPECTED_UNIT_PRICE,
|
||||
SkuPanelDevice,
|
||||
SkuSelectionError,
|
||||
SkuSelectionFlow,
|
||||
_SKU_ENTRY_FAILURE_STAGES,
|
||||
_REVEAL_GESTURE,
|
||||
_ENTRY_TEXT_BOUNDS,
|
||||
_TARGET_COLOR_UNROLLED_BOUNDS,
|
||||
_TARGET_SIZE_BOUNDS,
|
||||
_action_bounds,
|
||||
_annotate_sku_entry_failure,
|
||||
_parse_nodes,
|
||||
_safe_sku_entry_failure_stage,
|
||||
_unit_price,
|
||||
resolve_task_selection,
|
||||
)
|
||||
|
||||
@@ -39,6 +54,25 @@ EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
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):
|
||||
"""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)
|
||||
class SkuSelectionRunResult:
|
||||
"""已发布的截图和无页面正文 manifest 摘要。"""
|
||||
@@ -68,12 +148,21 @@ class SkuSelectionRunResult:
|
||||
screenshot_path: Path
|
||||
manifest_path: Path
|
||||
unit_price: str
|
||||
captured_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuExitSpikeResult:
|
||||
"""已原子发布、仍需人工判断页面身份的 T-104 证据。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
|
||||
|
||||
class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
"""把 uiautomator2 缩为 T-103 所需的读取与三种命名操作。
|
||||
"""把 uiautomator2 缩为 T-103 所需的读取与四种命名操作。
|
||||
|
||||
``tap_sku_entry``、``tap_sku_option`` 和 ``leave_sku_panel`` 是仅有的状态改变方法;
|
||||
四个命名方法是仅有的状态改变入口;reveal 的手势参数固定且不向 Flow 暴露;
|
||||
坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。
|
||||
"""
|
||||
|
||||
@@ -83,7 +172,14 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
self._device = device
|
||||
self._timeout_seconds = timeout_seconds
|
||||
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._back_rpc_outcome = "not_attempted"
|
||||
|
||||
@property
|
||||
def entry_was_tapped(self) -> bool:
|
||||
@@ -95,6 +191,38 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
def left_panel(self) -> bool:
|
||||
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]:
|
||||
value = self._call("app_info", package_name)
|
||||
if not isinstance(value, dict):
|
||||
@@ -114,19 +242,46 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
return value
|
||||
|
||||
def tap_sku_entry(self, bounds: str) -> None:
|
||||
if self._entry_was_tapped:
|
||||
raise SkuSelectionDeviceAdapterError("规格入口已经尝试过,拒绝重试。")
|
||||
# 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。
|
||||
self._entry_was_tapped = True
|
||||
self._entry_bounds_attempted = bounds
|
||||
self._entry_rpc_outcome = "ambiguous"
|
||||
self._tap_bounds_once(bounds)
|
||||
self._entry_rpc_outcome = "completed"
|
||||
|
||||
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._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:
|
||||
if self._left_panel:
|
||||
raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。")
|
||||
# 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。
|
||||
self._left_panel = True
|
||||
self._back_rpc_outcome = "ambiguous"
|
||||
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds)
|
||||
self._back_rpc_outcome = "completed"
|
||||
|
||||
def capture_screenshot(self) -> str:
|
||||
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds)
|
||||
@@ -158,6 +313,210 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
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:
|
||||
"""只运行 T-103 目标规格恢复、价格确认、原始截图和一次安全退出。"""
|
||||
|
||||
@@ -183,41 +542,57 @@ class SkuSelectionRunner:
|
||||
task_size: str,
|
||||
output_directory: Path,
|
||||
) -> SkuSelectionRunResult:
|
||||
link = parse_product_url(product_url)
|
||||
if link.goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
|
||||
selection = resolve_task_selection(task_color, task_size)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
stage = "precheck"
|
||||
staging: Path | None = None
|
||||
adapter: UiautomatorSkuPanelAdapter | None = None
|
||||
flow: SkuSelectionFlow | None = None
|
||||
staging = _prepare_staging(target)
|
||||
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||
try:
|
||||
link = parse_product_url(product_url)
|
||||
if link.goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
|
||||
selection = resolve_task_selection(task_color, task_size)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
staging = _prepare_staging(target)
|
||||
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||
|
||||
stage = "device_inspection"
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
|
||||
stage = "device_session"
|
||||
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
|
||||
pre_intent_hierarchy = adapter.dump_window_hierarchy()
|
||||
|
||||
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
|
||||
stage = "product_open"
|
||||
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||
|
||||
remaining = deadline - self._monotonic_clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
|
||||
|
||||
stage = "sku_entry"
|
||||
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
|
||||
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
|
||||
|
||||
stage = "sku_selection"
|
||||
flow.select_sku_options(selection)
|
||||
|
||||
stage = "price_verification"
|
||||
unit_price = flow.verify_target_selection_and_read_price(selection)
|
||||
if unit_price != EXPECTED_UNIT_PRICE:
|
||||
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
|
||||
|
||||
stage = "screenshot_capture"
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
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)
|
||||
except SkuSelectionRunError:
|
||||
raise
|
||||
@@ -226,36 +601,63 @@ class SkuSelectionRunner:
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
|
||||
stage = "screenshot_reverify"
|
||||
final_price = flow.verify_target_selection_and_read_price(selection)
|
||||
if final_price != EXPECTED_UNIT_PRICE:
|
||||
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
|
||||
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
|
||||
stage = "safe_exit"
|
||||
flow.exit_sku_panel_safely()
|
||||
|
||||
_require_completed_action_audit(adapter)
|
||||
|
||||
stage = "publish"
|
||||
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",
|
||||
)
|
||||
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError):
|
||||
except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||
_clean_staging(staging)
|
||||
_annotate_failure(error, _failure_stage_for(error, stage))
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error
|
||||
mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
|
||||
_annotate_mapped_failure(mapped, error, stage)
|
||||
raise mapped from error
|
||||
except OSError as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error
|
||||
mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
|
||||
_annotate_mapped_failure(mapped, error, stage)
|
||||
raise mapped from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error
|
||||
mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
|
||||
_annotate_mapped_failure(mapped, error, stage)
|
||||
raise mapped from error
|
||||
finally:
|
||||
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
||||
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
|
||||
# 结果不明只允许读回 pending;失败路径绝不继续后续动作或自动 Back。
|
||||
if flow is not None:
|
||||
try:
|
||||
flow.reconcile_pending_action()
|
||||
flow.exit_sku_panel_safely()
|
||||
except (SkuSelectionRunError, SkuSelectionError):
|
||||
pass
|
||||
|
||||
@@ -264,6 +666,7 @@ class SkuSelectionRunner:
|
||||
screenshot_path=target / "screenshot.png",
|
||||
manifest_path=target / "manifest.json",
|
||||
unit_price=EXPECTED_UNIT_PRICE,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -310,6 +713,87 @@ def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||
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:
|
||||
try:
|
||||
with Image.open(screenshot_path) as image:
|
||||
@@ -322,20 +806,83 @@ def _require_screenshot_size(screenshot_path: Path) -> None:
|
||||
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。"""
|
||||
|
||||
option_outcomes = dict(adapter.option_rpc_outcomes)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"captured_at": captured_at.isoformat(),
|
||||
"operation": "t103-sku-selection",
|
||||
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||
"target_selection": {"color": task_color, "size": task_size},
|
||||
"unit_price": EXPECTED_UNIT_PRICE,
|
||||
"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",
|
||||
"page_identity": "human_review_required",
|
||||
"page_identity": "same_goods_evidence_bound",
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"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
|
||||
@@ -0,0 +1,106 @@
|
||||
"""T-302/T-204 固定 HTTP wire 的编码、解码与错误分类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from cmbuyer_client.core.errors import (
|
||||
AmbiguousRemoteError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
ValidationError,
|
||||
)
|
||||
from cmbuyer_client.core.validation import require_exact_fields, strict_json_loads
|
||||
|
||||
from .http_transport import HttpResponse
|
||||
|
||||
|
||||
JSON_REQUEST_LIMIT = 4096
|
||||
JSON_RESPONSE_LIMIT = 32 * 1024
|
||||
SMALL_RESPONSE_LIMIT = 8 * 1024
|
||||
JSON_CONTENT_TYPES = frozenset(("application/json", "application/json; charset=utf-8"))
|
||||
|
||||
|
||||
def encode_json(value: Mapping[str, object]) -> bytes:
|
||||
body = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
if len(body) > JSON_REQUEST_LIMIT:
|
||||
raise ProtocolRemoteError("request_too_large")
|
||||
return body
|
||||
|
||||
|
||||
def common_headers(device_id: str, token: str, content_type: str) -> tuple[tuple[str, str], ...]:
|
||||
return (
|
||||
("Authorization", "Bearer " + token),
|
||||
("X-CMBuyer-Device-ID", device_id),
|
||||
("Accept", "application/json"),
|
||||
("Content-Type", content_type),
|
||||
)
|
||||
|
||||
|
||||
def parse_json_response(response: HttpResponse, *, maximum: int) -> object:
|
||||
encodings = response.header_values("Content-Encoding")
|
||||
types = response.header_values("Content-Type")
|
||||
if encodings or len(types) != 1 or types[0].lower() not in JSON_CONTENT_TYPES:
|
||||
raise ValidationError("invalid_response_content_type")
|
||||
return strict_json_loads(response.body, maximum=maximum)
|
||||
|
||||
|
||||
def require_empty_response(response: HttpResponse) -> None:
|
||||
if response.body or response.header_values("Content-Encoding"):
|
||||
raise ProtocolRemoteError("unexpected_error_body")
|
||||
|
||||
|
||||
def classify_json_error(response: HttpResponse, *, allowed_409: frozenset[str]) -> None:
|
||||
"""抛出错误,不返回。调用方只在非成功状态使用。"""
|
||||
|
||||
if 200 <= response.status <= 299:
|
||||
# 服务端可能已提交幂等事实;未知 2xx 绝不能终结本地槽或换 key。
|
||||
raise AmbiguousRemoteError("unknown_success_status")
|
||||
if response.status == 401:
|
||||
require_empty_response(response)
|
||||
raise CredentialRemoteError("device_credential_rejected")
|
||||
if response.status == 503 or 500 <= response.status <= 599:
|
||||
# 5xx 无法证明服务端是否在提交响应前完成事务。
|
||||
raise AmbiguousRemoteError("server_result_unknown")
|
||||
if response.status == 409:
|
||||
try:
|
||||
data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",))
|
||||
code = data["error"]
|
||||
except ValidationError as error:
|
||||
raise ProtocolRemoteError("invalid_conflict_response") from error
|
||||
if not isinstance(code, str) or code not in allowed_409:
|
||||
raise ProtocolRemoteError("unknown_conflict")
|
||||
raise ManualRemoteError(code)
|
||||
expected = {400: "invalid_request", 413: "request_too_large", 415: "unsupported_media_type"}
|
||||
if response.status in expected:
|
||||
try:
|
||||
data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",))
|
||||
except ValidationError as error:
|
||||
raise ProtocolRemoteError("invalid_error_response") from error
|
||||
if data["error"] != expected[response.status]:
|
||||
raise ProtocolRemoteError("unexpected_error_code")
|
||||
raise ProtocolRemoteError(expected[response.status])
|
||||
if 300 <= response.status <= 399:
|
||||
raise ProtocolRemoteError("redirect_rejected")
|
||||
raise ProtocolRemoteError("unexpected_http_status")
|
||||
|
||||
|
||||
def classify_bodyless_error(response: HttpResponse) -> None:
|
||||
if 200 <= response.status <= 299:
|
||||
raise AmbiguousRemoteError("unknown_success_status")
|
||||
if response.status == 401:
|
||||
require_empty_response(response)
|
||||
raise CredentialRemoteError("device_credential_rejected")
|
||||
if response.status == 503 or 500 <= response.status <= 599:
|
||||
raise AmbiguousRemoteError("server_result_unknown")
|
||||
if response.status == 409:
|
||||
require_empty_response(response)
|
||||
raise ManualRemoteError("evidence_conflict")
|
||||
if response.status in (400, 403, 413, 415):
|
||||
require_empty_response(response)
|
||||
raise ProtocolRemoteError("evidence_request_rejected")
|
||||
if 300 <= response.status <= 399:
|
||||
raise ProtocolRemoteError("redirect_rejected")
|
||||
raise ProtocolRemoteError("unexpected_http_status")
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -17,21 +18,34 @@ class RuntimePaths:
|
||||
root: Path
|
||||
logs: Path
|
||||
artifacts: Path
|
||||
state: Path
|
||||
database: Path
|
||||
|
||||
@classmethod
|
||||
def from_root(cls, root: Path) -> "RuntimePaths":
|
||||
resolved_root = root.expanduser()
|
||||
# 路径在进程启动时一次性固化;之后 cwd 改变不能打开第二套数据库或绕过原 mutex。
|
||||
resolved_root = root.expanduser().resolve(strict=False)
|
||||
state = resolved_root / "state"
|
||||
return cls(
|
||||
root=resolved_root,
|
||||
logs=resolved_root / "logs",
|
||||
artifacts=resolved_root / "artifacts",
|
||||
state=state,
|
||||
database=state / "client-state.sqlite3",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> "RuntimePaths":
|
||||
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||
if local_app_data:
|
||||
return cls.from_root(Path(local_app_data) / "cmbuyer")
|
||||
local_root = Path(local_app_data).expanduser()
|
||||
if not local_root.is_absolute():
|
||||
raise RuntimeError("local_app_data_must_be_absolute")
|
||||
return cls.from_root(local_root / "cmbuyer")
|
||||
|
||||
if os.name == "nt":
|
||||
# Windows 上回退到 home 会悄悄创建第二套状态库并绕开同一 mutex,必须失败闭合。
|
||||
raise RuntimeError("local_app_data_required")
|
||||
|
||||
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
|
||||
|
||||
@@ -40,3 +54,49 @@ class RuntimePaths:
|
||||
|
||||
self.logs.mkdir(parents=True, exist_ok=True)
|
||||
self.artifacts.mkdir(parents=True, exist_ok=True)
|
||||
self.state.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalStateRuntime:
|
||||
"""持有 named mutex 与本地状态库,保证 mutex 总是先取得。"""
|
||||
|
||||
paths: RuntimePaths
|
||||
mutex: Any
|
||||
store: Any
|
||||
|
||||
@classmethod
|
||||
def open(
|
||||
cls,
|
||||
paths: RuntimePaths | None = None,
|
||||
*,
|
||||
mutex_factory: Callable[[Path], Any] | None = None,
|
||||
protector_factory: Callable[[], Any] | None = None,
|
||||
store_factory: Callable[[Path, Any], Any] | None = None,
|
||||
) -> "LocalStateRuntime":
|
||||
from .localstate.protection import DpapiProtector
|
||||
from .localstate.single_instance import NamedMutex
|
||||
from .localstate.store import LocalStateStore
|
||||
|
||||
selected = paths or RuntimePaths.default()
|
||||
selected.ensure_exists()
|
||||
make_mutex = mutex_factory or NamedMutex
|
||||
make_protector = protector_factory or DpapiProtector
|
||||
make_store = store_factory or LocalStateStore
|
||||
mutex = make_mutex(selected.database)
|
||||
try:
|
||||
protector = make_protector()
|
||||
store = make_store(selected.database, protector)
|
||||
except Exception:
|
||||
mutex.close()
|
||||
raise
|
||||
return cls(selected, mutex, store)
|
||||
|
||||
def close(self) -> None:
|
||||
self.mutex.close()
|
||||
|
||||
def __enter__(self) -> "LocalStateRuntime":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
self.close()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""采购工具原生 Qt Widgets 界面。"""
|
||||
|
||||
from .main_window import PurchaseToolWindow
|
||||
|
||||
__all__ = ["PurchaseToolWindow"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 "未保存")
|
||||
@@ -0,0 +1 @@
|
||||
"""core tests。"""
|
||||
@@ -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 functools import lru_cache
|
||||
from hashlib import sha256
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
@@ -48,6 +50,16 @@ def _hash(path: Path) -> str:
|
||||
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:
|
||||
return (
|
||||
"<hierarchy rotation='0'>"
|
||||
@@ -116,12 +128,7 @@ def _write_raw(
|
||||
raw = root / "raw"
|
||||
raw.mkdir(parents=True)
|
||||
screenshot = raw / "screenshot.png"
|
||||
image = Image.new("RGB", size, color=(0, 180, 0))
|
||||
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")
|
||||
screenshot.write_bytes(_source_png(size))
|
||||
hierarchy = raw / "hierarchy.xml"
|
||||
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
|
||||
manifest = {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""localstate tests。"""
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
SRC = Path(__file__).resolve().parents[2] / "src" / "cmbuyer_client"
|
||||
SCOPED = tuple((SRC / name) for name in ("core", "remote", "localstate"))
|
||||
|
||||
|
||||
class StaticBoundaryTests(unittest.TestCase):
|
||||
def test_scoped_modules_do_not_import_device_pdd_or_unapproved_capabilities(self) -> None:
|
||||
forbidden_modules = ("cmbuyer_client.device", "cmbuyer_client.pdd")
|
||||
forbidden_text = (
|
||||
"ResultSink",
|
||||
"/events",
|
||||
"/fail",
|
||||
"/submission-fence",
|
||||
"/result",
|
||||
"click_permitted",
|
||||
)
|
||||
for directory in SCOPED:
|
||||
for path in directory.glob("*.py"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(text)
|
||||
imports = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imports.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
imports.append(node.module)
|
||||
for module in forbidden_modules:
|
||||
self.assertFalse(any(name.startswith(module) for name in imports), (path, module))
|
||||
for value in forbidden_text:
|
||||
self.assertNotIn(value, text, (path, value))
|
||||
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, StateError
|
||||
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken
|
||||
from cmbuyer_client.localstate.facade import DurableClientGateway
|
||||
from cmbuyer_client.localstate.models import ProfileSettings
|
||||
from cmbuyer_client.localstate.store import LocalStateStore
|
||||
from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink
|
||||
from cmbuyer_client.remote.http_transport import HttpResponse
|
||||
from cmbuyer_client.remote.task_source import HttpTaskSource
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, claim_wire
|
||||
from tests.localstate.test_store import DEVICE_TOKEN, FakeProtector, PROFILE
|
||||
from tests.remote.test_task_source import DEVICE_ID, FakeTransport, response
|
||||
|
||||
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
class InspectingSource:
|
||||
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
|
||||
self.store = store
|
||||
self.profile_id = profile_id
|
||||
self.calls = 0
|
||||
self.mode = "success"
|
||||
self.request_ids: list[str] = []
|
||||
|
||||
def claim_next(self, credentials, request):
|
||||
self.calls += 1
|
||||
self.request_ids.append(request.claim_request_id)
|
||||
# HTTP 适配器被调用时,幂等请求必须已经 durable。
|
||||
self.assert_pending(request.claim_request_id)
|
||||
if self.mode == "ambiguous":
|
||||
raise AmbiguousRemoteError("http_result_unknown")
|
||||
if self.mode == "manual":
|
||||
raise ManualRemoteError("claim_requires_manual")
|
||||
return ClaimedTask.from_wire(claim_wire())
|
||||
|
||||
def renew(self, credentials, request):
|
||||
raise AssertionError("not used")
|
||||
|
||||
def assert_pending(self, request_id: str) -> None:
|
||||
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
|
||||
if snapshot.pending_claim is None or snapshot.pending_claim.claim_request_id != request_id:
|
||||
raise AssertionError("HTTP happened before durable prepare")
|
||||
|
||||
|
||||
class InspectingSink:
|
||||
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
|
||||
self.store = store
|
||||
self.profile_id = profile_id
|
||||
self.calls = 0
|
||||
|
||||
def upload(self, credentials, upload):
|
||||
self.calls += 1
|
||||
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
|
||||
if not snapshot.pending_evidence or snapshot.pending_evidence[0].upload_key != upload.upload_key:
|
||||
raise AssertionError("HTTP happened before durable evidence slot")
|
||||
return AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
upload.task_id,
|
||||
upload.attempt_id,
|
||||
upload.kind,
|
||||
upload.privacy_tier,
|
||||
upload.sha256,
|
||||
len(upload.content),
|
||||
"image/png",
|
||||
1,
|
||||
1,
|
||||
upload.captured_at,
|
||||
)
|
||||
|
||||
|
||||
class InspectingRenewSource:
|
||||
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
|
||||
self.store = store
|
||||
self.profile_id = profile_id
|
||||
self.calls = 0
|
||||
|
||||
def claim_next(self, credentials, request):
|
||||
raise AssertionError("not used")
|
||||
|
||||
def renew(self, credentials, request):
|
||||
self.calls += 1
|
||||
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
|
||||
if snapshot.pending_renew is None or snapshot.pending_renew.renew_request_id != request.renew_request_id:
|
||||
raise AssertionError("HTTP happened before durable renew")
|
||||
return RenewResult(request.task_id, request.attempt_id, request.claim_generation, request.expected_lease_expires_at)
|
||||
|
||||
|
||||
class DurableClientGatewayTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.database = Path(self.directory.name) / "client-state.sqlite3"
|
||||
self.store = LocalStateStore(
|
||||
self.database,
|
||||
FakeProtector(),
|
||||
now=lambda: datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
profile = ProfileSettings(
|
||||
PROFILE,
|
||||
"http://127.0.0.1:8080",
|
||||
DEVICE_ID,
|
||||
"D:/Portable/adb/adb.exe",
|
||||
"192.168.0.173:5555",
|
||||
"wifi",
|
||||
)
|
||||
self.store.save_profile(profile, SecretToken(DEVICE_TOKEN))
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
self.source = InspectingSource(self.store, PROFILE)
|
||||
self.sink = InspectingSink(self.store, PROFILE)
|
||||
self.gateway = DurableClientGateway(self.store, self.source, self.sink)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.directory.cleanup()
|
||||
|
||||
def test_claim_unknown_replays_same_durable_key_then_commits(self) -> None:
|
||||
self.source.mode = "ambiguous"
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
self.gateway.claim_next(PROFILE)
|
||||
self.source.mode = "success"
|
||||
claimed = self.gateway.claim_next(PROFILE)
|
||||
self.assertEqual(claimed.task.id, TASK_ID)
|
||||
self.assertEqual(self.source.request_ids[0], self.source.request_ids[1])
|
||||
self.assertIsNotNone(self.store.active_claim(PROFILE))
|
||||
|
||||
def test_unknown_claim_2xx_keeps_pending_key_for_real_adapter_replay(self) -> None:
|
||||
transport = FakeTransport(response(201, claim_wire()))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.claim_next(PROFILE)
|
||||
pending = self.store.recovery_snapshot(PROFILE).pending_claim
|
||||
self.assertIsNotNone(pending)
|
||||
transport.response = response(200, claim_wire())
|
||||
claimed = gateway.claim_next(PROFILE)
|
||||
self.assertEqual(claimed.task.id, TASK_ID)
|
||||
sent = [call[3] for call in transport.calls]
|
||||
self.assertEqual(sent[0], sent[1])
|
||||
|
||||
def test_claim_401_allows_token_repair_and_same_key_replay(self) -> None:
|
||||
transport = FakeTransport(HttpResponse(401, (), b""))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(CredentialRemoteError):
|
||||
gateway.claim_next(PROFILE)
|
||||
request_id = self.store.recovery_snapshot(PROFILE).pending_claim.claim_request_id
|
||||
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
|
||||
transport.response = response(200, claim_wire())
|
||||
gateway.claim_next(PROFILE)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
|
||||
self.assertEqual(json.loads(transport.calls[1][3])["claim_request_id"], request_id)
|
||||
|
||||
def test_profile_read_sql_failure_after_prepare_is_fixed_error_and_zero_http(self) -> None:
|
||||
original_connect = self.store._connect
|
||||
calls = 0
|
||||
|
||||
def fail_second_connection():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
connection = original_connect()
|
||||
if calls == 2:
|
||||
connection.set_authorizer(
|
||||
lambda action, table, *_: sqlite3.SQLITE_DENY
|
||||
if action == sqlite3.SQLITE_READ and table == "profiles"
|
||||
else sqlite3.SQLITE_OK
|
||||
)
|
||||
return connection
|
||||
|
||||
with mock.patch.object(self.store, "_connect", side_effect=fail_second_connection):
|
||||
with self.assertRaisesRegex(StateError, "localstate_read_failed") as captured:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
self.assertEqual(self.source.calls, 0)
|
||||
self.assertNotIn(str(self.database), repr(captured.exception))
|
||||
|
||||
def test_renew_is_durable_before_http(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
source = InspectingRenewSource(self.store, PROFILE)
|
||||
gateway = DurableClientGateway(self.store, source, self.sink)
|
||||
result = gateway.renew(PROFILE)
|
||||
self.assertEqual(result.attempt_id, ATTEMPT_ID)
|
||||
self.assertEqual(source.calls, 1)
|
||||
|
||||
def test_unknown_renew_2xx_keeps_pending_payload_for_replay(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
payload = {
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:06:00Z",
|
||||
}
|
||||
transport = FakeTransport(response(201, payload))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.renew(PROFILE)
|
||||
pending = self.store.recovery_snapshot(PROFILE).pending_renew
|
||||
self.assertIsNotNone(pending)
|
||||
transport.response = response(200, payload)
|
||||
gateway.renew(PROFILE)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
|
||||
def test_renew_401_allows_bearer_repair_without_changing_claim_payload(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
payload = {
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:06:00Z",
|
||||
}
|
||||
transport = FakeTransport(HttpResponse(401, (), b""))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(CredentialRemoteError):
|
||||
gateway.renew(PROFILE)
|
||||
self.assertIsNotNone(self.store.recovery_snapshot(PROFILE).pending_renew)
|
||||
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
|
||||
transport.response = response(200, payload)
|
||||
gateway.renew(PROFILE)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
|
||||
|
||||
def test_unknown_evidence_2xx_keeps_pending_multipart_for_replay(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "unknown.png"
|
||||
path.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
transport = FakeTransport(HttpResponse(202, (), b""))
|
||||
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
pending = self.store.recovery_snapshot(PROFILE).pending_evidence
|
||||
self.assertEqual(len(pending), 1)
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
receipt = {
|
||||
"asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode())
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
|
||||
def test_evidence_401_allows_bearer_repair_with_same_file_and_multipart(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "credential.png"
|
||||
path.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
transport = FakeTransport(HttpResponse(401, (), b""))
|
||||
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
|
||||
with self.assertRaises(CredentialRemoteError):
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(len(self.store.recovery_snapshot(PROFILE).pending_evidence), 1)
|
||||
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
receipt = {
|
||||
"asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode())
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
|
||||
|
||||
def test_equivalent_captured_at_replays_exact_original_multipart_bytes(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "exact-replay.png"
|
||||
path.write_bytes(PNG)
|
||||
transport = FakeTransport(HttpResponse(202, (), b""))
|
||||
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
|
||||
first = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.1Z")
|
||||
equivalent = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.100000Z")
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.upload_evidence(PROFILE, first)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.upload_evidence(PROFILE, equivalent)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
|
||||
def test_manual_claim_is_durable_and_never_gets_new_key(self) -> None:
|
||||
self.source.mode = "manual"
|
||||
with self.assertRaises(ManualRemoteError):
|
||||
self.gateway.claim_next(PROFILE)
|
||||
with self.assertRaises(Exception):
|
||||
self.gateway.claim_next(PROFILE)
|
||||
self.assertEqual(self.source.calls, 1)
|
||||
|
||||
def test_evidence_slot_exists_before_http_and_success_never_reuploads(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "one.png"
|
||||
path.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.120000Z")
|
||||
first = self.gateway.upload_evidence(PROFILE, asset)
|
||||
path.write_bytes(PNG + b"changed")
|
||||
second = self.gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(self.sink.calls, 1)
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import ProtectionError
|
||||
from cmbuyer_client.localstate.protection import DpapiProtector
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "DPAPI 仅在 Windows 验证")
|
||||
class DpapiProtectorTests(unittest.TestCase):
|
||||
def test_current_user_round_trip_purpose_isolation_and_corruption(self) -> None:
|
||||
protector = DpapiProtector()
|
||||
plaintext = b"a" * 64
|
||||
device_purpose = "device-token:default:33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
claim_purpose = "claim-token:default:53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
ciphertext = protector.protect(plaintext, purpose=device_purpose)
|
||||
self.assertNotIn(plaintext, ciphertext)
|
||||
self.assertEqual(protector.unprotect(ciphertext, purpose=device_purpose), plaintext)
|
||||
with self.assertRaises(ProtectionError):
|
||||
protector.unprotect(ciphertext, purpose=claim_purpose)
|
||||
damaged = ciphertext[:-1] + bytes((ciphertext[-1] ^ 1,))
|
||||
with self.assertRaises(ProtectionError):
|
||||
protector.unprotect(damaged, purpose=device_purpose)
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import SingleInstanceError
|
||||
from cmbuyer_client.localstate.single_instance import NamedMutex
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "named mutex 仅在 Windows 验证")
|
||||
class NamedMutexTests(unittest.TestCase):
|
||||
def test_second_process_for_same_database_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
database = Path(directory) / "state.sqlite3"
|
||||
first = NamedMutex(database)
|
||||
try:
|
||||
with self.assertRaises(SingleInstanceError):
|
||||
NamedMutex(database)
|
||||
code = (
|
||||
"from pathlib import Path; "
|
||||
"from cmbuyer_client.localstate.single_instance import NamedMutex; "
|
||||
"from cmbuyer_client.core.errors import SingleInstanceError; "
|
||||
f"p=Path({str(database)!r}); "
|
||||
"\ntry:\n NamedMutex(p)\nexcept SingleInstanceError:\n raise SystemExit(17)\nraise SystemExit(0)"
|
||||
)
|
||||
environment = dict(os.environ)
|
||||
environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[2] / "src")
|
||||
result = subprocess.run([sys.executable, "-c", code], env=environment, check=False)
|
||||
self.assertEqual(result.returncode, 17)
|
||||
finally:
|
||||
first.close()
|
||||
|
||||
with NamedMutex(database):
|
||||
pass
|
||||
@@ -0,0 +1,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>
|
||||
<!-- 商品内容区同名促销小字:它不是规格入口,保留为防误点反例。 -->
|
||||
<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.ViewSwitcher" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
|
||||
@@ -8,7 +9,9 @@
|
||||
</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>
|
||||
</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>
|
||||
+59
@@ -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()
|
||||
@@ -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
+1797
-112
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
"""轮询协调器测试。"""
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""remote tests。"""
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import base64
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.models import DeviceCredentials, EvidenceUpload, SecretToken
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError
|
||||
from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink
|
||||
from cmbuyer_client.remote.http_transport import HttpResponse
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN
|
||||
from tests.remote.test_task_source import DEVICE_ID, FakeTransport
|
||||
|
||||
|
||||
UPLOAD_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
ASSET_ID = "63c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
class EvidenceSinkTests(unittest.TestCase):
|
||||
def test_upload_has_fixed_fields_and_never_contains_local_path(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
payload = {
|
||||
"asset_id": ASSET_ID,
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
transport = FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))
|
||||
receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(receipt.asset_id, ASSET_ID)
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
body = transport.calls[0][3]
|
||||
self.assertIn(b'filename="evidence.png"', body)
|
||||
self.assertNotIn(b"C:\\", body)
|
||||
self.assertNotIn(b"manifest", body)
|
||||
self.assertNotIn(b".xml", body)
|
||||
names = re.findall(br'Content-Disposition: form-data; name="([^"]+)"', body)
|
||||
self.assertEqual(
|
||||
names,
|
||||
[b"upload_key", b"attempt_id", b"kind", b"privacy_tier", b"sha256", b"captured_at", b"file"],
|
||||
)
|
||||
self.assertEqual(body.count(b'filename="evidence.png"'), 1)
|
||||
self.assertNotIn(b"claim_token", body)
|
||||
self.assertNotIn(b"session_id", body)
|
||||
boundary = dict(transport.calls[0][2])["Content-Type"].split("boundary=", 1)[1]
|
||||
self.assertEqual(boundary, "cmbuyer-" + UPLOAD_ID.replace("-", ""))
|
||||
|
||||
def test_captured_at_equivalent_trailing_zeros_are_accepted(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00.120000Z", PNG)
|
||||
payload = {
|
||||
"asset_id": ASSET_ID,
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00.12Z",
|
||||
}
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
for status in (200, 201):
|
||||
with self.subTest(status=status):
|
||||
transport = FakeTransport(HttpResponse(status, (("Content-Type", "application/json"),), raw))
|
||||
receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(receipt.captured_at, "2026-08-04T09:01:00.12Z")
|
||||
|
||||
def test_wrong_content_hash_fails_before_http_object_can_be_built(self) -> None:
|
||||
transport = FakeTransport(HttpResponse(500, (), b""))
|
||||
with self.assertRaises(ValidationError):
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, "0" * 64, "2026-08-04T09:01:00Z", PNG)
|
||||
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(transport.calls, [])
|
||||
|
||||
def test_unknown_2xx_is_ambiguous(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
for status in (202, 204, 206):
|
||||
with self.subTest(status=status):
|
||||
transport = FakeTransport(HttpResponse(status, (), b""))
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
|
||||
def test_boundary_collision_and_receipt_mismatch_fail_closed(self) -> None:
|
||||
marker = ("--cmbuyer-" + UPLOAD_ID.replace("-", "")).encode()
|
||||
collision_content = PNG + marker
|
||||
collision = EvidenceUpload(
|
||||
TASK_ID,
|
||||
UPLOAD_ID,
|
||||
ATTEMPT_ID,
|
||||
hashlib.sha256(collision_content).hexdigest(),
|
||||
"2026-08-04T09:01:00Z",
|
||||
collision_content,
|
||||
)
|
||||
transport = FakeTransport(HttpResponse(500, (), b""))
|
||||
from cmbuyer_client.core.errors import ProtocolRemoteError
|
||||
with self.assertRaises(ProtocolRemoteError):
|
||||
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), collision)
|
||||
self.assertEqual(transport.calls, [])
|
||||
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
mismatch = {
|
||||
"asset_id": ASSET_ID,
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": "f" * 64,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
raw = json.dumps(mismatch, separators=(",", ":")).encode()
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload(
|
||||
DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload
|
||||
)
|
||||
|
||||
dimension_mismatch = dict(mismatch)
|
||||
dimension_mismatch["sha256"] = digest
|
||||
dimension_mismatch["width_px"] = 2
|
||||
raw = json.dumps(dimension_mismatch, separators=(",", ":")).encode()
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload(
|
||||
DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload
|
||||
)
|
||||
|
||||
def test_evidence_error_status_matrix(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN))
|
||||
from cmbuyer_client.core.errors import CredentialRemoteError, ManualRemoteError, ProtocolRemoteError
|
||||
|
||||
cases = (
|
||||
(401, CredentialRemoteError),
|
||||
(400, ProtocolRemoteError),
|
||||
(403, ProtocolRemoteError),
|
||||
(409, ManualRemoteError),
|
||||
(413, ProtocolRemoteError),
|
||||
(415, ProtocolRemoteError),
|
||||
(500, AmbiguousRemoteError),
|
||||
(503, AmbiguousRemoteError),
|
||||
)
|
||||
for status, expected in cases:
|
||||
with self.subTest(status=status), self.assertRaises(expected):
|
||||
HttpEvidenceSink(FakeTransport(HttpResponse(status, (), b""))).upload(credentials, upload)
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import http.client
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError
|
||||
from cmbuyer_client.remote.http_transport import HttpTransport
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __init__(self, body: bytes = b"{}", headers: list[tuple[str, str]] | None = None) -> None:
|
||||
self.body = body
|
||||
self.headers = headers or [("Content-Type", "application/json")]
|
||||
|
||||
def getheader(self, name: str) -> str | None:
|
||||
return str(len(self.body)) if name == "Content-Length" else None
|
||||
|
||||
def getheaders(self) -> list[tuple[str, str]]:
|
||||
return self.headers
|
||||
|
||||
def read(self, maximum: int) -> bytes:
|
||||
return self.body[:maximum]
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, host: str, port: int, timeout: int) -> None:
|
||||
self.created = (host, port, timeout)
|
||||
self.calls = 0
|
||||
self.closed = False
|
||||
self.sent_headers: dict[str, str] = {}
|
||||
self.response = FakeResponse()
|
||||
|
||||
def request(self, method: str, path: str, body: bytes, headers: dict[str, str]) -> None:
|
||||
self.calls += 1
|
||||
self.sent_headers = headers
|
||||
|
||||
def getresponse(self) -> FakeResponse:
|
||||
return self.response
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class HttpTransportTests(unittest.TestCase):
|
||||
def test_exact_loopback_and_proxy_environment_is_irrelevant(self) -> None:
|
||||
made: list[FakeConnection] = []
|
||||
|
||||
def factory(*args: object, **kwargs: object) -> FakeConnection:
|
||||
connection = FakeConnection(*args, **kwargs)
|
||||
made.append(connection)
|
||||
return connection
|
||||
|
||||
with mock.patch.dict(os.environ, {"HTTP_PROXY": "http://example.invalid:9999"}):
|
||||
result = HttpTransport(connection_factory=factory).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (("Content-Type", "application/json"),), b"{}", response_limit=10
|
||||
)
|
||||
self.assertEqual(result.status, 200)
|
||||
self.assertEqual(made[0].created, ("127.0.0.1", 8080, 10))
|
||||
self.assertEqual(made[0].calls, 1)
|
||||
self.assertTrue(made[0].closed)
|
||||
|
||||
for url in ("http://localhost:8080", "http://127.0.0.1:8081", "http://127.0.0.1:8080/", "https://127.0.0.1:8080"):
|
||||
with self.subTest(url=url), self.assertRaises(ProtocolRemoteError):
|
||||
HttpTransport(url)
|
||||
|
||||
def test_network_failure_is_ambiguous_without_retry(self) -> None:
|
||||
class Broken(FakeConnection):
|
||||
def getresponse(self) -> FakeResponse:
|
||||
raise OSError("offline")
|
||||
|
||||
made: list[Broken] = []
|
||||
|
||||
def factory(*args: object, **kwargs: object) -> Broken:
|
||||
connection = Broken(*args, **kwargs)
|
||||
made.append(connection)
|
||||
return connection
|
||||
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTransport(connection_factory=factory).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=10
|
||||
)
|
||||
self.assertEqual(made[0].calls, 1)
|
||||
|
||||
def test_generator_headers_and_content_length_framing(self) -> None:
|
||||
made: list[FakeConnection] = []
|
||||
|
||||
def factory(*args: object, **kwargs: object) -> FakeConnection:
|
||||
connection = FakeConnection(*args, **kwargs)
|
||||
made.append(connection)
|
||||
return connection
|
||||
|
||||
headers = ((name, value) for name, value in (("Content-Type", "application/json"), ("Accept", "application/json")))
|
||||
HttpTransport(connection_factory=factory).request(
|
||||
"POST", "/api/v1/tasks/claim-next", headers, b"{}", response_limit=8
|
||||
)
|
||||
self.assertEqual(made[0].sent_headers["Content-Type"], "application/json")
|
||||
self.assertEqual(made[0].sent_headers["Accept"], "application/json")
|
||||
|
||||
legal = FakeConnection("127.0.0.1", 8080, 10)
|
||||
legal.response = FakeResponse(b"{}", [("Content-Length", "2")])
|
||||
accepted = HttpTransport(connection_factory=lambda *args, **kwargs: legal).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
self.assertEqual(accepted.body, b"{}")
|
||||
chunked = FakeConnection("127.0.0.1", 8080, 10)
|
||||
chunked.response = FakeResponse(b"{}", [("Transfer-Encoding", "Chunked")])
|
||||
accepted_chunked = HttpTransport(connection_factory=lambda *args, **kwargs: chunked).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
self.assertEqual(accepted_chunked.body, b"{}")
|
||||
|
||||
cases = (
|
||||
([('Transfer-Encoding', 'chunked'), ('Content-Length', '2')], b'{}'),
|
||||
([('Transfer-Encoding', 'gzip')], b'{}'),
|
||||
([('Transfer-Encoding', ' chunked ')], b'{}'),
|
||||
([('Transfer-Encoding', 'chunked,gzip')], b'{}'),
|
||||
([('Transfer-Encoding', 'chunked'), ('Transfer-Encoding', 'chunked')], b'{}'),
|
||||
([("Content-Length", "2"), ("Content-Length", "2")], b"{}"),
|
||||
([("Content-Length", "+2")], b"{}"),
|
||||
([("Content-Length", "-0")], b""),
|
||||
([("Content-Length", "2x")], b"{}"),
|
||||
([("Content-Length", "3")], b"{}"),
|
||||
([("Content-Length", "1")], b"{}"),
|
||||
([("Content-Length", "999")], b"{}"),
|
||||
([], b"0123456789"),
|
||||
)
|
||||
for response_headers, body in cases:
|
||||
with self.subTest(headers=response_headers, body=body):
|
||||
connection = FakeConnection("127.0.0.1", 8080, 10)
|
||||
connection.response = FakeResponse(body, response_headers)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTransport(connection_factory=lambda *args, value=connection, **kwargs: value).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
|
||||
def test_timeout_incomplete_read_and_close_do_not_expose_partial_body(self) -> None:
|
||||
token = ("a" * 64).encode()
|
||||
|
||||
class Incomplete(FakeResponse):
|
||||
def read(self, maximum: int) -> bytes:
|
||||
raise http.client.IncompleteRead(token, 1)
|
||||
|
||||
class Connection(FakeConnection):
|
||||
def getresponse(self) -> FakeResponse:
|
||||
return Incomplete()
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
raise OSError("close failed")
|
||||
|
||||
with self.assertRaises(AmbiguousRemoteError) as captured:
|
||||
HttpTransport(connection_factory=Connection).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=128
|
||||
)
|
||||
self.assertNotIn(token.decode(), _exception_graph(captured.exception))
|
||||
|
||||
class Timeout(FakeConnection):
|
||||
def getresponse(self) -> FakeResponse:
|
||||
raise TimeoutError("timed out")
|
||||
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTransport(connection_factory=Timeout).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
|
||||
|
||||
def _exception_graph(error: BaseException) -> str:
|
||||
seen: set[int] = set()
|
||||
values: list[str] = []
|
||||
pending: list[object] = [error]
|
||||
while pending:
|
||||
value = pending.pop()
|
||||
if id(value) in seen:
|
||||
continue
|
||||
seen.add(id(value))
|
||||
values.append(repr(value))
|
||||
if isinstance(value, BaseException):
|
||||
pending.extend(item for item in (value.__cause__, value.__context__) if item is not None)
|
||||
pending.extend(value.__dict__.values())
|
||||
return "\n".join(values)
|
||||
@@ -0,0 +1,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)
|
||||
@@ -22,3 +22,36 @@ class ApplicationArgumentsTests(unittest.TestCase):
|
||||
def test_none_uses_process_arguments(self) -> None:
|
||||
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
|
||||
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)
|
||||
|
||||
@@ -47,3 +47,24 @@ class LoggingPolicyTests(unittest.TestCase):
|
||||
self.assertNotIn("not-for-log", content)
|
||||
self.assertNotIn("13900139000", content)
|
||||
self.assertIn("[已隐藏]", content)
|
||||
|
||||
def test_bearer_bare_token_and_traceback_are_redacted(self) -> None:
|
||||
token = "a" * 64
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = RuntimePaths.from_root(Path(directory))
|
||||
logger = configure_application_logger(paths)
|
||||
try:
|
||||
try:
|
||||
raise RuntimeError("credential=" + token)
|
||||
except RuntimeError:
|
||||
logger.exception("Authorization: Bearer %s bare=%s", token, token)
|
||||
for handler in logger.handlers:
|
||||
handler.flush()
|
||||
content = (paths.logs / "client.log").read_text(encoding="utf-8")
|
||||
finally:
|
||||
for handler in tuple(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
self.assertNotIn(token, content)
|
||||
self.assertIn("[已隐藏]", content)
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.runtime import RuntimePaths
|
||||
from cmbuyer_client.runtime import LocalStateRuntime, RuntimePaths
|
||||
|
||||
|
||||
class RuntimePathsTests(unittest.TestCase):
|
||||
@@ -23,3 +25,58 @@ class RuntimePathsTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(paths.logs.is_dir())
|
||||
self.assertTrue(paths.artifacts.is_dir())
|
||||
self.assertTrue(paths.state.is_dir())
|
||||
self.assertEqual(paths.database, paths.state / "client-state.sqlite3")
|
||||
|
||||
def test_localstate_runtime_acquires_mutex_before_protector_and_store(self) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class Mutex:
|
||||
def __init__(self, path: Path) -> None:
|
||||
events.append("mutex")
|
||||
|
||||
def close(self) -> None:
|
||||
events.append("close")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
runtime = LocalStateRuntime.open(
|
||||
RuntimePaths.from_root(Path(directory)),
|
||||
mutex_factory=Mutex,
|
||||
protector_factory=lambda: events.append("protector") or object(),
|
||||
store_factory=lambda path, protector: events.append("store") or object(),
|
||||
)
|
||||
runtime.close()
|
||||
self.assertEqual(events, ["mutex", "protector", "store", "close"])
|
||||
|
||||
def test_localstate_runtime_releases_mutex_if_open_fails(self) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class Mutex:
|
||||
def __init__(self, path: Path) -> None:
|
||||
events.append("mutex")
|
||||
|
||||
def close(self) -> None:
|
||||
events.append("close")
|
||||
|
||||
def fail() -> object:
|
||||
raise RuntimeError("failed")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, self.assertRaises(RuntimeError):
|
||||
LocalStateRuntime.open(
|
||||
RuntimePaths.from_root(Path(directory)),
|
||||
mutex_factory=Mutex,
|
||||
protector_factory=fail,
|
||||
)
|
||||
self.assertEqual(events, ["mutex", "close"])
|
||||
|
||||
def test_windows_without_localappdata_fails_instead_of_creating_second_database(self) -> None:
|
||||
with mock.patch("cmbuyer_client.runtime.os.name", "nt"), mock.patch.dict(os.environ, {}, clear=True):
|
||||
with self.assertRaisesRegex(RuntimeError, "local_app_data_required"):
|
||||
RuntimePaths.default()
|
||||
|
||||
def test_runtime_root_is_frozen_absolute_and_relative_localappdata_is_rejected(self) -> None:
|
||||
paths = RuntimePaths.from_root(Path("relative-runtime"))
|
||||
self.assertTrue(paths.root.is_absolute())
|
||||
with mock.patch.dict(os.environ, {"LOCALAPPDATA": "relative-local-app-data"}, clear=True):
|
||||
with self.assertRaisesRegex(RuntimeError, "local_app_data_must_be_absolute"):
|
||||
RuntimePaths.default()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""原生 Qt Widgets UI 测试。"""
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -87,13 +87,15 @@ T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择
|
||||
|
||||
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
||||
|
||||
1. T-111 先冻结“开始采购授权”的单趟契约;同时主审并合入只创建 `DRAFT` 的 T-202。
|
||||
2. T-111 完成后恢复 **T-103 真机取证**;按依赖推进 T-104 → T-107 的分段安全判据。
|
||||
3. admin agent 先做 T-209 把旧两趟 schema / 状态机迁到单趟,再推进 T-203 服务端批量开始采购 /
|
||||
一次性授权;两者不依赖页面选择器。T-205 起的 attempt 与证据字段等待 T-103。
|
||||
4. Phase 3:双端打通与单趟围栏前 dry-run。
|
||||
5. Phase 4:单趟真实提交、待付款、调和与收尾。
|
||||
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
|
||||
1. 完成 **T-103 人工真机验收**,再按依赖推进 T-104 → T-107 的分段安全判据。
|
||||
2. admin agent 在 T-107 后推进 T-210 → T-205 → T-206 → T-207 → T-208,冻结三闸门证据、
|
||||
attempt 事件、围栏、一次性结果与人工调和。
|
||||
3. client agent 先做当前可领取的 T-303,再推进 T-304 / T-306 / T-307 / T-308;与 admin 写路径
|
||||
不重叠的任务继续并行。
|
||||
4. T-305 完成围栏前真机 dry-run;T-400 只用 fixture/fake port 建立离线一次性提交安全闭包。
|
||||
5. T-403 → T-402 先准备围栏后调和与人工付款事实记录;T-401 才在新真实单趟中首次点击一次,
|
||||
点击后固定报 `UNKNOWN` 并由人调和,绝不付款。
|
||||
6. T-405 生成可核验 Windows 包,T-404 对同一候选 commit 与产物做最终只读验收;V2 能力继续后置。
|
||||
|
||||
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
|
||||
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
|
||||
|
||||
@@ -104,8 +104,10 @@ US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统
|
||||
等于任务值。缺失、重复、禁用、前缀碰撞、读回不一致都停止,不选相近项。
|
||||
- **F-006 三道价格闸门**:价格只从规格面板和订单确认页读取,金额使用十进制字符串。
|
||||
1. 选中目标规格后读取当前 SKU 单价,计算 `单价 × 数量`,不得超过授权最高总价。
|
||||
2. 设置并复核数量后再次读取同一规格与单价;规格必须仍相同,单价必须与闸门一完全相等。
|
||||
3. 订单确认页核对规格、数量与应付总额;总额不得超过授权最高总价。
|
||||
2. 设置并复核数量后再次核对同一规格,并唯一读取规格面板的目标数量总额;总额不得超过授权
|
||||
最高总价。多件优惠可能非线性,不用闸门一金额乘数量猜总额,也不要求目标数量总额等于闸门一。
|
||||
3. 订单确认页核对规格、数量与应付总额;应付总额必须与闸门二面板总额严格相等,且再次不超过
|
||||
授权最高总价。
|
||||
任一道读不到、有歧义或不通过都停止并转人工,不从详情页、搜索页或按钮文字凑价格。
|
||||
- 同一设备会话连续完成上述步骤;不退出商品再等采购服务确认,也不把旧截图或旧页面读数当作
|
||||
当前提交依据。
|
||||
@@ -127,13 +129,13 @@ US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统
|
||||
|
||||
### 结果、证据与异常
|
||||
|
||||
- **F-007**:按步骤回传实际规格、两次规格面板价格、数量读回、确认页总额、判定、时间和截图。
|
||||
- **F-007**:按步骤回传实际规格、闸门一单价、数量读回、闸门二面板总额、确认页总额、判定、时间和截图。
|
||||
截图是内部审计证据,不是提交前审批前置。
|
||||
- 内部系统允许上传规格面板与订单确认页的原始截图,截图中可保留页面已显示的地址和手机号;只允许
|
||||
已认证设备显式上传、已登录管理员查看。完整 XML 只留采购工具本机,不上传;地址/手机号不解析为
|
||||
业务字段或日志;外部支付页、支付凭据永不上传。
|
||||
- **F-011**:至少区分设备未连接、版本失配、商品打不开、面板打不开、规格不匹配、价格不可读、
|
||||
两次价格不一致、数量复核失败、金额超上限、确认页不一致、提交控件不唯一、围栏失败、页面识别
|
||||
规格漂移、数量复核失败、闸门二总额不可读或超上限、确认页与闸门二不一致、提交控件不唯一、围栏失败、页面识别
|
||||
失败、安全校验、外部支付交接和超时。
|
||||
- 运行时拼多多 App 版本与已取证版本不一致时停止领取真机任务,提示重新取证。
|
||||
- 订单明确创建后展示截图、商品、规格、数量和授权金额;人在拼多多付款后手工标记完成。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user