Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
|
||||||
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
|
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
|
||||||
| `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 |
|
| `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_TASK_QUANTITY = '99'
|
||||||
$env:CMBUYER_MAX_TOTAL_PRICE = '999.99'
|
$env:CMBUYER_MAX_TOTAL_PRICE = '999.99'
|
||||||
$env:CMBUYER_EVIDENCE_DIR = '<内部截图绝对目录>'
|
$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/migrate -database $env:CMBUYER_DATABASE_SOURCE up
|
||||||
go run ./cmd/server
|
go run ./cmd/server
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"cmbuyer/admin/internal/server"
|
"cmbuyer/admin/internal/server"
|
||||||
evidencestorage "cmbuyer/admin/internal/storage/evidence"
|
evidencestorage "cmbuyer/admin/internal/storage/evidence"
|
||||||
"cmbuyer/admin/internal/storage/sqlite"
|
"cmbuyer/admin/internal/storage/sqlite"
|
||||||
|
"cmbuyer/admin/internal/taskclaim"
|
||||||
"cmbuyer/admin/internal/taskdetail"
|
"cmbuyer/admin/internal/taskdetail"
|
||||||
"cmbuyer/admin/internal/tasks"
|
"cmbuyer/admin/internal/tasks"
|
||||||
)
|
)
|
||||||
@@ -52,6 +53,10 @@ func run() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
claimStore, err := taskclaim.NewStore(database, configuration.ClaimTokenSecret, configuration.ClaimLeaseTTL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
router, err := server.NewRouter(server.Options{
|
router, err := server.NewRouter(server.Options{
|
||||||
AdminUsername: configuration.AdminUsername,
|
AdminUsername: configuration.AdminUsername,
|
||||||
@@ -61,6 +66,7 @@ func run() error {
|
|||||||
TaskDetails: detailStore,
|
TaskDetails: detailStore,
|
||||||
Evidence: evidenceStore,
|
Evidence: evidenceStore,
|
||||||
DeviceAuthenticator: deviceAuthenticator,
|
DeviceAuthenticator: deviceAuthenticator,
|
||||||
|
TaskClaims: claimStore,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -23,6 +25,8 @@ const (
|
|||||||
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
|
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
|
||||||
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
|
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
|
||||||
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
|
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
|
||||||
|
claimTokenSecretEnv = "CMBUYER_CLAIM_TOKEN_SECRET"
|
||||||
|
claimLeaseTTLEnv = "CMBUYER_CLAIM_LEASE_TTL"
|
||||||
minimumSecretLength = 32
|
minimumSecretLength = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +41,8 @@ type Config struct {
|
|||||||
MaxTaskQuantity int
|
MaxTaskQuantity int
|
||||||
MaxTotalPrice string
|
MaxTotalPrice string
|
||||||
EvidenceDirectory string
|
EvidenceDirectory string
|
||||||
|
ClaimTokenSecret []byte
|
||||||
|
ClaimLeaseTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
||||||
@@ -112,6 +118,27 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
|
|||||||
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
|
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
|
||||||
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
|
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{
|
return Config{
|
||||||
AdminUsername: username,
|
AdminUsername: username,
|
||||||
@@ -121,6 +148,8 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
|
|||||||
DatabaseSource: databaseSource,
|
DatabaseSource: databaseSource,
|
||||||
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
|
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
|
||||||
EvidenceDirectory: evidenceDirectory,
|
EvidenceDirectory: evidenceDirectory,
|
||||||
|
ClaimTokenSecret: claimSecret,
|
||||||
|
ClaimLeaseTTL: claimTTL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config_test
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"cmbuyer/admin/internal/config"
|
"cmbuyer/admin/internal/config"
|
||||||
|
|
||||||
@@ -25,13 +26,15 @@ func TestLoad(t *testing.T) {
|
|||||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||||
|
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
|
||||||
|
"CMBUYER_CLAIM_LEASE_TTL": "1m",
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := config.Load(lookup(values))
|
got, err := config.Load(lookup(values))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
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)
|
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_TASK_QUANTITY": "99",
|
||||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||||
|
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
|
||||||
|
"CMBUYER_CLAIM_LEASE_TTL": "1m",
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
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"},
|
{"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"},
|
{"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"},
|
{"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 {
|
for _, test := range tests {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
|||||||
if err := migrations.Up(context, database, directory); err != nil {
|
if err := migrations.Up(context, database, directory); err != nil {
|
||||||
t.Fatalf("apply migrations: %v", err)
|
t.Fatalf("apply migrations: %v", err)
|
||||||
}
|
}
|
||||||
assertVersion(t, database, 4)
|
assertVersion(t, database, 5)
|
||||||
assertTableExists(t, database, "tasks", true)
|
assertTableExists(t, database, "tasks", true)
|
||||||
assertTableExists(t, database, "spec_trials", false)
|
assertTableExists(t, database, "spec_trials", false)
|
||||||
assertTableExists(t, database, "order_authorizations", true)
|
assertTableExists(t, database, "order_authorizations", true)
|
||||||
@@ -34,12 +34,20 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
|||||||
assertTableExists(t, database, "order_submissions", true)
|
assertTableExists(t, database, "order_submissions", true)
|
||||||
assertTableExists(t, database, "evidence_assets", true)
|
assertTableExists(t, database, "evidence_assets", true)
|
||||||
assertTableExists(t, database, "device_credentials", true)
|
assertTableExists(t, database, "device_credentials", true)
|
||||||
|
assertTableExists(t, database, "purchase_attempt_claims", true)
|
||||||
assertTableExists(t, database, "single_pass_upgrade_guard", false)
|
assertTableExists(t, database, "single_pass_upgrade_guard", false)
|
||||||
|
|
||||||
if err := migrations.Up(context, database, directory); err != nil {
|
if err := migrations.Up(context, database, directory); err != nil {
|
||||||
t.Fatalf("reapply migrations: %v", err)
|
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)
|
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 {
|
if err := migrations.Down(context, database, directory); err != nil {
|
||||||
t.Fatalf("roll back device credential migration: %v", err)
|
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 {
|
if err := migrations.Up(context, database, directory); err != nil {
|
||||||
t.Fatalf("reapply v2 after rollback: %v", err)
|
t.Fatalf("reapply v2 after rollback: %v", err)
|
||||||
}
|
}
|
||||||
assertVersion(t, database, 4)
|
assertVersion(t, database, 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
|
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 {
|
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||||
t.Fatalf("upgrade v1 draft: %v", err)
|
t.Fatalf("upgrade v1 draft: %v", err)
|
||||||
}
|
}
|
||||||
assertVersion(t, database, 4)
|
assertVersion(t, database, 5)
|
||||||
var got struct {
|
var got struct {
|
||||||
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
|
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
|
||||||
quantity, version int
|
quantity, version int
|
||||||
@@ -235,9 +243,7 @@ func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
|
|||||||
|
|
||||||
func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||||
database := openTestDatabase(t)
|
database := openTestDatabase(t)
|
||||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
migrateToV3(t, database)
|
||||||
t.Fatalf("apply migrations: %v", err)
|
|
||||||
}
|
|
||||||
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
|
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
|
||||||
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
|
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
|
||||||
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
|
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 {
|
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||||
t.Fatal("evidence-bearing schema downgraded successfully")
|
t.Fatal("evidence-bearing schema downgraded successfully")
|
||||||
}
|
}
|
||||||
@@ -287,9 +290,7 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
|||||||
|
|
||||||
func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||||
database := openTestDatabase(t)
|
database := openTestDatabase(t)
|
||||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
migrateToV4(t, database)
|
||||||
t.Fatalf("apply migrations: %v", err)
|
|
||||||
}
|
|
||||||
deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||||
hash := make([]byte, 32)
|
hash := make([]byte, 32)
|
||||||
for index := range hash {
|
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) {
|
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -416,6 +543,24 @@ func migrateToV2(t *testing.T, database *sql.DB) {
|
|||||||
assertVersion(t, database, 2)
|
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) {
|
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
|
||||||
t.Helper()
|
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 {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("issue credential: %v", err)
|
t.Fatalf("issue credential: %v", err)
|
||||||
}
|
}
|
||||||
|
insertEvidenceClaim(t, database, issued.DeviceID)
|
||||||
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
|
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new authenticator: %v", err)
|
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)
|
t.Fatalf("migrate database: %v", err)
|
||||||
}
|
}
|
||||||
insertEvidenceAttempt(t, database)
|
insertEvidenceAttempt(t, database)
|
||||||
|
insertEvidenceClaimDevice(t, database, evidenceDeviceID)
|
||||||
|
insertEvidenceClaim(t, database, evidenceDeviceID)
|
||||||
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
|
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new evidence store: %v", err)
|
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 {
|
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()
|
t.Helper()
|
||||||
request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra)
|
request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"cmbuyer/admin/internal/auth"
|
"cmbuyer/admin/internal/auth"
|
||||||
"cmbuyer/admin/internal/deviceauth"
|
"cmbuyer/admin/internal/deviceauth"
|
||||||
"cmbuyer/admin/internal/evidence"
|
"cmbuyer/admin/internal/evidence"
|
||||||
|
"cmbuyer/admin/internal/taskclaim"
|
||||||
"cmbuyer/admin/internal/taskdetail"
|
"cmbuyer/admin/internal/taskdetail"
|
||||||
"cmbuyer/admin/internal/tasks"
|
"cmbuyer/admin/internal/tasks"
|
||||||
"cmbuyer/admin/internal/transport/webui"
|
"cmbuyer/admin/internal/transport/webui"
|
||||||
@@ -36,11 +37,12 @@ type Options struct {
|
|||||||
TaskDetails taskdetail.Store
|
TaskDetails taskdetail.Store
|
||||||
Evidence evidence.Store
|
Evidence evidence.Store
|
||||||
DeviceAuthenticator deviceauth.Authenticator
|
DeviceAuthenticator deviceauth.Authenticator
|
||||||
|
TaskClaims taskclaim.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||||
func NewRouter(options Options) (*gin.Engine, error) {
|
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")
|
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", createTask(options))
|
||||||
router.POST("/tasks/start-purchases", startPurchases(options))
|
router.POST("/tasks/start-purchases", startPurchases(options))
|
||||||
router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(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("/evidence/:asset_id", readEvidence(options))
|
||||||
router.GET("/static/tasks.js", func(context *gin.Context) {
|
router.GET("/static/tasks.js", func(context *gin.Context) {
|
||||||
context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript())
|
context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript())
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"cmbuyer/admin/internal/deviceauth"
|
"cmbuyer/admin/internal/deviceauth"
|
||||||
"cmbuyer/admin/internal/evidence"
|
"cmbuyer/admin/internal/evidence"
|
||||||
"cmbuyer/admin/internal/server"
|
"cmbuyer/admin/internal/server"
|
||||||
|
"cmbuyer/admin/internal/taskclaim"
|
||||||
"cmbuyer/admin/internal/taskdetail"
|
"cmbuyer/admin/internal/taskdetail"
|
||||||
"cmbuyer/admin/internal/tasks"
|
"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) {
|
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()
|
t.Helper()
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
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,
|
TaskDetails: details,
|
||||||
Evidence: evidenceStore,
|
Evidence: evidenceStore,
|
||||||
DeviceAuthenticator: deviceAuthenticator,
|
DeviceAuthenticator: deviceAuthenticator,
|
||||||
|
TaskClaims: claims,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewRouter: %v", err)
|
t.Fatalf("NewRouter: %v", err)
|
||||||
@@ -517,6 +523,16 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
|
|||||||
|
|
||||||
type emptyDetailStore struct{}
|
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) {
|
func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
|
||||||
return taskdetail.Detail{}, taskdetail.ErrNotFound
|
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
|
return existing, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var attemptCount int
|
var ownedClaimCount int
|
||||||
if err := transaction.QueryRowContext(ctx, "SELECT COUNT(*) FROM purchase_attempts WHERE task_id = ? AND id = ?", metadata.TaskID, metadata.AttemptID).Scan(&attemptCount); err != nil {
|
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
|
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
|
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) {
|
func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
|
||||||
database, store := newTestStore(t)
|
database, store := newTestStore(t)
|
||||||
insertAttemptFixture(t, database)
|
insertAttemptFixture(t, database)
|
||||||
@@ -494,6 +529,12 @@ func newTestStore(t *testing.T) (*sql.DB, *Store) {
|
|||||||
func insertAttemptFixture(t *testing.T, database *sql.DB) {
|
func insertAttemptFixture(t *testing.T, database *sql.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
timestamp := "2026-08-04T00:00:00Z"
|
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 {
|
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)
|
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 {
|
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)
|
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 {
|
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)
|
||||||
|
}
|
||||||
@@ -17,11 +17,17 @@ const (
|
|||||||
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||||
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
|
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||||
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
|
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||||
|
detailDevice = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
|
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
|
||||||
database := openDetailDatabase(t)
|
database := openDetailDatabase(t)
|
||||||
timestamp := "2026-08-04T00:00:00Z"
|
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 {
|
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)
|
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 {
|
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)
|
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)
|
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)
|
t.Fatalf("insert evidence: %v", err)
|
||||||
}
|
}
|
||||||
store, err := NewSQLiteStore(database)
|
store, err := NewSQLiteStore(database)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ type TaskRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeCents(value string) (string, *big.Int, bool) {
|
func normalizeCents(value string) (string, *big.Int, bool) {
|
||||||
if value == "" || strings.TrimSpace(value) != value {
|
if value == "" || len(value) > MaxMoneyASCIICharacters || strings.TrimSpace(value) != value {
|
||||||
return "", nil, false
|
return "", nil, false
|
||||||
}
|
}
|
||||||
parts := strings.Split(value, ".")
|
parts := strings.Split(value, ".")
|
||||||
@@ -71,6 +71,11 @@ func normalizeCents(value string) (string, *big.Int, bool) {
|
|||||||
return value, cents, true
|
return value, cents, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidCanonicalMoney(value string) bool {
|
||||||
|
canonical, _, ok := normalizeCents(value)
|
||||||
|
return ok && canonical == value
|
||||||
|
}
|
||||||
|
|
||||||
func startItems(command StartCommand) ([]StartItem, error) {
|
func startItems(command StartCommand) ([]StartItem, error) {
|
||||||
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
|
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
|
||||||
return nil, ErrInvalidStart
|
return nil, ErrInvalidStart
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -164,12 +165,27 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
|
|||||||
"nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
"nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||||
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='937x' WHERE id=?`, id)
|
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='937x' WHERE id=?`, id)
|
||||||
},
|
},
|
||||||
|
"overlong goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||||
|
execTestSQL(t, store.database, `UPDATE tasks SET goods_id=? WHERE id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
|
||||||
|
},
|
||||||
|
"invalid utf8 title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||||
|
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), id)
|
||||||
|
},
|
||||||
|
"overlong title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||||
|
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
|
||||||
|
},
|
||||||
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||||
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
|
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
|
||||||
},
|
},
|
||||||
|
"overlong color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||||
|
execTestSQL(t, store.database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
|
||||||
|
},
|
||||||
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||||
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
|
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
|
||||||
},
|
},
|
||||||
|
"overlong size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||||
|
execTestSQL(t, store.database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
|
||||||
|
},
|
||||||
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
|
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
|
||||||
store.policy.MaxQuantity = 1
|
store.policy.MaxQuantity = 1
|
||||||
},
|
},
|
||||||
@@ -179,6 +195,9 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
|
|||||||
"noncanonical leading zero": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
"noncanonical leading zero": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||||
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='012.80' WHERE id=?`, id)
|
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='012.80' WHERE id=?`, id)
|
||||||
},
|
},
|
||||||
|
"overlong canonical price": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||||
|
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price=? WHERE id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
|
||||||
|
},
|
||||||
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
|
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
|
||||||
store.policy.MaxTotalPrice = "12.79"
|
store.policy.MaxTotalPrice = "12.79"
|
||||||
},
|
},
|
||||||
@@ -199,6 +218,43 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartPurchasesReplayRejectsMalformedAuthorizationOrTaskSnapshot(t *testing.T) {
|
||||||
|
mutations := map[string]func(*testing.T, *sql.DB, string){
|
||||||
|
"authorization goods id": func(t *testing.T, database *sql.DB, id string) {
|
||||||
|
execTestSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE task_id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
|
||||||
|
},
|
||||||
|
"authorization color": func(t *testing.T, database *sql.DB, id string) {
|
||||||
|
execTestSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE task_id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
|
||||||
|
},
|
||||||
|
"authorization size": func(t *testing.T, database *sql.DB, id string) {
|
||||||
|
execTestSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE task_id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
|
||||||
|
},
|
||||||
|
"authorization money": func(t *testing.T, database *sql.DB, id string) {
|
||||||
|
execTestSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE task_id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
|
||||||
|
},
|
||||||
|
"task title": func(t *testing.T, database *sql.DB, id string) {
|
||||||
|
execTestSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, mutate := range mutations {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
database := migratedDatabase(t)
|
||||||
|
store := configuredStartStore(t, database)
|
||||||
|
id := startTestUUID(1)
|
||||||
|
createStartDraft(t, store, id)
|
||||||
|
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}
|
||||||
|
if _, err := store.StartPurchases(context.Background(), command, "admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mutate(t, database, id)
|
||||||
|
if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) {
|
||||||
|
t.Fatalf("replay error = %v, want ErrStartConflict", err)
|
||||||
|
}
|
||||||
|
assertAuthorizationCount(t, database, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
|
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
|
|||||||
}
|
}
|
||||||
return StartResult{}, err
|
return StartResult{}, err
|
||||||
}
|
}
|
||||||
if status != "DRAFT" || version != item.ExpectedTaskVersion || !goodsIDValid(goods) || color == "" || size == "" || quantity < 1 || quantity > store.policy.MaxQuantity {
|
if status != "DRAFT" || version != item.ExpectedTaskVersion ||
|
||||||
|
!ValidTaskWireFields(title, goods, color, size, price) ||
|
||||||
|
quantity < 1 || quantity > store.policy.MaxQuantity {
|
||||||
return StartResult{}, ErrStartConflict
|
return StartResult{}, ErrStartConflict
|
||||||
}
|
}
|
||||||
canonical, cents, ok := normalizeCents(price)
|
canonical, cents, ok := normalizeCents(price)
|
||||||
@@ -179,20 +181,15 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func goodsIDValid(value string) bool {
|
|
||||||
if value == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, ch := range value {
|
|
||||||
if ch < '0' || ch > '9' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) {
|
func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) {
|
||||||
rows, err := tx.QueryContext(ctx, "SELECT id,task_id,task_version,expires_at FROM order_authorizations WHERE start_key=? ORDER BY task_id", startKey)
|
rows, err := tx.QueryContext(ctx, `SELECT authorizations.id,authorizations.task_id,
|
||||||
|
authorizations.task_version,authorizations.expires_at,authorizations.goods_id,
|
||||||
|
authorizations.sku_color,authorizations.sku_size,authorizations.quantity,
|
||||||
|
authorizations.total_price_cap,tasks.title,tasks.goods_id,tasks.sku_color,
|
||||||
|
tasks.sku_size,tasks.quantity,tasks.max_total_price
|
||||||
|
FROM order_authorizations AS authorizations
|
||||||
|
JOIN tasks ON tasks.id = authorizations.task_id
|
||||||
|
WHERE authorizations.start_key=? ORDER BY authorizations.task_id`, startKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return StartResult{}, false, err
|
return StartResult{}, false, err
|
||||||
}
|
}
|
||||||
@@ -201,9 +198,22 @@ func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []Start
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item AuthorizedTask
|
var item AuthorizedTask
|
||||||
var expires string
|
var expires string
|
||||||
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires); err != nil {
|
var authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice string
|
||||||
|
var taskTitle, taskGoodsID, taskColor, taskSize, taskPrice string
|
||||||
|
var authorizationQuantity, taskQuantity int
|
||||||
|
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires,
|
||||||
|
&authorizationGoodsID, &authorizationColor, &authorizationSize, &authorizationQuantity,
|
||||||
|
&authorizationPrice, &taskTitle, &taskGoodsID, &taskColor, &taskSize, &taskQuantity,
|
||||||
|
&taskPrice); err != nil {
|
||||||
return StartResult{}, false, err
|
return StartResult{}, false, err
|
||||||
}
|
}
|
||||||
|
if !ValidAuthorizationFields(authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice) ||
|
||||||
|
authorizationQuantity <= 0 ||
|
||||||
|
!ValidTaskWireFields(taskTitle, taskGoodsID, taskColor, taskSize, taskPrice) || taskQuantity <= 0 ||
|
||||||
|
authorizationGoodsID != taskGoodsID || authorizationColor != taskColor ||
|
||||||
|
authorizationSize != taskSize || authorizationQuantity != taskQuantity || authorizationPrice != taskPrice {
|
||||||
|
return StartResult{}, false, ErrStartConflict
|
||||||
|
}
|
||||||
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
|
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return StartResult{}, false, err
|
return StartResult{}, false, err
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
|
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
|
||||||
|
// Validate again at the persistence boundary. HTTP form validation is not the only
|
||||||
|
// caller, and a malformed row here would later make an authorized claim unencodable.
|
||||||
|
if !validUUID(draft.ID) || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) || draft.Quantity <= 0 {
|
||||||
|
return Draft{}, ErrInvalidDraft
|
||||||
|
}
|
||||||
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
|
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
// SQLite permits one writer at a time. Serializing this store's short create
|
// SQLite permits one writer at a time. Serializing this store's short create
|
||||||
|
|||||||
@@ -9,14 +9,21 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxTitleLength = 120
|
MaxTitleCodePoints = 120
|
||||||
maxSKUText = 80
|
MaxSKUTextCodePoints = 80
|
||||||
|
MaxGoodsIDCharacters = 32
|
||||||
|
MaxMoneyASCIICharacters = 32
|
||||||
|
maxSKUText = MaxSKUTextCodePoints
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
|
var (
|
||||||
|
ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
|
||||||
|
ErrInvalidDraft = errors.New("invalid draft")
|
||||||
|
)
|
||||||
|
|
||||||
type Draft struct {
|
type Draft struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -41,13 +48,13 @@ func Validate(form Form) (Draft, Errors) {
|
|||||||
if !validUUID(draft.ID) {
|
if !validUUID(draft.ID) {
|
||||||
errors["create_key"] = "创建请求已过期,请重新打开表单。"
|
errors["create_key"] = "创建请求已过期,请重新打开表单。"
|
||||||
}
|
}
|
||||||
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
|
if !validBoundedText(draft.Title, MaxTitleCodePoints) {
|
||||||
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
|
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
|
||||||
}
|
}
|
||||||
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
|
if !validBoundedText(draft.SKUColor, MaxSKUTextCodePoints) {
|
||||||
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
|
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
|
||||||
}
|
}
|
||||||
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
|
if !validBoundedText(draft.SKUSize, MaxSKUTextCodePoints) {
|
||||||
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
|
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
|
||||||
}
|
}
|
||||||
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
|
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
|
||||||
@@ -93,6 +100,9 @@ func CanonicalGoodsID(value string) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !ValidGoodsID(goodsIDs[0]) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
return goodsIDs[0], true
|
return goodsIDs[0], true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,5 +165,54 @@ func normalizeMoney(value string) (string, bool) {
|
|||||||
if whole == "0" && strings.Trim(fraction, "0") == "" {
|
if whole == "0" && strings.Trim(fraction, "0") == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return whole + "." + (fraction + "00")[:2], true
|
canonical := whole + "." + (fraction + "00")[:2]
|
||||||
|
if len(canonical) > MaxMoneyASCIICharacters {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return canonical, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidTaskWireFields is shared by creation, authorization and claim. Keeping one
|
||||||
|
// bounded domain prevents a database row from being valid in one stage but impossible
|
||||||
|
// to encode inside the fixed claim response budget in another stage.
|
||||||
|
func ValidTaskWireFields(title, goodsID, skuColor, skuSize, maxTotalPrice string) bool {
|
||||||
|
return validBoundedText(title, MaxTitleCodePoints) &&
|
||||||
|
ValidAuthorizationFields(goodsID, skuColor, skuSize, maxTotalPrice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidAuthorizationFields(goodsID, skuColor, skuSize, totalPriceCap string) bool {
|
||||||
|
return ValidGoodsID(goodsID) &&
|
||||||
|
validBoundedText(skuColor, MaxSKUTextCodePoints) &&
|
||||||
|
validBoundedText(skuSize, MaxSKUTextCodePoints) &&
|
||||||
|
ValidCanonicalMoney(totalPriceCap)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidGoodsID(value string) bool {
|
||||||
|
if value == "" || len(value) > MaxGoodsIDCharacters {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index := 0; index < len(value); index++ {
|
||||||
|
if value[index] < '0' || value[index] > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validBoundedText(value string, maximum int) bool {
|
||||||
|
// RuneCountInString replaces malformed byte sequences with RuneError. Validate first
|
||||||
|
// so corrupt SQLite text cannot consume the code-point budget as if it were legitimate.
|
||||||
|
if !utf8.ValidString(value) || value == "" || strings.TrimSpace(value) != value ||
|
||||||
|
utf8.RuneCountInString(value) > maximum {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
// Python str.strip treats these four C0 separators as whitespace while Go
|
||||||
|
// TrimSpace does not. Reject them everywhere so both wire models have one
|
||||||
|
// explicit persisted-text domain instead of runtime-dependent trimming.
|
||||||
|
if character >= '\u001c' && character <= '\u001f' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -39,7 +40,10 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
|
|||||||
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
|
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
|
||||||
for name, update := range map[string]func(*Form){
|
for name, update := range map[string]func(*Form){
|
||||||
"empty title": func(form *Form) { form.Title = " " },
|
"empty title": func(form *Form) { form.Title = " " },
|
||||||
|
"invalid utf8 title": func(form *Form) { form.Title = string([]byte{0xff}) },
|
||||||
|
"long title": func(form *Form) { form.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
|
||||||
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
|
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
|
||||||
|
"invalid utf8 size": func(form *Form) { form.SKUSize = string([]byte{0xff}) },
|
||||||
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
|
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
|
||||||
"zero quantity": func(form *Form) { form.Quantity = "0" },
|
"zero quantity": func(form *Form) { form.Quantity = "0" },
|
||||||
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
|
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
|
||||||
@@ -67,6 +71,7 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
|
|||||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
|
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
|
||||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
|
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
|
||||||
"https://mobile.yangkeduo.com/other.html?goods_id=1",
|
"https://mobile.yangkeduo.com/other.html?goods_id=1",
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=" + strings.Repeat("1", MaxGoodsIDCharacters+1),
|
||||||
} {
|
} {
|
||||||
if _, ok := CanonicalGoodsID(value); ok {
|
if _, ok := CanonicalGoodsID(value); ok {
|
||||||
t.Fatalf("CanonicalGoodsID accepted %q", value)
|
t.Fatalf("CanonicalGoodsID accepted %q", value)
|
||||||
@@ -75,19 +80,62 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeMoneyBoundaries(t *testing.T) {
|
func TestNormalizeMoneyBoundaries(t *testing.T) {
|
||||||
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} {
|
maximum := strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00"
|
||||||
|
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00", maximum: maximum} {
|
||||||
got, ok := normalizeMoney(value)
|
got, ok := normalizeMoney(value)
|
||||||
if !ok || got != want {
|
if !ok || got != want {
|
||||||
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
|
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1"} {
|
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1", strings.Repeat("9", MaxMoneyASCIICharacters-2) + ".00"} {
|
||||||
if got, ok := normalizeMoney(value); ok {
|
if got, ok := normalizeMoney(value); ok {
|
||||||
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
|
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateAcceptsWorstLegalUnicodeFieldBounds(t *testing.T) {
|
||||||
|
goodsID := strings.Repeat("1", MaxGoodsIDCharacters)
|
||||||
|
draft, validation := Validate(Form{
|
||||||
|
CreateKey: testKey, Title: strings.Repeat("😀", MaxTitleCodePoints),
|
||||||
|
ProductURL: CanonicalURL(goodsID), SKUColor: strings.Repeat("色", MaxSKUTextCodePoints),
|
||||||
|
SKUSize: strings.Repeat("码", MaxSKUTextCodePoints), Quantity: "1",
|
||||||
|
MaxTotalPrice: strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00",
|
||||||
|
})
|
||||||
|
if !validation.Valid() || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) {
|
||||||
|
t.Fatalf("worst legal draft = %#v, validation = %#v", draft, validation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersistedTextHasRuntimeIndependentC0AndNBSPDomain(t *testing.T) {
|
||||||
|
for name, invalid := range map[string]string{
|
||||||
|
"c0 prefix": "\u001cvalue",
|
||||||
|
"c0 suffix": "value\u001f",
|
||||||
|
"c0 interior": "value\u001dinside",
|
||||||
|
"nbsp prefix": "\u00a0value",
|
||||||
|
"nbsp suffix": "value\u00a0",
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if validBoundedText(invalid, MaxTitleCodePoints) {
|
||||||
|
t.Fatalf("validBoundedText(%q) accepted runtime-dependent text", invalid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !validBoundedText("left\u00a0right", MaxTitleCodePoints) {
|
||||||
|
t.Fatal("interior NBSP must remain a valid Unicode code point")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual form input is normalized with Go TrimSpace before persistence.
|
||||||
|
draft, validation := Validate(Form{
|
||||||
|
CreateKey: testKey, Title: "\u00a0title\u00a0",
|
||||||
|
ProductURL: CanonicalURL("1"), SKUColor: "\u00a0black\u00a0",
|
||||||
|
SKUSize: "\u00a0M\u00a0", Quantity: "1", MaxTotalPrice: "1",
|
||||||
|
})
|
||||||
|
if !validation.Valid() || draft.Title != "title" || draft.SKUColor != "black" || draft.SKUSize != "M" {
|
||||||
|
t.Fatalf("NBSP form normalization = %#v, errors = %#v", draft, validation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
|
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
|
||||||
key, err := NewCreateKey()
|
key, err := NewCreateKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,6 +205,37 @@ func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) {
|
||||||
|
mutations := map[string]func(*Draft){
|
||||||
|
"untrimmed title": func(draft *Draft) { draft.Title = " title" },
|
||||||
|
"c0 interior title": func(draft *Draft) { draft.Title = "title\u001dhidden" },
|
||||||
|
"invalid utf8 title": func(draft *Draft) { draft.Title = string([]byte{0xff}) },
|
||||||
|
"long title": func(draft *Draft) { draft.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
|
||||||
|
"long color": func(draft *Draft) { draft.SKUColor = strings.Repeat("色", MaxSKUTextCodePoints+1) },
|
||||||
|
"long size": func(draft *Draft) { draft.SKUSize = strings.Repeat("码", MaxSKUTextCodePoints+1) },
|
||||||
|
"long goods id": func(draft *Draft) { draft.GoodsID = strings.Repeat("1", MaxGoodsIDCharacters+1) },
|
||||||
|
"long money": func(draft *Draft) { draft.MaxTotalPrice = strings.Repeat("1", MaxMoneyASCIICharacters-2) + ".00" },
|
||||||
|
}
|
||||||
|
for name, mutate := range mutations {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
database := migratedDatabase(t)
|
||||||
|
store, err := NewSQLiteStore(database)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
draft := testDraft(testKey, "title")
|
||||||
|
mutate(&draft)
|
||||||
|
if _, err := store.CreateDraft(context.Background(), draft); !errors.Is(err, ErrInvalidDraft) {
|
||||||
|
t.Fatalf("CreateDraft error = %v, want ErrInvalidDraft", err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := database.QueryRow("SELECT COUNT(*) FROM tasks").Scan(&count); err != nil || count != 0 {
|
||||||
|
t.Fatalf("tasks after invalid create = %d, err %v", count, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
|
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
|
||||||
database := migratedDatabase(t)
|
database := migratedDatabase(t)
|
||||||
store, err := NewSQLiteStore(database)
|
store, err := NewSQLiteStore(database)
|
||||||
|
|||||||
@@ -0,0 +1,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;
|
||||||
@@ -15,7 +15,7 @@ from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, Subproce
|
|||||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||||
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||||
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError, TASK_TO_UI_SELECTION
|
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError, TASK_TO_UI_SELECTION
|
||||||
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner
|
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner, safe_failure_stage
|
||||||
|
|
||||||
|
|
||||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
@@ -66,7 +66,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir)
|
result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir)
|
||||||
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
|
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||||
# Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
|
# Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
|
||||||
print("规格恢复失败:已停止,未发布本地证据目录。", file=sys.stderr)
|
print(
|
||||||
|
f"规格恢复失败:stage={safe_failure_stage(error)};已停止,未发布本地证据目录。",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
except OSError:
|
except OSError:
|
||||||
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from .core.errors import StateError
|
||||||
from .logging_policy import configure_application_logger
|
from .logging_policy import configure_application_logger
|
||||||
from .runtime import RuntimePaths
|
from .runtime import LocalStateRuntime, RuntimePaths
|
||||||
|
|
||||||
|
|
||||||
def select_application_argv(argv: Sequence[str] | None) -> list[str]:
|
def select_application_argv(argv: Sequence[str] | None) -> list[str]:
|
||||||
@@ -30,8 +31,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtWidgets import QApplication
|
||||||
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.error("缺少 PySide6,无法启动桌面界面。")
|
logger.error("缺少 PySide6,无法启动桌面界面。")
|
||||||
print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr)
|
print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr)
|
||||||
@@ -39,19 +39,47 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
|
|
||||||
application = QApplication.instance() or QApplication(select_application_argv(argv))
|
application = QApplication.instance() or QApplication(select_application_argv(argv))
|
||||||
application.setApplicationName("采购工具")
|
application.setApplicationName("采购工具")
|
||||||
|
runtime: LocalStateRuntime | None = None
|
||||||
|
coordinator = None
|
||||||
|
try:
|
||||||
|
runtime = LocalStateRuntime.open(paths)
|
||||||
|
try:
|
||||||
|
summary = runtime.store.load_profile_summary("default")
|
||||||
|
except StateError as error:
|
||||||
|
if error.reason != "profile_not_found":
|
||||||
|
raise
|
||||||
|
summary = None
|
||||||
|
|
||||||
window = QMainWindow()
|
from .polling.coordinator import PollingCoordinator
|
||||||
window.setWindowTitle("采购工具")
|
from .ui.main_window import PurchaseToolWindow
|
||||||
window.setAccessibleName("采购工具")
|
|
||||||
window.setMinimumSize(420, 240)
|
|
||||||
window.resize(560, 320)
|
|
||||||
|
|
||||||
message = QLabel("应用骨架已初始化。\n采购执行功能尚未启用。")
|
settings = None if summary is None else summary.settings
|
||||||
message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
has_token = False if summary is None else summary.has_stored_device_token
|
||||||
message.setWordWrap(True)
|
coordinator = PollingCoordinator(
|
||||||
message.setAccessibleName("当前状态")
|
profile_id="default",
|
||||||
window.setCentralWidget(message)
|
store=runtime.store,
|
||||||
|
gateway_factory=None,
|
||||||
logger.info("应用已启动;采购执行功能尚未启用。")
|
consumer=None,
|
||||||
|
profile_settings=settings,
|
||||||
|
poll_interval_seconds=15 if settings is None else settings.poll_interval_seconds,
|
||||||
|
failure_threshold=3 if settings is None else settings.failure_threshold,
|
||||||
|
)
|
||||||
|
window = PurchaseToolWindow(
|
||||||
|
store=runtime.store,
|
||||||
|
coordinator=coordinator,
|
||||||
|
profile_settings=settings,
|
||||||
|
has_stored_device_token=has_token,
|
||||||
|
)
|
||||||
|
logger.info("应用已启动;单趟执行能力尚未接入,真实领取保持禁用。")
|
||||||
window.show()
|
window.show()
|
||||||
return application.exec()
|
return application.exec()
|
||||||
|
except (OSError, RuntimeError, StateError):
|
||||||
|
logger.error("无法打开采购工具本地安全状态。")
|
||||||
|
print("无法启动采购工具:本地安全状态不可用。", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
finally:
|
||||||
|
worker_stopped = True
|
||||||
|
if coordinator is not None:
|
||||||
|
worker_stopped = coordinator.shutdown()
|
||||||
|
if runtime is not None and worker_stopped:
|
||||||
|
runtime.close()
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""与 UI、HTTP 和拼多多页面实现无关的客户端核心契约。"""
|
||||||
|
|
||||||
|
from .errors import (
|
||||||
|
AmbiguousRemoteError,
|
||||||
|
CredentialRemoteError,
|
||||||
|
ManualRemoteError,
|
||||||
|
ProtocolRemoteError,
|
||||||
|
StateError,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
from .models import (
|
||||||
|
AssetReceipt,
|
||||||
|
AuthorizationSnapshot,
|
||||||
|
ClaimRequest,
|
||||||
|
ClaimedTask,
|
||||||
|
DeviceCredentials,
|
||||||
|
EvidenceUpload,
|
||||||
|
PurchaseTask,
|
||||||
|
RenewRequest,
|
||||||
|
RenewResult,
|
||||||
|
SecretToken,
|
||||||
|
)
|
||||||
|
from .ports import EvidenceSink, TaskSource
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AmbiguousRemoteError",
|
||||||
|
"AssetReceipt",
|
||||||
|
"AuthorizationSnapshot",
|
||||||
|
"ClaimRequest",
|
||||||
|
"ClaimedTask",
|
||||||
|
"CredentialRemoteError",
|
||||||
|
"DeviceCredentials",
|
||||||
|
"EvidenceSink",
|
||||||
|
"EvidenceUpload",
|
||||||
|
"ManualRemoteError",
|
||||||
|
"ProtocolRemoteError",
|
||||||
|
"PurchaseTask",
|
||||||
|
"RenewRequest",
|
||||||
|
"RenewResult",
|
||||||
|
"SecretToken",
|
||||||
|
"StateError",
|
||||||
|
"TaskSource",
|
||||||
|
"ValidationError",
|
||||||
|
]
|
||||||
@@ -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,
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
|
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
|
||||||
|
_BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[0-9a-f]{64}\b")
|
||||||
|
_BARE_TOKEN_PATTERN = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
|
||||||
|
|
||||||
|
|
||||||
def redact_text(message: str) -> str:
|
def redact_text(message: str) -> str:
|
||||||
@@ -31,7 +33,9 @@ def redact_text(message: str) -> str:
|
|||||||
def replace_key_value(match: re.Match[str]) -> str:
|
def replace_key_value(match: re.Match[str]) -> str:
|
||||||
return f"{match.group('key')}{match.group('separator')}{REDACTED}"
|
return f"{match.group('key')}{match.group('separator')}{REDACTED}"
|
||||||
|
|
||||||
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, message)
|
redacted = _BEARER_PATTERN.sub("Bearer " + REDACTED, message)
|
||||||
|
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, redacted)
|
||||||
|
redacted = _BARE_TOKEN_PATTERN.sub(REDACTED, redacted)
|
||||||
return _PHONE_PATTERN.sub(REDACTED, redacted)
|
return _PHONE_PATTERN.sub(REDACTED, redacted)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +51,16 @@ class SensitiveDataFilter(logging.Filter):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class RedactingFormatter(logging.Formatter):
|
||||||
|
"""再次处理完整格式化文本,覆盖异常 traceback 中的敏感值。"""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
return redact_text(super().format(record))
|
||||||
|
|
||||||
|
def formatException(self, exc_info: tuple[type[BaseException], BaseException, object]) -> str:
|
||||||
|
return redact_text(super().formatException(exc_info))
|
||||||
|
|
||||||
|
|
||||||
def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
||||||
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。"""
|
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。"""
|
||||||
|
|
||||||
@@ -61,6 +75,6 @@ def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
|||||||
|
|
||||||
handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
|
handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
|
||||||
handler.addFilter(SensitiveDataFilter())
|
handler.addFilter(SensitiveDataFilter())
|
||||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
handler.setFormatter(RedactingFormatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
return logger
|
return logger
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ _ENTRY = "快要抢光"
|
|||||||
_ENTRY_TEXT_BOUNDS = "[900,1312][1056,1355]"
|
_ENTRY_TEXT_BOUNDS = "[900,1312][1056,1355]"
|
||||||
_ENTRY_INNER_BOUNDS = "[712,1312][1056,1355]"
|
_ENTRY_INNER_BOUNDS = "[712,1312][1056,1355]"
|
||||||
_ENTRY_ACTION_BOUNDS = "[0,1256][1080,1355]"
|
_ENTRY_ACTION_BOUNDS = "[0,1256][1080,1355]"
|
||||||
|
_FORBIDDEN_ENTRY_ACTION_DESC = (
|
||||||
|
"购买", "下单", "付款", "订单", "免拼购买", "单独购买", "直接拼成", "提交订单", "支付",
|
||||||
|
"先用后付", "0元下单", "0 元下单",
|
||||||
|
)
|
||||||
_SIZE = "尺码"
|
_SIZE = "尺码"
|
||||||
_W, _H = 1080, 2376
|
_W, _H = 1080, 2376
|
||||||
_PRICE_PARENT = "[396,498][895,570]"
|
_PRICE_PARENT = "[396,498][895,570]"
|
||||||
@@ -89,7 +93,9 @@ class SkuSelectionFlow:
|
|||||||
raise SkuSelectionError("商品不是已取证目标,已停止操作。")
|
raise SkuSelectionError("商品不是已取证目标,已停止操作。")
|
||||||
if pre_intent_hierarchy is not None:
|
if pre_intent_hierarchy is not None:
|
||||||
previous_nodes = _parse_nodes(pre_intent_hierarchy)
|
previous_nodes = _parse_nodes(pre_intent_hierarchy)
|
||||||
if _eligible_entries(previous_nodes):
|
# intent 前只判断旧页是否已经存在完整入口链;浮层或额外动作节点不能把旧商品
|
||||||
|
# 伪装成“不安全所以不存在”,否则 intent 后可能误把旧页当成新目标页。
|
||||||
|
if _physical_entries(previous_nodes):
|
||||||
raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。")
|
raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。")
|
||||||
entry, before = self._wait_for_entry(pre_intent_hierarchy)
|
entry, before = self._wait_for_entry(pre_intent_hierarchy)
|
||||||
_action_bounds(entry.bounds)
|
_action_bounds(entry.bounds)
|
||||||
@@ -168,13 +174,19 @@ class SkuSelectionFlow:
|
|||||||
current = self._device.app_current()
|
current = self._device.app_current()
|
||||||
if isinstance(current, dict) and current.get("package") == PDD_PACKAGE:
|
if isinstance(current, dict) and current.get("package") == PDD_PACKAGE:
|
||||||
raw = self._read_hierarchy()
|
raw = self._read_hierarchy()
|
||||||
entries = _eligible_entries(_parse_nodes(raw))
|
nodes = _parse_nodes(raw)
|
||||||
|
entries = _eligible_entries(nodes)
|
||||||
if len(entries) > 1:
|
if len(entries) > 1:
|
||||||
raise SkuSelectionError("商品页规格入口不唯一,已停止操作。")
|
raise SkuSelectionError("商品页规格入口不唯一,已停止操作。")
|
||||||
if len(entries) == 1 and raw != previous:
|
if len(entries) == 1 and raw != previous:
|
||||||
if stable == raw:
|
# 商品详情正文包含倒计时等动态节点,全文 XML 稳定不是已取证入口的安全属性。
|
||||||
|
# 连续两帧只比较完整五层入口判据投影;任一层失配仍会在 eligible 阶段清空稳定态。
|
||||||
|
projection = _entry_projection(entries[0], nodes)
|
||||||
|
if projection is None:
|
||||||
|
raise SkuSelectionError("商品页规格入口结构失效,已停止操作。")
|
||||||
|
if stable == projection:
|
||||||
return entries[0], raw
|
return entries[0], raw
|
||||||
stable = raw
|
stable = projection
|
||||||
else:
|
else:
|
||||||
stable = None
|
stable = None
|
||||||
else:
|
else:
|
||||||
@@ -358,41 +370,128 @@ def _eligible_entries(nodes: list[_Node]) -> list[_Node]:
|
|||||||
# 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续
|
# 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续
|
||||||
# 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部
|
# 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部
|
||||||
# 容器既不属于这条祖先链,也绝不能作为替代入口。
|
# 容器既不属于这条祖先链,也绝不能作为替代入口。
|
||||||
if any(node.bounds == _PRICE_PARENT for node in nodes): return []
|
if any(node.bounds == _PRICE_PARENT for node in nodes):
|
||||||
|
return []
|
||||||
|
entries = _physical_entries(nodes)
|
||||||
|
if len(entries) != 1:
|
||||||
|
return entries
|
||||||
|
entry = entries[0]
|
||||||
|
chain = _physical_entry_chain(entry)
|
||||||
|
if chain is None or _entry_projection(entry, nodes) is None:
|
||||||
|
return []
|
||||||
|
ancestor = chain[-1]
|
||||||
|
left, top, right, bottom = _action_bounds(entry.bounds)
|
||||||
|
center = (left + (right - left) // 2, top + (bottom - top) // 2)
|
||||||
|
occupants = _live_clickables_covering(nodes, center)
|
||||||
|
# RPC 点的是文本中心而不是祖先对象。只有完整入口链自己的动作祖先占用该坐标时才可点击;
|
||||||
|
# SystemUI 浮层、额外按钮或任意部分覆盖矩形都可能截获触摸,必须零点击失败关闭。
|
||||||
|
return entries if len(occupants) == 1 and occupants[0].element is ancestor.element else []
|
||||||
|
|
||||||
|
|
||||||
|
def _physical_entries(nodes: list[_Node]) -> list[_Node]:
|
||||||
entry_labels = [
|
entry_labels = [
|
||||||
node for node in nodes
|
node for node in nodes
|
||||||
if node.text == _ENTRY
|
if node.text == _ENTRY
|
||||||
and node.element.get("package") == PDD_PACKAGE
|
and node.element.get("package") == PDD_PACKAGE
|
||||||
and node.element.get("class") == "android.widget.TextView"
|
and node.element.get("class") == "android.widget.TextView"
|
||||||
]
|
]
|
||||||
if len(entry_labels) != 1:
|
# 这里故意只证明物理五层链。pre-intent 必须识别旧商品,不能让禁词、浮层或其他
|
||||||
return entry_labels
|
# post-intent 安全条件把已经存在的旧入口伪装成“不存在”。
|
||||||
action_ancestors = [
|
return [node for node in entry_labels if _physical_entry_chain(node) is not None]
|
||||||
node for node in nodes
|
|
||||||
if _exact_entry_node(node, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true")
|
|
||||||
]
|
def _physical_entry_chain(node: _Node) -> tuple[_Node, ...] | None:
|
||||||
if len(action_ancestors) != 1:
|
if node.text != _ENTRY or not _exact_entry_node(
|
||||||
return []
|
node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"
|
||||||
action_ancestor = action_ancestors[0]
|
) or len(node.element) != 0:
|
||||||
entries: list[_Node] = []
|
return None
|
||||||
for node in entry_labels:
|
|
||||||
if not _exact_entry_node(node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"):
|
|
||||||
return []
|
|
||||||
inner = node.parent
|
inner = node.parent
|
||||||
switcher = inner.parent if inner is not None else None
|
switcher = inner.parent if inner is not None else None
|
||||||
frame = switcher.parent if switcher is not None else None
|
frame = switcher.parent if switcher is not None else None
|
||||||
ancestor = frame.parent if frame is not None else None
|
ancestor = frame.parent if frame is not None else None
|
||||||
if (
|
chain = (
|
||||||
inner is not None
|
(node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"),
|
||||||
and _exact_entry_node(inner, "android.view.ViewGroup", _ENTRY_INNER_BOUNDS, "false")
|
(inner, "android.view.ViewGroup", _ENTRY_INNER_BOUNDS, "false"),
|
||||||
and switcher is not None
|
(switcher, "android.widget.ViewSwitcher", _ENTRY_INNER_BOUNDS, "false"),
|
||||||
and _exact_entry_node(switcher, "android.widget.ViewSwitcher", _ENTRY_INNER_BOUNDS, "false")
|
(frame, "android.widget.FrameLayout", _ENTRY_INNER_BOUNDS, "false"),
|
||||||
and frame is not None
|
(ancestor, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true"),
|
||||||
and _exact_entry_node(frame, "android.widget.FrameLayout", _ENTRY_INNER_BOUNDS, "false")
|
)
|
||||||
and ancestor is action_ancestor
|
if any(
|
||||||
|
candidate is None or not _exact_entry_node(candidate, class_name, bounds, clickable)
|
||||||
|
for candidate, class_name, bounds, clickable in chain
|
||||||
):
|
):
|
||||||
entries.append(node)
|
return None
|
||||||
return entries
|
return tuple(candidate for candidate, _, _, _ in chain if candidate is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_projection(node: _Node, nodes: list[_Node]) -> tuple[object, ...] | None:
|
||||||
|
chain = _physical_entry_chain(node)
|
||||||
|
if chain is None:
|
||||||
|
return None
|
||||||
|
_, inner, switcher, frame, ancestor = chain
|
||||||
|
if node.desc:
|
||||||
|
return None
|
||||||
|
if any(candidate.text or candidate.desc for candidate in (inner, switcher, frame)):
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
ancestor.text
|
||||||
|
or (ancestor.desc and not ancestor.desc.endswith(_ENTRY))
|
||||||
|
or any(forbidden in ancestor.desc for forbidden in _FORBIDDEN_ENTRY_ACTION_DESC)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
subtree = [
|
||||||
|
candidate for candidate in nodes
|
||||||
|
if candidate.element is ancestor.element or _descendant(candidate, ancestor)
|
||||||
|
]
|
||||||
|
if any(
|
||||||
|
forbidden in value
|
||||||
|
for candidate in subtree
|
||||||
|
for value in (candidate.text, candidate.desc)
|
||||||
|
for forbidden in _FORBIDDEN_ENTRY_ACTION_DESC
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
entry_text_nodes = [candidate for candidate in subtree if candidate.text == _ENTRY]
|
||||||
|
if len(entry_text_nodes) != 1 or entry_text_nodes[0].element is not node.element:
|
||||||
|
return None
|
||||||
|
# 动作祖先整棵子树按真实层级和顺序投影。允许非危险动态正文存在,但任一结构或语义
|
||||||
|
# 在两帧间变化都不会点击;正文只作为不透明稳定键,不从中解析详情价。
|
||||||
|
return _subtree_projection(ancestor.element)
|
||||||
|
|
||||||
|
|
||||||
|
def _subtree_projection(element: ElementTree.Element) -> tuple[object, ...]:
|
||||||
|
return (
|
||||||
|
element.tag,
|
||||||
|
element.get("package", ""),
|
||||||
|
element.get("class", ""),
|
||||||
|
element.get("bounds", ""),
|
||||||
|
element.get("clickable", ""),
|
||||||
|
element.get("enabled", ""),
|
||||||
|
element.get("visible-to-user", ""),
|
||||||
|
element.get("text", ""),
|
||||||
|
element.get("content-desc", ""),
|
||||||
|
tuple(_subtree_projection(child) for child in element),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_live_clickable(node: _Node) -> bool:
|
||||||
|
return (
|
||||||
|
node.element.get("clickable") == "true"
|
||||||
|
and node.element.get("enabled") == "true"
|
||||||
|
and node.element.get("visible-to-user") == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _live_clickables_covering(nodes: list[_Node], point: tuple[int, int]) -> list[_Node]:
|
||||||
|
occupants: list[_Node] = []
|
||||||
|
x, y = point
|
||||||
|
for node in nodes:
|
||||||
|
if not _is_live_clickable(node):
|
||||||
|
continue
|
||||||
|
# 活跃可点击节点的 bounds 无法验证时,无法证明它不会截获入口坐标,因此整体失败关闭。
|
||||||
|
left, top, right, bottom = _action_bounds(node.bounds)
|
||||||
|
if left <= x < right and top <= y < bottom:
|
||||||
|
occupants.append(node)
|
||||||
|
return occupants
|
||||||
|
|
||||||
|
|
||||||
def _exact_entry_node(node: _Node, class_name: str, bounds: str, clickable: str) -> bool:
|
def _exact_entry_node(node: _Node, class_name: str, bounds: str, clickable: str) -> bool:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from uiautomator2.exceptions import HTTPTimeoutError
|
|||||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||||
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
|
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
|
||||||
from .product_open import EXPECTED_PDD_VERSION
|
from .product_open import EXPECTED_PDD_VERSION
|
||||||
from .product_url import ProductUrl, parse_product_url
|
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||||
from .sku_selection import (
|
from .sku_selection import (
|
||||||
EXPECTED_GOODS_ID,
|
EXPECTED_GOODS_ID,
|
||||||
EXPECTED_UNIT_PRICE,
|
EXPECTED_UNIT_PRICE,
|
||||||
@@ -39,6 +39,24 @@ EXPECTED_DEVICE_MODEL = "PKG110"
|
|||||||
EXPECTED_ANDROID_VERSION = "16"
|
EXPECTED_ANDROID_VERSION = "16"
|
||||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||||
|
|
||||||
|
# CLI 只允许输出这些固定阶段码。阶段码描述运行器自己的控制流,不包含页面
|
||||||
|
# 文本、节点属性、serial、路径或第三方异常;未知/伪造值统一降级为 unknown。
|
||||||
|
_FAILURE_STAGES = frozenset(
|
||||||
|
(
|
||||||
|
"precheck",
|
||||||
|
"device_inspection",
|
||||||
|
"device_session",
|
||||||
|
"product_open",
|
||||||
|
"sku_entry",
|
||||||
|
"sku_selection",
|
||||||
|
"price_verification",
|
||||||
|
"screenshot_capture",
|
||||||
|
"screenshot_reverify",
|
||||||
|
"safe_exit",
|
||||||
|
"publish",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SkuSelectionRunError(RuntimeError):
|
class SkuSelectionRunError(RuntimeError):
|
||||||
"""T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
|
"""T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
|
||||||
@@ -60,6 +78,29 @@ class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
|
|||||||
"""第三方设备接口失败的脱敏映射。"""
|
"""第三方设备接口失败的脱敏映射。"""
|
||||||
|
|
||||||
|
|
||||||
|
def safe_failure_stage(error: BaseException) -> str:
|
||||||
|
"""返回允许公开的固定阶段码,绝不回显异常正文。"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
stage = getattr(error, "_cmbuyer_failure_stage", None)
|
||||||
|
# exact str 避免恶意 str 子类在 hash/eq 中执行任意异常;诊断路径
|
||||||
|
# 自己也必须失败闭合,不能让异常正文越过 CLI 的统一脱敏出口。
|
||||||
|
return stage if type(stage) is str and stage in _FAILURE_STAGES else "unknown"
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SkuSelectionRunResult:
|
class SkuSelectionRunResult:
|
||||||
"""已发布的截图和无页面正文 manifest 摘要。"""
|
"""已发布的截图和无页面正文 manifest 摘要。"""
|
||||||
@@ -183,38 +224,52 @@ class SkuSelectionRunner:
|
|||||||
task_size: str,
|
task_size: str,
|
||||||
output_directory: Path,
|
output_directory: Path,
|
||||||
) -> SkuSelectionRunResult:
|
) -> SkuSelectionRunResult:
|
||||||
|
stage = "precheck"
|
||||||
|
staging: Path | None = None
|
||||||
|
adapter: UiautomatorSkuPanelAdapter | None = None
|
||||||
|
flow: SkuSelectionFlow | None = None
|
||||||
|
try:
|
||||||
link = parse_product_url(product_url)
|
link = parse_product_url(product_url)
|
||||||
if link.goods_id != EXPECTED_GOODS_ID:
|
if link.goods_id != EXPECTED_GOODS_ID:
|
||||||
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
|
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
|
||||||
selection = resolve_task_selection(task_color, task_size)
|
selection = resolve_task_selection(task_color, task_size)
|
||||||
target = Path(output_directory)
|
target = Path(output_directory)
|
||||||
_validate_new_target(target)
|
_validate_new_target(target)
|
||||||
|
|
||||||
adapter: UiautomatorSkuPanelAdapter | None = None
|
|
||||||
flow: SkuSelectionFlow | None = None
|
|
||||||
staging = _prepare_staging(target)
|
staging = _prepare_staging(target)
|
||||||
deadline = self._monotonic_clock() + self._timeout_seconds
|
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||||
try:
|
|
||||||
|
stage = "device_inspection"
|
||||||
inspection = self._adb_client.inspect(serial)
|
inspection = self._adb_client.inspect(serial)
|
||||||
_require_expected_device(inspection)
|
_require_expected_device(inspection)
|
||||||
|
|
||||||
|
stage = "device_session"
|
||||||
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
||||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||||
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
|
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
|
||||||
pre_intent_hierarchy = adapter.dump_window_hierarchy()
|
pre_intent_hierarchy = adapter.dump_window_hierarchy()
|
||||||
|
|
||||||
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
|
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
|
||||||
|
stage = "product_open"
|
||||||
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||||
|
|
||||||
remaining = deadline - self._monotonic_clock()
|
remaining = deadline - self._monotonic_clock()
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
|
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
|
||||||
|
|
||||||
|
stage = "sku_entry"
|
||||||
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
|
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
|
||||||
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
|
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
|
||||||
|
|
||||||
|
stage = "sku_selection"
|
||||||
flow.select_sku_options(selection)
|
flow.select_sku_options(selection)
|
||||||
|
|
||||||
|
stage = "price_verification"
|
||||||
unit_price = flow.verify_target_selection_and_read_price(selection)
|
unit_price = flow.verify_target_selection_and_read_price(selection)
|
||||||
if unit_price != EXPECTED_UNIT_PRICE:
|
if unit_price != EXPECTED_UNIT_PRICE:
|
||||||
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
|
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
|
||||||
|
|
||||||
|
stage = "screenshot_capture"
|
||||||
screenshot_path = staging / "screenshot.png"
|
screenshot_path = staging / "screenshot.png"
|
||||||
try:
|
try:
|
||||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||||
@@ -226,11 +281,15 @@ class SkuSelectionRunner:
|
|||||||
|
|
||||||
manifest_path = staging / "manifest.json"
|
manifest_path = staging / "manifest.json"
|
||||||
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
|
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
|
||||||
|
stage = "screenshot_reverify"
|
||||||
final_price = flow.verify_target_selection_and_read_price(selection)
|
final_price = flow.verify_target_selection_and_read_price(selection)
|
||||||
if final_price != EXPECTED_UNIT_PRICE:
|
if final_price != EXPECTED_UNIT_PRICE:
|
||||||
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
|
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
|
||||||
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
|
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
|
||||||
|
stage = "safe_exit"
|
||||||
flow.exit_sku_panel_safely()
|
flow.exit_sku_panel_safely()
|
||||||
|
|
||||||
|
stage = "publish"
|
||||||
manifest_path.write_text(
|
manifest_path.write_text(
|
||||||
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
@@ -238,18 +297,25 @@ class SkuSelectionRunner:
|
|||||||
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
||||||
os.rename(staging, target)
|
os.rename(staging, target)
|
||||||
staging = None
|
staging = None
|
||||||
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError):
|
except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||||
_clean_staging(staging)
|
_clean_staging(staging)
|
||||||
|
_annotate_failure(error, stage)
|
||||||
raise
|
raise
|
||||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||||
_clean_staging(staging)
|
_clean_staging(staging)
|
||||||
raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error
|
mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
|
||||||
|
_annotate_failure(mapped, stage)
|
||||||
|
raise mapped from error
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
_clean_staging(staging)
|
_clean_staging(staging)
|
||||||
raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error
|
mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
|
||||||
|
_annotate_failure(mapped, stage)
|
||||||
|
raise mapped from error
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
_clean_staging(staging)
|
_clean_staging(staging)
|
||||||
raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error
|
mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
|
||||||
|
_annotate_failure(mapped, stage)
|
||||||
|
raise mapped from error
|
||||||
finally:
|
finally:
|
||||||
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
||||||
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
|
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
|
||||||
|
|||||||
@@ -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
|
from dataclasses import dataclass
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -17,21 +18,34 @@ class RuntimePaths:
|
|||||||
root: Path
|
root: Path
|
||||||
logs: Path
|
logs: Path
|
||||||
artifacts: Path
|
artifacts: Path
|
||||||
|
state: Path
|
||||||
|
database: Path
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_root(cls, root: Path) -> "RuntimePaths":
|
def from_root(cls, root: Path) -> "RuntimePaths":
|
||||||
resolved_root = root.expanduser()
|
# 路径在进程启动时一次性固化;之后 cwd 改变不能打开第二套数据库或绕过原 mutex。
|
||||||
|
resolved_root = root.expanduser().resolve(strict=False)
|
||||||
|
state = resolved_root / "state"
|
||||||
return cls(
|
return cls(
|
||||||
root=resolved_root,
|
root=resolved_root,
|
||||||
logs=resolved_root / "logs",
|
logs=resolved_root / "logs",
|
||||||
artifacts=resolved_root / "artifacts",
|
artifacts=resolved_root / "artifacts",
|
||||||
|
state=state,
|
||||||
|
database=state / "client-state.sqlite3",
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def default(cls) -> "RuntimePaths":
|
def default(cls) -> "RuntimePaths":
|
||||||
local_app_data = os.environ.get("LOCALAPPDATA")
|
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||||
if local_app_data:
|
if local_app_data:
|
||||||
return cls.from_root(Path(local_app_data) / "cmbuyer")
|
local_root = Path(local_app_data).expanduser()
|
||||||
|
if not local_root.is_absolute():
|
||||||
|
raise RuntimeError("local_app_data_must_be_absolute")
|
||||||
|
return cls.from_root(local_root / "cmbuyer")
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
# Windows 上回退到 home 会悄悄创建第二套状态库并绕开同一 mutex,必须失败闭合。
|
||||||
|
raise RuntimeError("local_app_data_required")
|
||||||
|
|
||||||
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
|
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
|
||||||
|
|
||||||
@@ -40,3 +54,49 @@ class RuntimePaths:
|
|||||||
|
|
||||||
self.logs.mkdir(parents=True, exist_ok=True)
|
self.logs.mkdir(parents=True, exist_ok=True)
|
||||||
self.artifacts.mkdir(parents=True, exist_ok=True)
|
self.artifacts.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.state.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LocalStateRuntime:
|
||||||
|
"""持有 named mutex 与本地状态库,保证 mutex 总是先取得。"""
|
||||||
|
|
||||||
|
paths: RuntimePaths
|
||||||
|
mutex: Any
|
||||||
|
store: Any
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def open(
|
||||||
|
cls,
|
||||||
|
paths: RuntimePaths | None = None,
|
||||||
|
*,
|
||||||
|
mutex_factory: Callable[[Path], Any] | None = None,
|
||||||
|
protector_factory: Callable[[], Any] | None = None,
|
||||||
|
store_factory: Callable[[Path, Any], Any] | None = None,
|
||||||
|
) -> "LocalStateRuntime":
|
||||||
|
from .localstate.protection import DpapiProtector
|
||||||
|
from .localstate.single_instance import NamedMutex
|
||||||
|
from .localstate.store import LocalStateStore
|
||||||
|
|
||||||
|
selected = paths or RuntimePaths.default()
|
||||||
|
selected.ensure_exists()
|
||||||
|
make_mutex = mutex_factory or NamedMutex
|
||||||
|
make_protector = protector_factory or DpapiProtector
|
||||||
|
make_store = store_factory or LocalStateStore
|
||||||
|
mutex = make_mutex(selected.database)
|
||||||
|
try:
|
||||||
|
protector = make_protector()
|
||||||
|
store = make_store(selected.database, protector)
|
||||||
|
except Exception:
|
||||||
|
mutex.close()
|
||||||
|
raise
|
||||||
|
return cls(selected, mutex, store)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.mutex.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> "LocalStateRuntime":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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()))
|
||||||
@@ -15,7 +15,7 @@ from PIL import Image
|
|||||||
|
|
||||||
import cmbuyer_client.pdd as pdd
|
import cmbuyer_client.pdd as pdd
|
||||||
import cmbuyer_client.pdd.sku_selection_runner as runner_module
|
import cmbuyer_client.pdd.sku_selection_runner as runner_module
|
||||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
from cmbuyer_client.device.adb import AdbDevice, DeviceConnectionError, DeviceInspection
|
||||||
from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
|
from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
|
||||||
from cmbuyer_client.pdd.sku_selection import SkuPanelDevice, _action_bounds, resolve_task_selection
|
from cmbuyer_client.pdd.sku_selection import SkuPanelDevice, _action_bounds, resolve_task_selection
|
||||||
from cmbuyer_client.pdd.sku_selection_runner import (
|
from cmbuyer_client.pdd.sku_selection_runner import (
|
||||||
@@ -23,6 +23,7 @@ from cmbuyer_client.pdd.sku_selection_runner import (
|
|||||||
SkuSelectionRunError,
|
SkuSelectionRunError,
|
||||||
SkuSelectionScreenshotError,
|
SkuSelectionScreenshotError,
|
||||||
UiautomatorSkuPanelAdapter,
|
UiautomatorSkuPanelAdapter,
|
||||||
|
safe_failure_stage,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,6 +143,86 @@ def _duplicate_entry() -> str:
|
|||||||
return ElementTree.tostring(root, encoding="unicode")
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _extra_entry_action_ancestor() -> str:
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||||
|
action = _entry_chain(root)[4]
|
||||||
|
root.append(ElementTree.Element("node", dict(action.attrib)))
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_with_panel_price_marker() -> str:
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||||
|
root.append(ElementTree.Element("node", {"bounds": "[396,498][895,570]"}))
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _dynamic_product_page(value: str, action_desc: str = "") -> str:
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||||
|
root.set("dynamic-page-value", value)
|
||||||
|
_entry_chain(root)[4].set("content-desc", action_desc)
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _with_overlapping_clickable(package: str, class_name: str, bounds: str) -> str:
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||||
|
root.append(
|
||||||
|
ElementTree.Element(
|
||||||
|
"node",
|
||||||
|
{
|
||||||
|
"package": package,
|
||||||
|
"class": class_name,
|
||||||
|
"bounds": bounds,
|
||||||
|
"clickable": "true",
|
||||||
|
"enabled": "true",
|
||||||
|
"visible-to-user": "true",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _with_action_subtree_child(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
bounds: str = "[10,1260][100,1300]",
|
||||||
|
) -> str:
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||||
|
action = _entry_chain(root)[4]
|
||||||
|
ElementTree.SubElement(
|
||||||
|
action,
|
||||||
|
"node",
|
||||||
|
{
|
||||||
|
"text": text,
|
||||||
|
"package": "com.xunmeng.pinduoduo",
|
||||||
|
"class": "android.widget.TextView",
|
||||||
|
"bounds": bounds,
|
||||||
|
"clickable": "false",
|
||||||
|
"enabled": "true",
|
||||||
|
"visible-to-user": "true",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _home_with_unverified_entry_labels() -> str:
|
||||||
|
root = ElementTree.Element("hierarchy")
|
||||||
|
for index in range(2):
|
||||||
|
ElementTree.SubElement(
|
||||||
|
root,
|
||||||
|
"node",
|
||||||
|
{
|
||||||
|
"text": "快要抢光",
|
||||||
|
"package": "com.xunmeng.pinduoduo",
|
||||||
|
"class": "android.widget.TextView",
|
||||||
|
"clickable": "false",
|
||||||
|
"enabled": "true",
|
||||||
|
"visible-to-user": "true",
|
||||||
|
"bounds": f"[{index},{index}][{index + 1},{index + 1}]",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]:
|
def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]:
|
||||||
return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method]
|
return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method]
|
||||||
|
|
||||||
@@ -201,6 +282,73 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||||
|
|
||||||
|
def test_entry_stability_uses_verified_chain_projection_not_whole_xml(self) -> None:
|
||||||
|
class DynamicProductDevice(_RawDevice):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.frame = 0
|
||||||
|
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "dumpWindowHierarchy" and "快要抢光" in self.hierarchy:
|
||||||
|
self.frame += 1
|
||||||
|
self.hierarchy = _dynamic_product_page(str(self.frame), "活动剩余快要抢光")
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
now = [0.0]
|
||||||
|
device = DynamicProductDevice()
|
||||||
|
flow = SkuSelectionFlow(
|
||||||
|
UiautomatorSkuPanelAdapter(device, 10),
|
||||||
|
0.03,
|
||||||
|
0.01,
|
||||||
|
lambda: now[0],
|
||||||
|
lambda seconds: now.__setitem__(0, now[0] + seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
|
||||||
|
|
||||||
|
self.assertEqual(device.frame, 2)
|
||||||
|
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||||
|
|
||||||
|
def test_entry_action_description_must_be_stable_across_frames(self) -> None:
|
||||||
|
class ChangingActionDescriptionDevice(_RawDevice):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.frame = 0
|
||||||
|
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "dumpWindowHierarchy":
|
||||||
|
self.frame += 1
|
||||||
|
self.hierarchy = _dynamic_product_page(
|
||||||
|
str(self.frame),
|
||||||
|
f"活动{self.frame}快要抢光",
|
||||||
|
)
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
now = [0.0]
|
||||||
|
device = ChangingActionDescriptionDevice()
|
||||||
|
flow = SkuSelectionFlow(
|
||||||
|
UiautomatorSkuPanelAdapter(device, 10),
|
||||||
|
0.02,
|
||||||
|
0.01,
|
||||||
|
lambda: now[0],
|
||||||
|
lambda seconds: now.__setitem__(0, now[0] + seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
|
||||||
|
|
||||||
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
|
def test_unverified_home_entry_labels_do_not_trigger_old_product_rejection(self) -> None:
|
||||||
|
device = _RawDevice()
|
||||||
|
|
||||||
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(
|
||||||
|
_TARGET_URL,
|
||||||
|
_home_with_unverified_entry_labels(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||||
|
|
||||||
def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None:
|
def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None:
|
||||||
expected_clickable = ("false", "false", "false", "false", "true")
|
expected_clickable = ("false", "false", "false", "false", "true")
|
||||||
for depth in range(5):
|
for depth in range(5):
|
||||||
@@ -216,8 +364,74 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
with self.subTest(depth=depth, attribute=attribute):
|
with self.subTest(depth=depth, attribute=attribute):
|
||||||
self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value))
|
self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value))
|
||||||
|
|
||||||
|
def test_entry_chain_text_and_description_drift_never_clicks(self) -> None:
|
||||||
|
cases = [
|
||||||
|
_mutate_entry(depth, "text", "祖先文字漂移") for depth in range(1, 5)
|
||||||
|
] + [
|
||||||
|
_mutate_entry(depth, "content-desc", "祖先描述漂移") for depth in range(0, 4)
|
||||||
|
] + [
|
||||||
|
_mutate_entry(4, "content-desc", "祖先描述漂移")
|
||||||
|
] + [
|
||||||
|
_mutate_entry(4, "content-desc", forbidden + "快要抢光")
|
||||||
|
for forbidden in (
|
||||||
|
"免拼购买", "单独购买", "直接拼成", "提交订单", "支付", "先用后付", "0元下单",
|
||||||
|
"立即购买", "确认下单", "立即付款", "订单详情",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for hierarchy in cases:
|
||||||
|
with self.subTest():
|
||||||
|
self._assert_entry_rejected_without_click(hierarchy)
|
||||||
|
|
||||||
|
def test_any_live_clickable_covering_entry_center_blocks_click(self) -> None:
|
||||||
|
cases = (
|
||||||
|
_with_overlapping_clickable(
|
||||||
|
"com.xunmeng.pinduoduo",
|
||||||
|
"android.widget.Button",
|
||||||
|
"[950,1300][1000,1340]",
|
||||||
|
),
|
||||||
|
_with_overlapping_clickable(
|
||||||
|
"com.android.systemui",
|
||||||
|
"android.view.ViewGroup",
|
||||||
|
"[936,1208][1080,1352]",
|
||||||
|
),
|
||||||
|
_with_overlapping_clickable(
|
||||||
|
"com.android.systemui",
|
||||||
|
"android.view.ViewGroup",
|
||||||
|
"not-a-bound",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for hierarchy in cases:
|
||||||
|
with self.subTest():
|
||||||
|
self._assert_entry_rejected_without_click(hierarchy)
|
||||||
|
|
||||||
|
def test_pre_intent_old_entry_is_rejected_even_with_extra_action_node(self) -> None:
|
||||||
|
for old_page in (
|
||||||
|
_extra_entry_action_ancestor(),
|
||||||
|
_entry_with_panel_price_marker(),
|
||||||
|
_mutate_entry(4, "content-desc", "立即购买快要抢光"),
|
||||||
|
):
|
||||||
|
with self.subTest():
|
||||||
|
device = _RawDevice()
|
||||||
|
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
|
||||||
|
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
flow.open_sku_panel(_TARGET_URL, old_page)
|
||||||
|
|
||||||
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
|
def test_action_subtree_dangerous_or_ambiguous_children_never_click(self) -> None:
|
||||||
|
cases = (
|
||||||
|
_with_action_subtree_child("提交订单"),
|
||||||
|
_with_action_subtree_child("免拼购买"),
|
||||||
|
_with_action_subtree_child("快要抢光"),
|
||||||
|
)
|
||||||
|
for hierarchy in cases:
|
||||||
|
with self.subTest():
|
||||||
|
self._assert_entry_rejected_without_click(hierarchy)
|
||||||
|
|
||||||
def test_duplicate_entry_and_forbidden_sibling_entry_never_click(self) -> None:
|
def test_duplicate_entry_and_forbidden_sibling_entry_never_click(self) -> None:
|
||||||
self._assert_entry_rejected_without_click(_duplicate_entry())
|
self._assert_entry_rejected_without_click(_duplicate_entry())
|
||||||
|
self._assert_entry_rejected_without_click(_extra_entry_action_ancestor())
|
||||||
self._assert_entry_rejected_without_click(_without_entry())
|
self._assert_entry_rejected_without_click(_without_entry())
|
||||||
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true"))
|
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true"))
|
||||||
|
|
||||||
@@ -383,6 +597,38 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
self.assertFalse((target / "hierarchy.xml").exists())
|
self.assertFalse((target / "hierarchy.xml").exists())
|
||||||
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||||
|
|
||||||
|
def test_failure_stage_is_fixed_control_flow_metadata_without_error_text(self) -> None:
|
||||||
|
class NoPanelAfterEntry(_RawDevice):
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "click":
|
||||||
|
self.calls.append(("jsonrpc", method, params, timeout))
|
||||||
|
return ""
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
|
||||||
|
self._runner(_FakeAdb(), NoPanelAfterEntry()).run(
|
||||||
|
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
|
||||||
|
)
|
||||||
|
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry")
|
||||||
|
|
||||||
|
forged = SkuSelectionError("<hierarchy>private</hierarchy>")
|
||||||
|
setattr(forged, "_cmbuyer_failure_stage", "<private-stage>")
|
||||||
|
self.assertEqual(safe_failure_stage(forged), "unknown")
|
||||||
|
|
||||||
|
class HostileSetterError(DeviceConnectionError):
|
||||||
|
def __setattr__(self, name: str, value: object) -> None:
|
||||||
|
raise KeyboardInterrupt("SERIAL=private <hierarchy>secret</hierarchy>")
|
||||||
|
|
||||||
|
class FailingAdb(_FakeAdb):
|
||||||
|
def inspect(self, serial: str) -> DeviceInspection:
|
||||||
|
raise HostileSetterError("private")
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temporary, self.assertRaises(HostileSetterError) as hostile:
|
||||||
|
SkuSelectionRunner(FailingAdb(), lambda serial: _RawDevice(), 10).run(
|
||||||
|
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
|
||||||
|
)
|
||||||
|
self.assertEqual(safe_failure_stage(hostile.exception), "unknown")
|
||||||
|
|
||||||
def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None:
|
def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None:
|
||||||
with TemporaryDirectory() as temporary:
|
with TemporaryDirectory() as temporary:
|
||||||
target = Path(temporary) / "result"
|
target = Path(temporary) / "result"
|
||||||
@@ -401,8 +647,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
|
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
|
||||||
with TemporaryDirectory() as temporary:
|
with TemporaryDirectory() as temporary:
|
||||||
target = Path(temporary) / "result"
|
target = Path(temporary) / "result"
|
||||||
with self.assertRaises(SkuSelectionScreenshotError):
|
with self.assertRaises(SkuSelectionScreenshotError) as screenshot_failure:
|
||||||
self._runner(_FakeAdb(), _RawDevice(screenshot="not-image")).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
self._runner(_FakeAdb(), _RawDevice(screenshot="not-image")).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||||
|
self.assertEqual(safe_failure_stage(screenshot_failure.exception), "screenshot_capture")
|
||||||
self.assertFalse(target.exists())
|
self.assertFalse(target.exists())
|
||||||
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
|
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
|
||||||
|
|
||||||
@@ -418,8 +665,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
target.mkdir()
|
target.mkdir()
|
||||||
sentinel = target / "keep"
|
sentinel = target / "keep"
|
||||||
sentinel.write_text("keep", encoding="utf-8")
|
sentinel.write_text("keep", encoding="utf-8")
|
||||||
with self.assertRaises(SkuSelectionRunError):
|
with self.assertRaises(SkuSelectionRunError) as existing_target_failure:
|
||||||
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||||
|
self.assertEqual(safe_failure_stage(existing_target_failure.exception), "precheck")
|
||||||
self.assertEqual(adb.calls, [])
|
self.assertEqual(adb.calls, [])
|
||||||
self.assertEqual(device.calls, [])
|
self.assertEqual(device.calls, [])
|
||||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||||
@@ -520,7 +768,11 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
now = [0.0]
|
now = [0.0]
|
||||||
class SequenceDevice(_RawDevice):
|
class SequenceDevice(_RawDevice):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__(); self.frames = [_PRODUCT_PAGE, "<hierarchy />", _PRODUCT_PAGE]
|
super().__init__(); self.frames = [
|
||||||
|
_dynamic_product_page("first"),
|
||||||
|
"<hierarchy />",
|
||||||
|
_dynamic_product_page("second"),
|
||||||
|
]
|
||||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
if method == "dumpWindowHierarchy" and self.frames:
|
if method == "dumpWindowHierarchy" and self.frames:
|
||||||
self.hierarchy = self.frames.pop(0)
|
self.hierarchy = self.frames.pop(0)
|
||||||
@@ -654,10 +906,26 @@ class SkuSelectionCliTests(unittest.TestCase):
|
|||||||
def test_cli_main_catches_flow_error_without_traceback_or_page_body(self) -> None:
|
def test_cli_main_catches_flow_error_without_traceback_or_page_body(self) -> None:
|
||||||
script = _load_runner_script()
|
script = _load_runner_script()
|
||||||
|
|
||||||
|
secret = "SERIAL=192.168.0.173:5555 PATH=C:/private <hierarchy>page-body</hierarchy>"
|
||||||
|
|
||||||
|
class HostileGetterError(SkuSelectionError):
|
||||||
|
def __getattribute__(self, name: str) -> object:
|
||||||
|
if name == "_cmbuyer_failure_stage":
|
||||||
|
raise RuntimeError(secret)
|
||||||
|
return super().__getattribute__(name)
|
||||||
|
|
||||||
|
class HostileStage(str):
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
raise RuntimeError(secret)
|
||||||
|
|
||||||
|
hostile_string = SkuSelectionError(secret)
|
||||||
|
setattr(hostile_string, "_cmbuyer_failure_stage", HostileStage("sku_entry"))
|
||||||
|
|
||||||
|
for failure in (SkuSelectionError(secret), HostileGetterError(secret), hostile_string):
|
||||||
class FlowFailingRunner:
|
class FlowFailingRunner:
|
||||||
def __init__(self, *args: object, **kwargs: object) -> None: pass
|
def __init__(self, *args: object, **kwargs: object) -> None: pass
|
||||||
def run(self, *args: object, **kwargs: object) -> object:
|
def run(self, *args: object, **kwargs: object) -> object:
|
||||||
raise SkuSelectionError("<hierarchy>page-body</hierarchy>")
|
raise failure
|
||||||
|
|
||||||
stderr = BytesIO()
|
stderr = BytesIO()
|
||||||
# TextIOWrapper keeps the assertion independent from host console encoding.
|
# TextIOWrapper keeps the assertion independent from host console encoding.
|
||||||
@@ -671,7 +939,9 @@ class SkuSelectionCliTests(unittest.TestCase):
|
|||||||
text_stderr.flush()
|
text_stderr.flush()
|
||||||
output = stderr.getvalue().decode("utf-8")
|
output = stderr.getvalue().decode("utf-8")
|
||||||
self.assertEqual(status, 1)
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("stage=unknown", output)
|
||||||
self.assertNotIn("Traceback", output)
|
self.assertNotIn("Traceback", output)
|
||||||
|
self.assertNotIn(secret, output)
|
||||||
self.assertNotIn("page-body", output)
|
self.assertNotIn("page-body", output)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""轮询协调器测试。"""
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
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:
|
||||||
|
QTest.qWait(10)
|
||||||
|
elapsed += 10
|
||||||
|
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()
|
||||||
|
QTest.qWait(20)
|
||||||
|
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)
|
||||||
|
QTest.qWait(30)
|
||||||
|
self.assertEqual(gateway.calls, 1)
|
||||||
|
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:
|
def test_none_uses_process_arguments(self) -> None:
|
||||||
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
|
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
|
||||||
self.assertEqual(["process-name", "--process-option"], select_application_argv(None))
|
self.assertEqual(["process-name", "--process-option"], select_application_argv(None))
|
||||||
|
|
||||||
|
def test_standalone_entry_does_not_construct_http_or_device_capabilities(self) -> None:
|
||||||
|
source = (CLIENT_ROOT / "src" / "cmbuyer_client" / "app.py").read_text(encoding="utf-8")
|
||||||
|
for forbidden in (
|
||||||
|
"HttpTaskSource",
|
||||||
|
"HttpTransport",
|
||||||
|
"DurableClientGateway",
|
||||||
|
"import uiautomator2",
|
||||||
|
".device",
|
||||||
|
".pdd",
|
||||||
|
):
|
||||||
|
with self.subTest(forbidden=forbidden):
|
||||||
|
self.assertNotIn(forbidden, source)
|
||||||
|
self.assertIn("gateway_factory=None", source)
|
||||||
|
self.assertIn("consumer=None", source)
|
||||||
|
|
||||||
|
def test_t304_ui_has_no_hidden_connection_probe_or_irreversible_capability_import(self) -> None:
|
||||||
|
paths = [
|
||||||
|
*(CLIENT_ROOT / "src" / "cmbuyer_client" / "ui").glob("*.py"),
|
||||||
|
*(CLIENT_ROOT / "src" / "cmbuyer_client" / "polling").glob("*.py"),
|
||||||
|
]
|
||||||
|
source = "\n".join(path.read_text(encoding="utf-8") for path in paths)
|
||||||
|
for forbidden in (
|
||||||
|
"uiautomator2",
|
||||||
|
"cmbuyer_client.pdd",
|
||||||
|
"cmbuyer_client.device",
|
||||||
|
"submit_order",
|
||||||
|
"payment",
|
||||||
|
"http.client",
|
||||||
|
"subprocess",
|
||||||
|
):
|
||||||
|
with self.subTest(forbidden=forbidden):
|
||||||
|
self.assertNotIn(forbidden, source)
|
||||||
|
|||||||
@@ -47,3 +47,24 @@ class LoggingPolicyTests(unittest.TestCase):
|
|||||||
self.assertNotIn("not-for-log", content)
|
self.assertNotIn("not-for-log", content)
|
||||||
self.assertNotIn("13900139000", content)
|
self.assertNotIn("13900139000", content)
|
||||||
self.assertIn("[已隐藏]", content)
|
self.assertIn("[已隐藏]", content)
|
||||||
|
|
||||||
|
def test_bearer_bare_token_and_traceback_are_redacted(self) -> None:
|
||||||
|
token = "a" * 64
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
paths = RuntimePaths.from_root(Path(directory))
|
||||||
|
logger = configure_application_logger(paths)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
raise RuntimeError("credential=" + token)
|
||||||
|
except RuntimeError:
|
||||||
|
logger.exception("Authorization: Bearer %s bare=%s", token, token)
|
||||||
|
for handler in logger.handlers:
|
||||||
|
handler.flush()
|
||||||
|
content = (paths.logs / "client.log").read_text(encoding="utf-8")
|
||||||
|
finally:
|
||||||
|
for handler in tuple(logger.handlers):
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
|
||||||
|
self.assertNotIn(token, content)
|
||||||
|
self.assertIn("[已隐藏]", content)
|
||||||
|
|||||||
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||||
|
|
||||||
from cmbuyer_client.runtime import RuntimePaths
|
from cmbuyer_client.runtime import LocalStateRuntime, RuntimePaths
|
||||||
|
|
||||||
|
|
||||||
class RuntimePathsTests(unittest.TestCase):
|
class RuntimePathsTests(unittest.TestCase):
|
||||||
@@ -23,3 +25,58 @@ class RuntimePathsTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertTrue(paths.logs.is_dir())
|
self.assertTrue(paths.logs.is_dir())
|
||||||
self.assertTrue(paths.artifacts.is_dir())
|
self.assertTrue(paths.artifacts.is_dir())
|
||||||
|
self.assertTrue(paths.state.is_dir())
|
||||||
|
self.assertEqual(paths.database, paths.state / "client-state.sqlite3")
|
||||||
|
|
||||||
|
def test_localstate_runtime_acquires_mutex_before_protector_and_store(self) -> None:
|
||||||
|
events: list[str] = []
|
||||||
|
|
||||||
|
class Mutex:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
events.append("mutex")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
events.append("close")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
runtime = LocalStateRuntime.open(
|
||||||
|
RuntimePaths.from_root(Path(directory)),
|
||||||
|
mutex_factory=Mutex,
|
||||||
|
protector_factory=lambda: events.append("protector") or object(),
|
||||||
|
store_factory=lambda path, protector: events.append("store") or object(),
|
||||||
|
)
|
||||||
|
runtime.close()
|
||||||
|
self.assertEqual(events, ["mutex", "protector", "store", "close"])
|
||||||
|
|
||||||
|
def test_localstate_runtime_releases_mutex_if_open_fails(self) -> None:
|
||||||
|
events: list[str] = []
|
||||||
|
|
||||||
|
class Mutex:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
events.append("mutex")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
events.append("close")
|
||||||
|
|
||||||
|
def fail() -> object:
|
||||||
|
raise RuntimeError("failed")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory, self.assertRaises(RuntimeError):
|
||||||
|
LocalStateRuntime.open(
|
||||||
|
RuntimePaths.from_root(Path(directory)),
|
||||||
|
mutex_factory=Mutex,
|
||||||
|
protector_factory=fail,
|
||||||
|
)
|
||||||
|
self.assertEqual(events, ["mutex", "close"])
|
||||||
|
|
||||||
|
def test_windows_without_localappdata_fails_instead_of_creating_second_database(self) -> None:
|
||||||
|
with mock.patch("cmbuyer_client.runtime.os.name", "nt"), mock.patch.dict(os.environ, {}, clear=True):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "local_app_data_required"):
|
||||||
|
RuntimePaths.default()
|
||||||
|
|
||||||
|
def test_runtime_root_is_frozen_absolute_and_relative_localappdata_is_rejected(self) -> None:
|
||||||
|
paths = RuntimePaths.from_root(Path("relative-runtime"))
|
||||||
|
self.assertTrue(paths.root.is_absolute())
|
||||||
|
with mock.patch.dict(os.environ, {"LOCALAPPDATA": "relative-local-app-data"}, clear=True):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "local_app_data_must_be_absolute"):
|
||||||
|
RuntimePaths.default()
|
||||||
|
|||||||
@@ -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 整段串行等待**。当前优先路径:
|
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
||||||
|
|
||||||
1. T-111 先冻结“开始采购授权”的单趟契约;同时主审并合入只创建 `DRAFT` 的 T-202。
|
1. 完成 **T-103 人工真机验收**,再按依赖推进 T-104 → T-107 的分段安全判据。
|
||||||
2. T-111 完成后恢复 **T-103 真机取证**;按依赖推进 T-104 → T-107 的分段安全判据。
|
2. admin agent 在 T-107 后推进 T-210 → T-205 → T-206 → T-207 → T-208,冻结三闸门证据、
|
||||||
3. admin agent 先做 T-209 把旧两趟 schema / 状态机迁到单趟,再推进 T-203 服务端批量开始采购 /
|
attempt 事件、围栏、一次性结果与人工调和。
|
||||||
一次性授权;两者不依赖页面选择器。T-205 起的 attempt 与证据字段等待 T-103。
|
3. client agent 先做当前可领取的 T-303,再推进 T-304 / T-306 / T-307 / T-308;与 admin 写路径
|
||||||
4. Phase 3:双端打通与单趟围栏前 dry-run。
|
不重叠的任务继续并行。
|
||||||
5. Phase 4:单趟真实提交、待付款、调和与收尾。
|
4. T-305 完成围栏前真机 dry-run;T-400 只用 fixture/fake port 建立离线一次性提交安全闭包。
|
||||||
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
|
5. T-403 → T-402 先准备围栏后调和与人工付款事实记录;T-401 才在新真实单趟中首次点击一次,
|
||||||
|
点击后固定报 `UNKNOWN` 并由人调和,绝不付款。
|
||||||
|
6. T-405 生成可核验 Windows 包,T-404 对同一候选 commit 与产物做最终只读验收;V2 能力继续后置。
|
||||||
|
|
||||||
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
|
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
|
||||||
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
|
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
|
||||||
|
|||||||
@@ -31,7 +31,10 @@
|
|||||||
| 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 |
|
| 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 |
|
||||||
| 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 |
|
| 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 |
|
||||||
| 截图处理 | `Pillow` | 已定 | 判断页面是否渲染完成,避免保存白屏壳层 |
|
| 截图处理 | `Pillow` | 已定 | 判断页面是否渲染完成,避免保存白屏壳层 |
|
||||||
| HTTP 客户端 | 标准库 `urllib` 或 `httpx` | **待定** | 先用标准库;确有重试/连接池需求再评估 |
|
| HTTP 客户端 | 标准库 `http.client` | 已定 | 只直连 `127.0.0.1:8080`;不读代理、不跟随重定向、不做隐藏重试或连接池 |
|
||||||
|
| 可恢复状态 | 标准库 `sqlite3`(WAL / FULL) | 已定 | request/slot 先落库再 HTTP;每个操作独立连接,恢复读取使用单一事务快照 |
|
||||||
|
| 秘密保护 | Windows Current User DPAPI | 已定 | token 原始 32 字节只以绑定 profile+device/attempt context 的密文 BLOB 入库;非 Windows 不降级 |
|
||||||
|
| 单实例 | Windows `Global\` named mutex | 已定 | 以规范数据库路径 hash 命名,先于 DPAPI/SQLite 取得,覆盖同用户跨 session |
|
||||||
| Excel | 不引入 | 已定 | Excel 解析移到采购服务;采购工具不再直接读表 |
|
| Excel | 不引入 | 已定 | Excel 解析移到采购服务;采购工具不再直接读表 |
|
||||||
| 测试 | `unittest`(标准库) | 已定 | 前序项目 171 项测试均用标准库,无需 pytest |
|
| 测试 | `unittest`(标准库) | 已定 | 前序项目 171 项测试均用标准库,无需 pytest |
|
||||||
| 打包 | `pyinstaller` | 已定 | 交付给运营电脑;开发期依赖 |
|
| 打包 | `pyinstaller` | 已定 | 交付给运营电脑;开发期依赖 |
|
||||||
@@ -55,6 +58,10 @@
|
|||||||
Go 侧重写表头校验和行级报错,不能直接复用前序项目的 Python 实现。
|
Go 侧重写表头校验和行级报错,不能直接复用前序项目的 Python 实现。
|
||||||
- **采购工具不持有业务权威。** 金额上限、授权有效性、任务状态流转的判定权在采购服务;
|
- **采购工具不持有业务权威。** 金额上限、授权有效性、任务状态流转的判定权在采购服务;
|
||||||
采购工具本地校验只作为第二道防线,两边不一致时一律转人工。
|
采购工具本地校验只作为第二道防线,两边不一致时一律转人工。
|
||||||
|
- **HTTP 不做自动重试。** claim/renew/evidence 的重放权属于持有 durable 幂等槽的恢复门面;底层每次
|
||||||
|
方法最多一个请求。401 修复 Bearer 后、结果不明或重启恢复都必须复用原 key 与原 body/file。
|
||||||
|
- **客户端 SQLite 只保存恢复事实。** append-only session/claim/renew/evidence history 不是服务端任务
|
||||||
|
权威;它的作用是阻止崩溃、并发或状态损坏导致第二次领取、换图或换 key。
|
||||||
- **不引入 pytest / 不引入 ORM。** 同一职责不并存两套方案。
|
- **不引入 pytest / 不引入 ORM。** 同一职责不并存两套方案。
|
||||||
|
|
||||||
## 五、构建与运行命令
|
## 五、构建与运行命令
|
||||||
|
|||||||
+71
-2
@@ -229,9 +229,42 @@ CREATE TABLE purchase_attempts (
|
|||||||
failure_code TEXT,
|
failure_code TEXT,
|
||||||
started_at TEXT NOT NULL,
|
started_at TEXT NOT NULL,
|
||||||
finished_at TEXT,
|
finished_at TEXT,
|
||||||
UNIQUE (task_id, claim_generation)
|
UNIQUE (task_id, claim_generation),
|
||||||
|
UNIQUE (task_id, authorization_id, id, claim_generation)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- attempt 的设备/session 所有权与可恢复租约;token 明文永不入库
|
||||||
|
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 REFERENCES device_credentials(device_id),
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
claim_generation INTEGER NOT NULL,
|
||||||
|
task_version INTEGER NOT NULL,
|
||||||
|
task_title TEXT NOT NULL,
|
||||||
|
authorization_task_version INTEGER NOT NULL,
|
||||||
|
goods_id TEXT NOT NULL,
|
||||||
|
sku_color TEXT NOT NULL,
|
||||||
|
sku_size TEXT NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL,
|
||||||
|
total_price_cap TEXT NOT NULL,
|
||||||
|
authorization_expires_at TEXT NOT NULL,
|
||||||
|
claim_nonce BLOB NOT NULL, -- 32 字节随机 nonce
|
||||||
|
claim_token_sha256 BLOB NOT NULL, -- 32 字节 hash,不是 token 明文
|
||||||
|
lease_expires_at TEXT NOT NULL,
|
||||||
|
claimed_at TEXT NOT NULL,
|
||||||
|
closed_at TEXT,
|
||||||
|
FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation)
|
||||||
|
REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx
|
||||||
|
ON purchase_attempt_claims (claimed_by_device_id) WHERE closed_at IS NULL;
|
||||||
|
|
||||||
|
-- claim-next 的 CLAIMED / EMPTY / BLOCKED 与 renew CAS 都持久化,保证跨重启幂等。
|
||||||
|
-- 复合外键同时绑定 attempt、设备、session、generation 与 token hash,应用 bug 不能跨归属写事实。
|
||||||
|
|
||||||
-- 真机真实点击前建立;一份授权最多一条
|
-- 真机真实点击前建立;一份授权最多一条
|
||||||
CREATE TABLE order_submissions (
|
CREATE TABLE order_submissions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -327,6 +360,20 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
|||||||
|
|
||||||
### 5.3 授权、租约与恢复
|
### 5.3 授权、租约与恢复
|
||||||
|
|
||||||
|
- claim/renew 使用独立 32 字节 HMAC secret;配置为 64 位小写十六进制,不得等于 session secret 或
|
||||||
|
任一设备 token。每条 claim 以版本域分隔 HMAC 绑定 device/task/authorization/attempt/generation/
|
||||||
|
32 字节随机 nonce,SQLite 只保存 nonce 和 token SHA-256。启动会重建并恒定时核对所有 open/closed
|
||||||
|
claim;secret 错误时失败闭合,不轮换 token。
|
||||||
|
- claim 与 renew 的 SQLite 事务必须先通过条件 no-op UPDATE 取得写入线性化位置并复核设备 ACTIVE,
|
||||||
|
才能读取或重放 request。撤销先提交则请求失败;claim/renew 先取得写位置则该事务可完成,随后撤销
|
||||||
|
不会自动释放已建立的 claim。
|
||||||
|
- 同一授权最多一个 attempt,同一设备最多一个未关闭 claim。EMPTY、人工恢复冲突和续租响应都持久化;
|
||||||
|
同幂等键只能重放原结果,不能因候选变化、续租或服务重启递增 generation、轮换 token 或延长第二次。
|
||||||
|
- claim 行同时冻结成功响应所需的 task 标题/版本以及 authorization 版本、规格、数量、总价上限和到期
|
||||||
|
时间。旧 request 跨重启只从该快照重建;源 task/authorization 后续漂移不能改变历史响应,并会让新
|
||||||
|
恢复或续租失败闭合。进入 `ORDERING` 的同 attempt 仅接受 task version 恰好比 claim 快照加一。
|
||||||
|
- 租约 TTL 显式配置为正且严格短于授权 TTL;新到期时间不得超过授权到期。租约与授权边界相等即过期,
|
||||||
|
没有宽限;过期、撤销、停止轮询和进程退出均不关闭 claim、不释放授权、不允许另一设备接管。
|
||||||
- 授权带 `expires_at`,只有围栏前可转 `EXPIRED` / `ABANDONED`;任务回到 `DRAFT`,必须重新点击
|
- 授权带 `expires_at`,只有围栏前可转 `EXPIRED` / `ABANDONED`;任务回到 `DRAFT`,必须重新点击
|
||||||
开始采购。旧授权永不复活。
|
开始采购。旧授权永不复活。
|
||||||
- 设备租约丢失不等于授权可安全重用。只有服务端确认该 attempt 未建立围栏,才能关闭 attempt 并
|
- 设备租约丢失不等于授权可安全重用。只有服务端确认该 attempt 未建立围栏,才能关闭 attempt 并
|
||||||
@@ -334,6 +381,24 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
|||||||
- 围栏建立后即使租约过期也只恢复同一 `order_submission` 的调和,不能回到可领取队列。
|
- 围栏建立后即使租约过期也只恢复同一 `order_submission` 的调和,不能回到可领取队列。
|
||||||
- 每种非终态都必须给出安全下一步,不能出现隐藏表单导致任务永久锁死。
|
- 每种非终态都必须给出安全下一步,不能出现隐藏表单导致任务永久锁死。
|
||||||
|
|
||||||
|
#### 采购工具本地恢复状态
|
||||||
|
|
||||||
|
- 生产入口先取得基于规范数据库路径的 Windows `Global\` named mutex,再构造 DPAPI 和 SQLite;不能
|
||||||
|
以服务端“单设备最多一个 claim”替代本机单实例。
|
||||||
|
- profile、polling session、claim/renew request、open/historical claim、evidence marker/slot 使用 WAL、
|
||||||
|
`synchronous=FULL` 与 append-only/单调关闭约束。当前 session/claim 由 `closed_at IS NULL` partial
|
||||||
|
unique 保证唯一,历史关闭后不阻塞下一条,但不得删除或复活。
|
||||||
|
- `DurableClientGateway` 是轮询和截图接入的唯一顺序入口:先 durable prepare,再一次 HTTP,最后原子
|
||||||
|
commit。停止只把当前 session 的 `accept_new` 设为 false;飞行中响应仍提交,pending/open 不清除。
|
||||||
|
- 每次发送前校验 profile/session/request/active immutable business snapshot、renew history、evidence marker/slot/
|
||||||
|
receipt 的完整状态图;snapshot、renew response 与 evidence receipt 还保存不可变摘要。任一冗余事实不一致、
|
||||||
|
DPAPI context 不匹配、数据库缺行或文件 identity 变化时零 HTTP 停止,不能自行“修复”。
|
||||||
|
- 设备 Bearer 的 401 发生在服务端读取 body/写幂等事实前,因此槽保持 `PENDING`;只允许同 device id
|
||||||
|
更新 Bearer,并在用户再次开始后用原 key/body/file 尝试。claim token 和其他 frozen 配置不变。
|
||||||
|
- 原始 token 以 DPAPI current-user context 密文保存:device token 绑定 profile+device,claim/renew token
|
||||||
|
绑定 profile+attempt,密文不能跨行复用;日志 formatter 对 Bearer、裸 64 位 token 和
|
||||||
|
traceback 做最终脱敏。异常对象也只保留固定 reason,不挂接含响应 body/partial/path 的异常上下文。
|
||||||
|
|
||||||
### 5.4 证据分层
|
### 5.4 证据分层
|
||||||
|
|
||||||
| 数据 | 位置 | 边界 |
|
| 数据 | 位置 | 边界 |
|
||||||
@@ -353,6 +418,9 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
|||||||
PNG 魔数、完整解码、字节数、尺寸和调用方声明的 SHA-256。
|
PNG 魔数、完整解码、字节数、尺寸和调用方声明的 SHA-256。
|
||||||
- 上传 handler 必须先通过逐请求 SQLite 设备认证并再次校验规范 principal,再解析 Content-Type 或
|
- 上传 handler 必须先通过逐请求 SQLite 设备认证并再次校验规范 principal,再解析 Content-Type 或
|
||||||
读取 body。空库、无效或已撤销凭据拒绝,认证存储故障返回 503;不把管理员 session 当设备身份。
|
读取 body。空库、无效或已撤销凭据拒绝,认证存储故障返回 503;不把管理员 session 当设备身份。
|
||||||
|
- 首次 evidence INSERT 必须由 store 校验和 SQLite trigger 双重证明 `(task, attempt, authenticated device)`
|
||||||
|
对应未关闭 claim。历史同设备/upload key 重放先于该检查,因此人工关闭 claim 不会破坏已提交资产的
|
||||||
|
幂等读取;关闭后禁止新 upload key,且不为此增加 token/session 字段或放宽截图 kind。
|
||||||
- 文件写入显式配置的私有证据根目录:同目录随机临时文件 → 流式 hash → 校验 → `fsync` → 原子
|
- 文件写入显式配置的私有证据根目录:同目录随机临时文件 → 流式 hash → 校验 → `fsync` → 原子
|
||||||
rename 到 SHA-256 内容地址 → 最后事务写数据库。数据库永远不指向半文件或缺失文件。
|
rename 到 SHA-256 内容地址 → 最后事务写数据库。数据库永远不指向半文件或缺失文件。
|
||||||
- SQLite 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除
|
- SQLite 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除
|
||||||
@@ -408,7 +476,8 @@ cmbuyer/
|
|||||||
└── scripts/
|
└── scripts/
|
||||||
```
|
```
|
||||||
|
|
||||||
执行器依赖 `TaskSource` / `ResultSink`,不直接读取 Excel 或拼接 HTTP。来源变化不得改变安全执行器。
|
执行器依赖核心端口,不直接读取 Excel 或拼接 HTTP。T-303 只提供 `TaskSource` / `EvidenceSink`;完整
|
||||||
|
`ResultSink` 在服务端 events/fail/fence/result 契约落地后分阶段组合,来源变化不得改变安全执行器。
|
||||||
|
|
||||||
## 九、架构纪律
|
## 九、架构纪律
|
||||||
|
|
||||||
|
|||||||
+25
-20
@@ -19,16 +19,17 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 1 | T-001 → T-004 → T-201 → T-202 | T-002 → T-101 → T-102 | T-003、原型与契约 |
|
| 1 | T-001 → T-004 → T-201 → T-202 | T-002 → T-101 → T-102 | T-003、原型与契约 |
|
||||||
| 2 | T-209 → T-203 → T-204 | T-103 → T-104 → T-105 → T-107 | T-111 单趟重构;逐任务主审 |
|
| 2 | T-209 → T-203 → T-204 | T-103 → T-104 → T-105 → T-107 | T-111 单趟重构;逐任务主审 |
|
||||||
| 3 | T-205 → T-207 → T-208 | T-301 → T-304 → T-306 | 双端契约完整门禁 |
|
| 3 | T-210 → T-205 → T-206 → T-207 → T-208 | T-301 → T-302 → T-303,再并行 T-304 / T-306 / T-307 | 双端契约完整门禁 |
|
||||||
| 4 | - | T-305 | 围栏前端到端 dry-run 主审 |
|
| 4 | T-403 → T-402 | T-305;T-208 / T-303 / T-307 后做 T-308;再汇合到 T-400 | 围栏前 dry-run、客户端围栏恢复与离线单次提交主审 |
|
||||||
| 5 | T-402 / T-403 | T-401 | 真机人工授权、提交与调和验收 |
|
| 5 | - | T-401 | 新真实单趟、首次一次性提交与人工调和验收 |
|
||||||
| 6 | - | T-405 | T-404 完整验收 |
|
| 6 | - | T-405 | T-404 完整只读验收 |
|
||||||
|
|
||||||
并行门禁:
|
并行门禁:
|
||||||
|
|
||||||
- T-202 只创建/展示 `DRAFT`,可与 T-103 并行;T-203 只实现管理员授权与任务状态,不依赖页面
|
- T-202 只创建/展示 `DRAFT`,可与 T-103 并行;T-203 只实现管理员授权与任务状态,不依赖页面
|
||||||
选择器,也可在 T-103 完成前推进。
|
选择器,也可在 T-103 完成前推进。
|
||||||
- T-205 起涉及实际 attempt/证据字段,必须等待 T-103 确认规格和价格最小事实。
|
- T-210 必须等待 T-107 真机确认三闸门证据类型;T-205 再基于三类真实 asset 冻结事件与摘要,
|
||||||
|
不允许用 mock 或缺证据事件绕过生产 schema。
|
||||||
- T-103、T-105、T-106、T-107 的 capability 相互隔离;未经取证的后续动作不可提前暴露。
|
- T-103、T-105、T-106、T-107 的 capability 相互隔离;未经取证的后续动作不可提前暴露。
|
||||||
- T-401 前任何任务都不得真实点击“提交订单”;支付能力永远不实现。
|
- T-401 前任何任务都不得真实点击“提交订单”;支付能力永远不实现。
|
||||||
- 主 agent 独立审阅每个子 agent 的 diff 并重跑验证,不以其自报完成作为证据。
|
- 主 agent 独立审阅每个子 agent 的 diff 并重跑验证,不以其自报完成作为证据。
|
||||||
@@ -59,9 +60,9 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
|
|||||||
| T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 |
|
| T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 |
|
||||||
| T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 |
|
| T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 |
|
||||||
| T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不进入确认页、不提交 |
|
| T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不进入确认页、不提交 |
|
||||||
| T-105 | 验证数量设置、读回与闸门二 | T-103 | 设置数量并精确读回;再次读规格/单价等于闸门一;不一致即停 |
|
| T-105 | 验证数量设置、读回与闸门二 | T-104 | 先只读取证并由人确认,再设置数量、精确读回并复核规格/单价;不一致即停 |
|
||||||
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 进入确认页读规格、数量、应付总额并验证提交控件唯一;绝不点击 |
|
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 只读采集导航前、确认页、最终控件及一次人工 Back 状态;agent 不执行页面动作 |
|
||||||
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只使用本项目证据;dry-run 静态不可达提交;版本失配 fail closed |
|
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只点已取证导航控件一次;最终控件只读观察;一次 Back;静态不可达提交 |
|
||||||
| T-110 | 批准证据绑定的受控规格入口 | T-103 no-go 决策 | 仅批准拼多多 8.17.0 已取证的精确唯一“快要抢光”,不泛化 |
|
| T-110 | 批准证据绑定的受控规格入口 | T-103 no-go 决策 | 仅批准拼多多 8.17.0 已取证的精确唯一“快要抢光”,不泛化 |
|
||||||
| T-111 | 调整为开始采购授权的单趟流程 | T-004, T-010, T-110 | 状态/API/原型/任务依赖一致;删除试选后确认但不放宽三闸门和围栏 |
|
| T-111 | 调整为开始采购授权的单趟流程 | T-004, T-010, T-110 | 状态/API/原型/任务依赖一致;删除试选后确认但不放宽三闸门和围栏 |
|
||||||
|
|
||||||
@@ -77,10 +78,11 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
|
|||||||
| T-209 | 把核心 schema / 状态机迁移为单趟模型 | T-004, T-111 | 新 migration 安全替换旧 `spec_trials` / 授权锁价结构;新增 `purchase_attempts`;删除旧 MVP 状态;无业务数据事实写入迁移前置,遇未知旧数据 fail closed;领域/迁移测试同步 |
|
| T-209 | 把核心 schema / 状态机迁移为单趟模型 | T-004, T-111 | 新 migration 安全替换旧 `spec_trials` / 授权锁价结构;新增 `purchase_attempts`;删除旧 MVP 状态;无业务数据事实写入迁移前置,遇未知旧数据 fail closed;领域/迁移测试同步 |
|
||||||
| T-203 | 表格查询与批量开始采购(F-004、F-008、F-018) | T-202, T-209 | 只选 DRAFT;按钮明确“不付款”;事务内创建锁定任务字段的一次性授权并全批 `DRAFT → PENDING`;重放/冲突测试 |
|
| T-203 | 表格查询与批量开始采购(F-004、F-008、F-018) | T-202, T-209 | 只选 DRAFT;按钮明确“不付款”;事务内创建锁定任务字段的一次性授权并全批 `DRAFT → PENDING`;重放/冲突测试 |
|
||||||
| T-204 | 路由化任务详情与内部截图资产(F-007、F-011) | T-203 | 抽屉/完整页共享 URL;状态详情;受保护 `INTERNAL_RAW` screenshot 上传/读取、SHA-256 与 no-store;不接收 XML |
|
| T-204 | 路由化任务详情与内部截图资产(F-007、F-011) | T-203 | 抽屉/完整页共享 URL;状态详情;受保护 `INTERNAL_RAW` screenshot 上传/读取、SHA-256 与 no-store;不接收 XML |
|
||||||
| T-205 | purchase attempt、事件与执行摘要 API | T-204, T-103 | 记录实际规格、两次单价、数量、确认页金额、固定 reason;不再创建 `spec_trials` 审批记录 |
|
| T-210 | 扩展闸门二与闸门三证据类型 | T-107, T-204, T-302 | 先精确扩展 Gate2/Gate3 kind;保留 Gate1、归属/FK/trigger/幂等;有新事实时拒绝降级 |
|
||||||
|
| T-205 | purchase attempt、事件与执行摘要 API | T-210 | 三条成功事件分别绑定正确 Gate1/2/3 asset,冻结摘要与围栏前 reason;不再创建 `spec_trials` 审批记录 |
|
||||||
| T-206 | 单趟状态视图与安全主动作 | T-205 | PENDING/CLAIMED/ORDERING/NEEDS_MANUAL/WAITING_PAYMENT/RECONCILIATION_REQUIRED 显示和允许动作一致;无中途确认 |
|
| T-206 | 单趟状态视图与安全主动作 | T-205 | PENDING/CLAIMED/ORDERING/NEEDS_MANUAL/WAITING_PAYMENT/RECONCILIATION_REQUIRED 显示和允许动作一致;无中途确认 |
|
||||||
| T-207 | 授权有效期与围栏前重置(F-010) | T-206 | 围栏前过期/关闭旧授权后回 DRAFT;围栏后拒绝释放、取消、重新授权 |
|
| T-207 | 授权有效期与围栏前重置(F-010) | T-206 | 围栏前过期/关闭旧授权后回 DRAFT;围栏后拒绝释放、取消、重新授权 |
|
||||||
| T-208 | 提交围栏与结果调和 API(F-017) | T-207, T-107 | 原子复核授权/attempt/三闸门/唯一控件;首次许可一次;重放不许可点击;结果不明永久围栏且只调和 |
|
| T-208 | 提交围栏与结果调和 API(F-017) | T-207, T-210 | 原子复核授权/attempt/三闸门/唯一控件;首次许可一次;重放不许可点击;结果不明永久围栏且只调和 |
|
||||||
|
|
||||||
### Phase 3 · 双端打通(单趟围栏前)
|
### Phase 3 · 双端打通(单趟围栏前)
|
||||||
|
|
||||||
@@ -88,20 +90,23 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| T-301 | 设备凭据与身份隔离(F-013) | T-201 | Bearer 不能建单/授权;管理会话不能领任务;凭据可撤销 |
|
| T-301 | 设备凭据与身份隔离(F-013) | T-201 | Bearer 不能建单/授权;管理会话不能领任务;凭据可撤销 |
|
||||||
| T-302 | 已授权任务原子领取与租约(F-005) | T-301, T-203 | 只领 PENDING+有效授权;并发唯一;重放同一 attempt;claim token/generation 有效 |
|
| T-302 | 已授权任务原子领取与租约(F-005) | T-301, T-203 | 只领 PENDING+有效授权;并发唯一;重放同一 attempt;claim token/generation 有效 |
|
||||||
| T-303 | `HttpTaskSource` / `HttpResultSink` | T-302, T-002 | 执行器依赖抽象;幂等重放;线协议严格校验;不扩大 Excel 范围 |
|
| T-303 | HTTP 任务源、证据 sink 与可恢复本地状态 | T-002, T-204, T-302 | `http.client` 单次回环请求、DPAPI/SQLite append-only 状态图、Global 单实例和 durable gateway;不伪造完整 ResultSink |
|
||||||
| T-304 | 定时轮询与会话边界 | T-303, T-006 | 人启动后轮询;停止只阻止下次领取;连续失败停;当前任务/记录详情双视图 |
|
| T-304 | 定时轮询与会话边界 | T-303, T-006 | 人启动后轮询;停止只阻止下次领取;连续失败停;当前任务/记录详情双视图 |
|
||||||
| T-306 | 单趟截图与证据上传 | T-304, T-204 | 显式上传原始 screenshot;可含页面地址/手机号;完整 XML、本机路径、外部支付页不上传 |
|
| T-306 | 规格面板原始截图与可靠证据上传 | T-104, T-303 | 只发布 Gate1 的显式原始 PNG;唯一恢复槽;可含页面地址/手机号;不上传 XML/路径或扩 kind |
|
||||||
| T-305 | **单趟围栏前端到端 dry-run** | T-306, T-107, T-205 | 从领取连续跑到闸门三与提交控件验证,随后安全停止;全程事件/证据齐全,不申请真实许可、不点击提交 |
|
| T-307 | 客户端 attempt 事件与失败 sink | T-205, T-303 | 窄 events/fail HTTP 与同键恢复;不实现 ResultSink、围栏、结果、PDD 或 UI |
|
||||||
|
| T-308 | 客户端提交围栏与结果 sink | T-208, T-303, T-307 | 复用同一 transport/localstate;首次明确且 durable 的 CREATE_ONCE 才产生当前进程 opaque permit;重启不可重建 |
|
||||||
|
| T-305 | **单趟围栏前端到端 dry-run** | T-107, T-304, T-306, T-307, T-210 | 连续跑到三闸门及最终控件只读观察,随后一次安全 Back 并以固定 reason 停在围栏前;不申请许可、不点击提交 |
|
||||||
|
|
||||||
### Phase 4 · 真实提交与收尾
|
### Phase 4 · 真实提交与收尾
|
||||||
|
|
||||||
| ID | 任务 | 依赖 | 验收要点 |
|
| ID | 任务 | 依赖 | 验收要点 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| T-401 | **单趟采购与提交围栏端到端** | T-305, T-208 | 同一设备会话通过三闸门、首次明确围栏许可后点击一次;结果不明只调和;明确创建转待付款;绝不付款 |
|
| T-400 | 离线单次提交安全编排 | T-305, T-308 | 只用 fixture/fake port 证明四条件、点击意图先落盘、调用恰好一次和崩溃后只调和;生产接线仍不可达 |
|
||||||
| T-402 | 待付款收口与手工完成 | T-401 | 详情展示订单证据;人付款后手工标记;待付款不等于成功 |
|
| T-403 | 失败分类、证据归档与管理员调和视图 | T-208 | 只展示已有事实并复用同一 submission 调和事务;围栏后无重试、释放或重新授权 |
|
||||||
| T-403 | 失败分类与证据归档 | T-401 | 覆盖需求 reason;终态原子回写;围栏后无重试 |
|
| T-402 | 待付款人工收口与付款事实记录 | T-403 | 只记录人已在外部付款的事实;三组确认、完整 lineage 与追加式审计;系统不付款 |
|
||||||
| T-404 | 完整 MVP 验收 | T-402, T-403 | P0 验收、双端门禁、真机记录和 current-state 全部通过 |
|
| T-401 | **首次真实提交与生产接线** | T-400, T-402 | 新真实单趟重新通过三闸门;首次明确许可后点击一次;首次运行固定 UNKNOWN 并人工调和;绝不付款 |
|
||||||
| T-405 | 采购工具打包与运行文档 | T-404 | 运营电脑可安装运行,记录产物 SHA-256 |
|
| T-405 | 采购工具 Windows 打包与运行文档 | T-401 | PyInstaller onedir ZIP;拒绝秘密/状态/证据/tests/XML;记录 commit 与 SHA-256 |
|
||||||
|
| T-404 | 完整 MVP 只读验收 | T-401, T-402, T-403, T-405 | 冻结同一候选 commit/产物做 P0、双端门禁和真机记录复核;缺陷退回原任务,不在验收任务修复 |
|
||||||
|
|
||||||
### V2 及以后
|
### V2 及以后
|
||||||
|
|
||||||
@@ -123,8 +128,8 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
|
|||||||
- **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103)
|
- **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103)
|
||||||
- **M3**:真机设置数量、闸门二、确认页闸门三和唯一提交控件 dry-run 通过,不点击。(T-107)
|
- **M3**:真机设置数量、闸门二、确认页闸门三和唯一提交控件 dry-run 通过,不点击。(T-107)
|
||||||
- **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302)
|
- **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302)
|
||||||
- **M5**:单趟从领取到围栏前 dry-run 跑通,事件与证据齐全。(T-305)
|
- **M5**:围栏前 dry-run 跑通,客户端围栏恢复和离线一次性提交安全闭包成立。(T-305、T-308、T-400)
|
||||||
- **M6**:单趟真实提交只创建待付款订单,围栏/调和/人工收口闭环。(T-401~T-404)
|
- **M6**:新真实单趟只创建可能的待付款订单,围栏/调和/人工收口、Windows 交付和只读验收闭环。(T-401~T-405)
|
||||||
|
|
||||||
**M2 仍是生死线。** M2 前可以并行管理会话、DRAFT 建单和开始采购的服务端授权事务,但不能编写
|
**M2 仍是生死线。** M2 前可以并行管理会话、DRAFT 建单和开始采购的服务端授权事务,但不能编写
|
||||||
依赖未取证页面字段的生产执行逻辑。提速来自两端并行、窄任务和主审复用门禁,不来自删除安全闸门。
|
依赖未取证页面字段的生产执行逻辑。提速来自两端并行、窄任务和主审复用门禁,不来自删除安全闸门。
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
| IX-004 | US-002、US-004 | web 列表行 / `/tasks/{id}` | 双击行、浏览器前进后退 | 路由化详情抽屉或完整页,显示执行证据 | P0 | 已定 |
|
| IX-004 | US-002、US-004 | web 列表行 / `/tasks/{id}` | 双击行、浏览器前进后退 | 路由化详情抽屉或完整页,显示执行证据 | P0 | 已定 |
|
||||||
| IX-005 | US-005 | web 批量操作条 | 点击开始采购 | 创建一次性授权并进入 `PENDING` | P0 | 已定 |
|
| IX-005 | US-005 | web 批量操作条 | 点击开始采购 | 创建一次性授权并进入 `PENDING` | P0 | 已定 |
|
||||||
| IX-006 | US-006 | web 待付款详情 | 查看后手工标记 | 只记录人工付款完成,不执行支付 | P0 | 已定 |
|
| IX-006 | US-006 | web 待付款详情 | 查看后手工标记 | 只记录人工付款完成,不执行支付 | P0 | 已定 |
|
||||||
| IX-007 | US-003 | desk 配置 tab | 保存并检查连接 | 服务、ADB、App 版本就绪 | P0 | 已定 |
|
| IX-007 | US-003 | desk 配置 tab | 保存并校验配置 | 本地字段/路径有效;服务身份与真机能力在真实流程中验证 | P0 | 已定 |
|
||||||
| IX-008 | US-003、US-008 | desk 采购执行 tab | 开始 / 停止轮询、选择记录、Esc | 单趟执行和安全人工接管 | P0 | 已定 |
|
| IX-008 | US-003、US-008 | desk 采购执行 tab | 开始 / 停止轮询、选择记录、Esc | 单趟执行和安全人工接管 | P0 | 已定 |
|
||||||
| IX-009 | US-008 | web / desk 通用错误反馈 | 请求或流程失败 | 显示阶段、原因和安全下一步 | P0 | 已定 |
|
| IX-009 | US-008 | web / desk 通用错误反馈 | 请求或流程失败 | 显示阶段、原因和安全下一步 | P0 | 已定 |
|
||||||
| IX-010 | US-005 | web 围栏前人工处理 | 重置 / 取消 | 关闭旧授权后回 `DRAFT` 或取消 | P0 | 已定 |
|
| IX-010 | US-005 | web 围栏前人工处理 | 重置 / 取消 | 关闭旧授权后回 `DRAFT` 或取消 | P0 | 已定 |
|
||||||
@@ -57,7 +57,8 @@
|
|||||||
|
|
||||||
### IX-006 待付款核对与标记完成
|
### IX-006 待付款核对与标记完成
|
||||||
|
|
||||||
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和订单截图。
|
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和三闸门内部证据;MVP
|
||||||
|
没有提交后结果截图类型,不把确认页图片冒充订单结果图。
|
||||||
- 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。”
|
- 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。”
|
||||||
- “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。
|
- “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。
|
||||||
- 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。
|
- 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。
|
||||||
@@ -105,7 +106,8 @@
|
|||||||
1. 当前任务在同一趟内完成闸门二和闸门三,并确认提交控件精确唯一。
|
1. 当前任务在同一趟内完成闸门二和闸门三,并确认提交控件精确唯一。
|
||||||
2. 客户端显示“正在申请提交围栏”,提交当前验证摘要。
|
2. 客户端显示“正在申请提交围栏”,提交当前验证摘要。
|
||||||
3. 只有服务端首次明确返回 `click_permitted=true`,客户端才先持久化唯一点击意图,再精确点击一次。
|
3. 只有服务端首次明确返回 `click_permitted=true`,客户端才先持久化唯一点击意图,再精确点击一次。
|
||||||
4. 明确创建转待付款;外部支付、安全校验、超时、断连或未知页面转待调和。
|
4. MVP 首次真实点击后因没有已取证的提交后页面判据,无条件上报 `UNKNOWN` 并转待调和;只有管理员
|
||||||
|
对同一 submission 明确调和为已创建,才转待付款。
|
||||||
5. 围栏后的 web 详情只显示同一 `submission_id` 的证据、调和记录与人工结论。
|
5. 围栏后的 web 详情只显示同一 `submission_id` 的证据、调和记录与人工结论。
|
||||||
|
|
||||||
| 状态 | 允许动作 | 禁止动作 |
|
| 状态 | 允许动作 | 禁止动作 |
|
||||||
|
|||||||
+144
-41
@@ -48,7 +48,7 @@
|
|||||||
"code": "version_conflict",
|
"code": "version_conflict",
|
||||||
"message": "任务已变化,请刷新后重选",
|
"message": "任务已变化,请刷新后重选",
|
||||||
"retryable": false,
|
"retryable": false,
|
||||||
"request_id": "018f..."
|
"request_id": "c3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"create_key": "018f...",
|
"create_key": "d3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"title": "纯棉短袖",
|
"title": "纯棉短袖",
|
||||||
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
|
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
|
||||||
"sku_color": "黑色CHA(纯棉)",
|
"sku_color": "黑色CHA(纯棉)",
|
||||||
@@ -96,6 +96,11 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。
|
- 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。
|
||||||
|
- `title` 以 Go `strings.TrimSpace`(Unicode White_Space)后的持久化值计,最多 120 个 Unicode code
|
||||||
|
point;`sku_color`、`sku_size` 同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8
|
||||||
|
必须先拒绝,不能把替换字符当作合法 code point;为消除 Go 与 Python 默认 trim 差异,U+001C--U+001F
|
||||||
|
四个 C0 分隔符无论位置一律拒绝;超限不得截断。
|
||||||
|
- `goods_id` 只允许 1--32 位 ASCII 数字;规范金额只允许 1--32 个 ASCII 字符。
|
||||||
- `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。
|
- `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。
|
||||||
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
|
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
|
||||||
|
|
||||||
@@ -103,10 +108,10 @@
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"start_key": "018f...",
|
"start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"task_id": "018f-task-1", "expected_task_version": 1},
|
{"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1},
|
||||||
{"task_id": "018f-task-2", "expected_task_version": 1}
|
{"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -125,11 +130,11 @@
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"start_key": "018f...",
|
"start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"authorized_count": 2,
|
"authorized_count": 2,
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"task_id": "018f-task-1", "task_version": 2, "authorization_id": "018f-auth-1"},
|
{"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "a3c9f507-7473-4fa6-8d71-8786c34c6301"},
|
||||||
{"task_id": "018f-task-2", "task_version": 2, "authorization_id": "018f-auth-2"}
|
{"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "b3c9f507-7473-4fa6-8d71-8786c34c6301"}
|
||||||
],
|
],
|
||||||
"payment_automated": false
|
"payment_automated": false
|
||||||
}
|
}
|
||||||
@@ -160,7 +165,7 @@
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"device_id": "desk-01",
|
"device_id": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"client_version": "0.1.0",
|
"client_version": "0.1.0",
|
||||||
"adb_serial": "192.168.0.173:5555",
|
"adb_serial": "192.168.0.173:5555",
|
||||||
"android_release": "16",
|
"android_release": "16",
|
||||||
@@ -173,12 +178,22 @@
|
|||||||
|
|
||||||
### `POST /api/v1/tasks/claim-next`
|
### `POST /api/v1/tasks/claim-next`
|
||||||
|
|
||||||
请求携带 `device_id`、`session_id`、`claim_request_id`。领取与授权绑定且具租约:
|
设备 id 只来自已经认证的 `X-CMBuyer-Device-ID`,不得放进 JSON。请求体上限 4096 字节,只接受
|
||||||
|
以下两个字段;二者都必须是规范小写 UUIDv4,未知字段、重复字段和额外 JSON 均拒绝:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
|
"claim_request_id": "33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功领取或同一会话恢复返回 `200`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"task": {
|
"task": {
|
||||||
"id": "018f-task",
|
"id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"title": "纯棉短袖",
|
"title": "纯棉短袖",
|
||||||
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
|
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
|
||||||
@@ -189,24 +204,78 @@
|
|||||||
"max_total_price": "30.00"
|
"max_total_price": "30.00"
|
||||||
},
|
},
|
||||||
"authorization": {
|
"authorization": {
|
||||||
"id": "018f-auth",
|
"id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"task_version": 2,
|
"task_version": 2,
|
||||||
"expires_at": "2026-08-04T10:00:00Z"
|
"expires_at": "2026-08-04T10:00:00Z"
|
||||||
},
|
},
|
||||||
"attempt": {
|
"attempt": {
|
||||||
"id": "018f-attempt",
|
"id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"claim_token": "opaque-single-claim-token",
|
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
"claim_generation": 1,
|
"claim_generation": 1,
|
||||||
"lease_expires_at": "2026-08-04T09:05:00Z"
|
"lease_expires_at": "2026-08-04T09:05:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- 只返回有 `ACTIVE` 授权的 `PENDING`;服务端在一个事务中转为 `CLAIMED` 并创建 attempt。
|
- 设备认证发生在 Content-Type 解析和 body 读取前。事务的第一条数据库业务语句取得 SQLite 写入
|
||||||
- 同一 `claim_request_id` 同载荷重放同一结果;并发设备只有一个成功。
|
位置并再次条件确认设备仍为 `ACTIVE`;之后才允许查幂等记录、候选或返回 EMPTY/冲突。
|
||||||
- 一个设备有未结束领取时优先重放该领取,不能悄悄领第二条。
|
- 只返回 `PENDING + ACTIVE + 严格未过期` 且 task/version/规格/数量/总价快照完全一致的最早授权;
|
||||||
|
服务端在同一事务中创建唯一 attempt/claim/request,并转为 `CLAIMED`。
|
||||||
|
- 同一 `claim_request_id` 同设备、同 session 稳定重放原结果;同键异载荷返回
|
||||||
|
`409 {"error":"idempotency_conflict"}`。没有候选返回空 `204`,且 EMPTY 也持久化稳定重放。
|
||||||
|
- claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、
|
||||||
|
数量、金额、版本或到期时间漂移。每次首次构造和旧 request 重放都重新校验持久化响应快照的字段
|
||||||
|
上限;源快照不一致时,新恢复/续租失败闭合。
|
||||||
|
- 响应内 `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个;`goods_id`
|
||||||
|
为 1--32 位 ASCII 数字,`max_total_price` 为最多 32 个 ASCII 字符的规范金额。创建、授权快照、
|
||||||
|
candidate、持久化 claim snapshot 和 HTTP 输出共用同一合法域;既有畸形行只失败闭合,不迁移、
|
||||||
|
截断或改写。candidate 查询必须先读取 task 与 authorization 两侧字段并分别验证;任一侧畸形必须
|
||||||
|
回滚且不得持久化 `EMPTY`,只有两侧均合法但快照不一致时才跳过。最坏合法字段组合编码后必须明确
|
||||||
|
小于既有 32 KiB claim 响应上限。
|
||||||
|
- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端
|
||||||
|
首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约
|
||||||
|
过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。
|
||||||
|
- `claim_token` 是 32 字节 HMAC 的 64 位小写十六进制表示,只证明一个 attempt 的归属,不是提交许可。
|
||||||
|
SQLite 仅保存随机 nonce 与 token SHA-256;同一 secret 重启后重建相同 token,错误 secret 拒绝启动。
|
||||||
- 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。
|
- 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。
|
||||||
|
|
||||||
|
### `POST /api/v1/tasks/{id}/lease/renew`
|
||||||
|
|
||||||
|
请求体同样限 4096 字节并执行严格 JSON 校验:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"renew_request_id": "43c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
|
"session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
|
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
|
"claim_generation": 1,
|
||||||
|
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
|
"expected_lease_expires_at": "2026-08-04T09:05:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功返回 `200`;响应不回显 token:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
|
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
|
"claim_generation": 1,
|
||||||
|
"lease_expires_at": "2026-08-04T09:06:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 原设备/session/attempt/generation/token 必须同时匹配,当前租约与授权都必须严格晚于服务端 UTC
|
||||||
|
当前时间,`expected_lease_expires_at` 必须逐字等于数据库当前值;边界相等即过期且不能复活。
|
||||||
|
- 新到期时间是 `min(server_now + CMBUYER_CLAIM_LEASE_TTL, authorization.expires_at)`。续租不改变
|
||||||
|
token、generation、任务版本或业务状态。
|
||||||
|
- 成功续租先持久化 request 与响应;同一 `renew_request_id` 同载荷只重放旧响应,不再次 CAS 或延长。
|
||||||
|
同键异载荷返回 `idempotency_conflict`;非当前 claim 固定返回 `claim_not_current`,两者均为 `409`。
|
||||||
|
|
||||||
|
claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限为
|
||||||
|
`413 {"error":"request_too_large"}`,Content-Type 错误为
|
||||||
|
`415 {"error":"unsupported_media_type"}`;设备认证/事务内撤销为无诊断 `401`,存储故障为无诊断 `503`。
|
||||||
|
|
||||||
### 事件与证据
|
### 事件与证据
|
||||||
|
|
||||||
事件只包含固定 `step` / `outcome` / `reason_code` 和非敏感摘要。禁止把完整 XML、地址、手机号、
|
事件只包含固定 `step` / `outcome` / `reason_code` 和非敏感摘要。禁止把完整 XML、地址、手机号、
|
||||||
@@ -234,8 +303,10 @@
|
|||||||
- 恰好一个带 `Content-Type: image/png` 的显式文件;除上述六个元数据字段外,未知或重复字段均拒绝。
|
- 恰好一个带 `Content-Type: image/png` 的显式文件;除上述六个元数据字段外,未知或重复字段均拒绝。
|
||||||
- 单文件最多 10 MiB、单边最多 8192 px、总像素最多 16,777,216;服务端校验 PNG 魔数、完整解码、
|
- 单文件最多 10 MiB、单边最多 8192 px、总像素最多 16,777,216;服务端校验 PNG 魔数、完整解码、
|
||||||
字节数、尺寸与调用方声明的 64 位小写 SHA-256。
|
字节数、尺寸与调用方声明的 64 位小写 SHA-256。
|
||||||
- `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task。认证必须先于 Content-Type 解析和请求体读取。
|
- `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task,且首次写入必须存在由认证设备持有的
|
||||||
- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;任务、attempt、截图或元数据变化返回 `409`。
|
未关闭 claim;设备 A 不能向设备 B 的 attempt 上传。认证必须先于 Content-Type 解析和请求体读取。
|
||||||
|
- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;即使 claim 后续由人工关闭,已成功资产仍先
|
||||||
|
重放历史结果。关闭后不得用新 upload key 写新证据;任务、attempt、截图或元数据变化返回 `409`。
|
||||||
- 首次成功返回 `201`,幂等重放返回 `200`。响应只含资产 id、关联 id、kind/tier、hash、字节数、
|
- 首次成功返回 `201`,幂等重放返回 `200`。响应只含资产 id、关联 id、kind/tier、hash、字节数、
|
||||||
MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。
|
MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。
|
||||||
- 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、
|
- 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、
|
||||||
@@ -247,11 +318,11 @@
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"fence_key": "018f-fence-request",
|
"fence_key": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"task_id": "018f-task",
|
"task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"expected_task_version": 3,
|
"expected_task_version": 3,
|
||||||
"authorization_id": "018f-auth",
|
"authorization_id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"claim_token": "opaque-single-claim-token",
|
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
"selected_color": "黑色CHA(纯棉)",
|
"selected_color": "黑色CHA(纯棉)",
|
||||||
"selected_size": "M(建议100-115)",
|
"selected_size": "M(建议100-115)",
|
||||||
"gate1_unit_price": "12.88",
|
"gate1_unit_price": "12.88",
|
||||||
@@ -271,7 +342,7 @@
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"submission_id": "018f-submission",
|
"submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"status": "FENCED",
|
"status": "FENCED",
|
||||||
"click_permitted": true,
|
"click_permitted": true,
|
||||||
"submit_text": "提交订单"
|
"submit_text": "提交订单"
|
||||||
@@ -288,10 +359,10 @@
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"result_key": "018f-result",
|
"result_key": "03c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"attempt_id": "018f-attempt",
|
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||||
"observation": "SUBMITTED",
|
"observation": "SUBMITTED",
|
||||||
"evidence_asset_id": "018f-asset"
|
"evidence_asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -306,30 +377,62 @@
|
|||||||
|
|
||||||
### 文本和金额校验
|
### 文本和金额校验
|
||||||
|
|
||||||
- 规格字段:Unicode 规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。
|
- 标题/规格字段:输入按 Go `strings.TrimSpace` 的 Unicode White_Space 集形成实际持久化值;title 最多
|
||||||
- `goods_id`:仅 ASCII 十进制数字,canonical URL 中唯一。
|
120 个 Unicode code point,颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;
|
||||||
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后再比较和持久化。
|
U+001C--U+001F 四个 C0 分隔符在任意位置均拒绝,客户端使用同一固定空白集而不依赖 Python
|
||||||
|
`str.strip()` 默认语义。不按 UTF-8 字节或视觉 grapheme 计数,不截断超限值。规格比较仍为规范化后
|
||||||
|
精确相等;不得包含、前缀、编辑距离或 AI 猜测。
|
||||||
|
- `goods_id`:仅 1--32 位 ASCII 十进制数字,canonical URL 中唯一。
|
||||||
|
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后必须是最多 32 个 ASCII 字符,再比较和持久化。
|
||||||
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
|
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
|
||||||
|
|
||||||
## 三、采购工具本地模块合约
|
## 三、采购工具本地模块合约
|
||||||
|
|
||||||
### `TaskSource` / `ResultSink`
|
### T-303 已实现端口
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class TaskSource(Protocol):
|
class TaskSource(Protocol):
|
||||||
def claim_next(self, session: Session) -> ClaimedPurchase | None: ...
|
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
|
||||||
def renew_lease(self, claim: Claim) -> Lease: ...
|
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
|
||||||
|
|
||||||
class ResultSink(Protocol):
|
class EvidenceSink(Protocol):
|
||||||
def append_events(self, claim: Claim, events: list[TaskEvent]) -> None: ...
|
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
|
||||||
def upload_screenshot(self, claim: Claim, asset: ScreenshotAsset) -> AssetRef: ...
|
|
||||||
def fail_attempt(self, claim: Claim, failure: AttemptFailure) -> None: ...
|
|
||||||
def create_submission_fence(self, claim: Claim, proof: SubmissionProof) -> SubmissionPermit: ...
|
|
||||||
def report_submission_result(self, permit: SubmissionPermit, result: SubmissionResult) -> None: ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
执行器不能依赖具体 HTTP 或 Excel 实现。`SubmissionPermit` 只能由 `ResultSink` 的服务端成功响应构造,
|
T-303 只实现 claim/renew 与 `SKU_PANEL_GATE_1` 单张 PNG;不得用运行时 `NotImplementedError` 伪造
|
||||||
业务代码不能手工 new 一个许可。
|
events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `DurableClientGateway` 调用:它先把
|
||||||
|
同一个 request/upload key 与完整载荷写入 SQLite,再最多发送一次 HTTP;401 保留 `PENDING`,由用户修复
|
||||||
|
同一 device id 的 Bearer 后显式重放;网络、超时、503、截断、非法/未知 2xx 同样只保留原槽。协议/409
|
||||||
|
终止槽但不换 key。成功响应落库失败时,重启仍用原 key 向服务端恢复事实。
|
||||||
|
|
||||||
|
金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零,最多
|
||||||
|
32 个 ASCII 字符);`goods_id` 只接受 1--32 位 ASCII 数字,title 最多 120 个 Unicode code point,
|
||||||
|
颜色与尺码各最多 80 个。客户端用与 Go `strings.TrimSpace` 相同的固定 Unicode White_Space 集校验
|
||||||
|
持久化文本,并与服务端共同拒绝任意位置的 U+001C--U+001F;不得截断或修复漂移响应。wire 整数为正 int64,拒绝 bool。claim 成功
|
||||||
|
响应总上限仍为 32 KiB,最坏合法字段组合由双端契约测试证明严格小于该值。RFC3339Nano 按 0--9 位
|
||||||
|
小数的纳秒时间轴比较,不能用 Python 微秒精度截断。
|
||||||
|
|
||||||
|
### 本地恢复合约
|
||||||
|
|
||||||
|
- 数据库固定为 `%LOCALAPPDATA%\cmbuyer\state\client-state.sqlite3`,WAL + `synchronous=FULL`;Windows
|
||||||
|
缺少 `LOCALAPPDATA` 时失败,不回退到 home 创建第二套状态。
|
||||||
|
- device token 与 claim token 的原始 32 字节只以当前用户 DPAPI 密文 BLOB 入库;前者 context 绑定
|
||||||
|
profile+device,后者绑定 profile+attempt,跨行交换密文会解密失败。claim token 不可更换;有
|
||||||
|
pending/open 状态时冻结 service/device/ADB/transport/轮询与超时配置,仅允许同 device id 修复 Bearer;
|
||||||
|
idle 时切换 device id 也必须同时提供新 token。
|
||||||
|
- `Global\cmbuyer-<db-path-hash>` named mutex 在任何 SQLite/DPAPI 打开前取得;同一状态库跨 Windows
|
||||||
|
session 只允许一个采购工具进程。
|
||||||
|
- 运行根目录与数据库路径在构造时固化为绝对路径;之后 cwd 改变不得打开第二套库或绕过原 mutex。
|
||||||
|
- polling session、claim history、renew request 和 evidence marker/slot 都是 append-only 历史;当前行用
|
||||||
|
`closed_at IS NULL` partial unique 表示。恢复或发送前在同一 SQLite 读快照校验整张状态图,冗余列、
|
||||||
|
snapshot、request、claim token、marker、slot、receipt 任一不一致都零 HTTP 失败闭合。
|
||||||
|
- evidence 槽唯一键是 `(attempt_id, kind)`。首次发送前固定显式路径的 regular/non-reparse 文件 identity、
|
||||||
|
size、mtime、SHA-256、IHDR 尺寸与字节;pending 时变化即停,并始终用首次保存的 exact metadata 重放
|
||||||
|
相同 multipart。成功后 receipt 成为事实,源文件变化或删除只返回原 receipt,不再次上传;receipt
|
||||||
|
尺寸必须与本地 IHDR 一致。客户端做签名/IHDR/尺寸/hash 防御;采购服务仍负责完整 PNG 解码权威校验。
|
||||||
|
|
||||||
|
完整 `ResultSink` 只有在 T-205/T-208 服务端契约完成后才由后续任务组合;`SubmissionPermit` 只能由
|
||||||
|
服务端首次明确成功响应构造,业务代码不能手工创建。
|
||||||
|
|
||||||
### 真机能力分层
|
### 真机能力分层
|
||||||
|
|
||||||
|
|||||||
+37
-19
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-08-04
|
- 日期:2026-08-05
|
||||||
- 阶段:**Phase 1 · T-103 规格选择/读价与 Phase 2 安全服务端任务并行**
|
- 阶段:**Phase 1 · T-103 规格选择/读价与 Phase 2 安全服务端任务并行**
|
||||||
- MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确
|
- MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确
|
||||||
选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端
|
选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端
|
||||||
@@ -19,15 +19,20 @@
|
|||||||
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
|
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
|
||||||
使用 Python 3.11+ / uiautomator2 / PySide6。
|
使用 Python 3.11+ / uiautomator2 / PySide6。
|
||||||
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
||||||
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
|
- 生产代码:`admin/` 已有登录/会话、手工 DRAFT 建单、批量开始采购与一次性授权、任务详情、内部
|
||||||
|
原始截图、设备身份,以及已授权任务的原子领取/幂等重放/租约续期;
|
||||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
||||||
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
|
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
|
||||||
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
|
绑定 PKG110 / Android 16 / 拼多多 8.17.0 的证据工具,以及受控规格入口、精确规格选择、SKU 当前价
|
||||||
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 80 项离线单元测试
|
读取、原始截图和一次安全退出能力;并已具备严格 localhost claim/renew/evidence HTTP、Windows DPAPI
|
||||||
(全部 mock,不连接真机)
|
凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。原生桌面主界面、
|
||||||
- 数据:SQLite v2 单趟核心表与领域状态机已落成,旧 `spec_trials`、`authorized_unit_price` 和
|
显式本地配置、停止/重启会话边界和安全轮询协调器已实现;未注入单趟执行 consumer 时保持零领取。
|
||||||
两趟状态已由受保护迁移移除;无业务实例数据。T-203 可以基于新模型实现批量“开始采购”与
|
真机能力与桌面界面仍待最终人工验收,数量/确认页/提交仍未开放。
|
||||||
一次性授权事务。
|
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
|
||||||
|
采购工具 240 项离线单元测试(全部 mock,不连接真机)。
|
||||||
|
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
|
||||||
|
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、
|
||||||
|
goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。仓库不含业务实例数据。
|
||||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||||
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
|
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
|
||||||
并且不覆盖低版本环境;成功后打印真实启动命令。
|
并且不覆盖低版本环境;成功后打印真实启动命令。
|
||||||
@@ -39,20 +44,24 @@
|
|||||||
分别取证后才接入生产单趟流程。T-103 的 v5 派生 XML 已由人确认无实际地址/
|
分别取证后才接入生产单趟流程。T-103 的 v5 派生 XML 已由人确认无实际地址/
|
||||||
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
|
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
|
||||||
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
|
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
|
||||||
规格选择与读价;T-204 将直接上传内部原始截图供管理员查看。T-010 已允许不依赖真机字段的 T-201
|
规格选择与读价;T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并
|
||||||
和只创建 `DRAFT` 的 T-202 并行。T-209 已完成 schema / 状态机单趟迁移;T-203 服务端
|
收紧为精确唯一“快要抢光”子节点及证据绑定可点击祖先,“免拼购买”仍为硬拒绝。最新真机证据
|
||||||
“开始采购”授权事务可立即推进。T-205 起实际 attempt / 真机字段继续等待 T-103。
|
证明商品页全文 XML 会随动态正文持续变化,但入口动作子树保持稳定;实现已改为连续验证该精确子树,
|
||||||
|
并拒绝任何覆盖入口点击中心的额外可点击控件。当前还发现 SystemUI“肉包采购辅助”浮窗覆盖入口中心,
|
||||||
|
复跑前必须由人关闭。T-103 只差关闭浮窗后完成精确规格恢复、读取 `12.88` 和一次安全退出。
|
||||||
|
完整事件链必须等待 T-107 与 T-210:先让 Gate2/Gate3 证据进入真实 schema,再实现 T-205 事件,
|
||||||
|
不用 mock 或无证据成功事件抢跑。
|
||||||
|
|
||||||
## 当前目录要点
|
## 当前目录要点
|
||||||
|
|
||||||
| 路径 | 状态 | 说明 |
|
| 路径 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-203、T-209) | T-111 单趟契约、T-202 手工 DRAFT 建单及 T-209 单趟 schema 已完成;T-103、T-203 并行推进 |
|
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-211、T-301~T-308、T-400~T-405) | T-303、T-211 已完成;T-304 代码完成后保持 DOING 等人工 UI 复核;T-103 等最终真机验收;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
|
||||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
|
||||||
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开/规格面板只读取证、确定性证据脱敏、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
|
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/安全退出;数量、确认页和提交未开放 |
|
||||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||||
|
|
||||||
## 任务状态
|
## 任务状态
|
||||||
@@ -68,11 +77,20 @@
|
|||||||
- 已完成 T-010(安全并行门禁)、T-201(管理员登录与会话)与 T-202(手工 DRAFT 建单和
|
- 已完成 T-010(安全并行门禁)、T-201(管理员登录与会话)与 T-202(手工 DRAFT 建单和
|
||||||
基础列表)。T-202 已通过主 agent 独立审查、竞态测试与完整门禁并合入主线,仍只创建/展示
|
基础列表)。T-202 已通过主 agent 独立审查、竞态测试与完整门禁并合入主线,仍只创建/展示
|
||||||
`DRAFT`,未实现授权、设备领取或采购执行。
|
`DRAFT`,未实现授权、设备领取或采购执行。
|
||||||
- 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为
|
- 已完成 T-110、T-111、T-203、T-204、T-301 与 T-302。服务端已能授权、认证设备、原子领取、
|
||||||
`DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交;
|
稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。
|
||||||
内部原始截图上传交给 T-204。admin 方向已完成 T-209,正在转入 T-203“开始采购”授权事务。
|
- T-103 离线 `SkuSelectionFlow` 已实现并通过独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107
|
||||||
- 已完成 T-209:SQLite v2 已迁移为单趟授权、采购尝试和提交围栏模型,并删除旧试选领域模型;
|
按数量/确认页风险逐段开放。真机运行要求同一物理手机只保留一个 ADB 通道;CLI 已增加不回显页面
|
||||||
迁移和状态机护栏已通过完整门禁。下一项采购服务任务为 T-203 批量“开始采购”与一次性授权。
|
正文的固定白名单失败阶段码;`sku_entry` 动态页面误判已按最新真机证据修复,覆盖点击中心的浮窗
|
||||||
|
会保持零点击。服务端后续顺序已收紧为
|
||||||
|
T-210 → T-205 → T-206 → T-207 → T-208。
|
||||||
|
- T-303 已完成并通过三重审查;T-304 的轮询会话、配置页和固定双 Tab 主界面已通过两轮独立代码审计,
|
||||||
|
当前 240 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、
|
||||||
|
Narrator 和纯键盘人工验收。客户端后续按依赖推进 T-306/T-307/T-308,再与 T-305 汇合到
|
||||||
|
T-400 离线一次性提交安全编排。围栏后管理链为 T-403 → T-402;T-401 必须在新的真实单趟重新
|
||||||
|
通过三闸门,首次点击后固定进入 `UNKNOWN`/人工调和,T-405 打包后由 T-404 只读验收。
|
||||||
|
- T-211 已完成 claim wire 字段上限闭合:创建、授权、candidate、持久化快照、重放、HTTP 输出和
|
||||||
|
客户端解析使用同一合法域;单侧畸形历史数据失败闭合且不写 EMPTY,最坏合法响应严格小于 32 KiB。
|
||||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||||
可读字段时必须先回修原型与交互清单。
|
可读字段时必须先回修原型与交互清单。
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -76,7 +76,8 @@
|
|||||||
2. 开始采购授权:授权 id、授权人、锁定任务版本、创建/有效期、当前状态。
|
2. 开始采购授权:授权 id、授权人、锁定任务版本、创建/有效期、当前状态。
|
||||||
3. 设备执行:attempt、设备、App 版本、步骤时间线和失败 code。
|
3. 设备执行:attempt、设备、App 版本、步骤时间线和失败 code。
|
||||||
4. 三道闸门:两次规格面板单价、规格/数量读回、确认页总额与判定。
|
4. 三道闸门:两次规格面板单价、规格/数量读回、确认页总额与判定。
|
||||||
5. 内部截图:规格面板、确认页和结果页;只经受保护端点读取。
|
5. 内部截图:三道闸门已批准的规格面板/确认页证据;只经受保护端点读取。MVP 不存在提交后结果
|
||||||
|
截图类型,不展示外部支付页,也不把 Gate3 图片冒充结果图。
|
||||||
6. 提交围栏:submission id、是否首次明确许可、唯一点击和调和记录。
|
6. 提交围栏:submission id、是否首次明确许可、唯一点击和调和记录。
|
||||||
7. 待付款收口:明确写系统尚未付款;人工核对后记录完成。
|
7. 待付款收口:明确写系统尚未付款;人工核对后记录完成。
|
||||||
|
|
||||||
@@ -122,10 +123,12 @@ T-204 只显示数据库中当前实际存在的任务、授权、attempt、subm
|
|||||||
|
|
||||||
### 配置 tab
|
### 配置 tab
|
||||||
|
|
||||||
- 采购服务 URL、设备 token(密码框,不回显完整值);
|
- 采购服务 URL、设备 token(密码框,不回显完整值;首次必填,已有凭据时留空表示保留,非空表示替换);
|
||||||
- ADB 路径、设备 serial、USB/WiFi 通道选择;
|
- ADB 路径、设备 serial、USB/WiFi 通道选择;
|
||||||
- 轮询间隔与连续失败停止阈值;
|
- 轮询间隔与连续失败停止阈值;
|
||||||
- 连接检查:服务、设备身份、拼多多安装和已取证版本。
|
- 只做本地字段与路径格式检查,并提示服务身份、设备连接、拼多多安装和已取证版本将在首次真实领取/
|
||||||
|
后续已批准真机流程中验证。配置页不调用 `claim-next`、renew、evidence 等业务 API,也不运行 ADB/PDD
|
||||||
|
作为“连接检查”;MVP 不提供清除已保存设备凭据的命令。
|
||||||
|
|
||||||
配置凭据进入系统安全存储;日志和界面不显示完整 token。
|
配置凭据进入系统安全存储;日志和界面不显示完整 token。
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -22,7 +22,7 @@ write_paths:
|
|||||||
- docs/current-state.md
|
- docs/current-state.md
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T10:47:47Z sha256=5471bdfeed1fffe4f0abd25201b9d5bf7bf55fa9500c11aa297eb4c422da5f02 -->
|
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T02:15:51Z sha256=4d24a78f3e69b08f44255093d6c95897aba647953fb78e0f4e143a9c91cc74e9 -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。
|
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。
|
||||||
@@ -182,6 +182,30 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
|
|||||||
### 2026-08-04T10:47:38Z · ila
|
### 2026-08-04T10:47:38Z · ila
|
||||||
|
|
||||||
2026-08-04 T-103 入口父链收紧完成:提交 44586fe,合入主分支 ce9d6ca。入口只允许精确唯一“快要抢光”子节点及五层已取证可点击 PDD 祖先链,实际点击仍取子节点中心;“免拼购买”独立容器为硬拒绝。独立审计复算证据 hash、验证 focused 32 / full 112 tests 和完整 init 均 PASS。最终真机运行曾安全失败且未发布输出目录;随后只读诊断发现系统通知栏覆盖,app_current 虽仍报告 PDD,但节点树 64 个节点全部属于 SystemUI。下一次必须由人先完全收起通知栏并停在 PDD 首页再运行;T-103 保持 DOING,不把这次失败或入口截图确认误记为完整验收。
|
2026-08-04 T-103 入口父链收紧完成:提交 44586fe,合入主分支 ce9d6ca。入口只允许精确唯一“快要抢光”子节点及五层已取证可点击 PDD 祖先链,实际点击仍取子节点中心;“免拼购买”独立容器为硬拒绝。独立审计复算证据 hash、验证 focused 32 / full 112 tests 和完整 init 均 PASS。最终真机运行曾安全失败且未发布输出目录;随后只读诊断发现系统通知栏覆盖,app_current 虽仍报告 PDD,但节点树 64 个节点全部属于 SystemUI。下一次必须由人先完全收起通知栏并停在 PDD 首页再运行;T-103 保持 DOING,不把这次失败或入口截图确认误记为完整验收。
|
||||||
|
|
||||||
|
### 2026-08-05T01:19:13Z · ila
|
||||||
|
|
||||||
|
2026-08-05 T-103 人工复跑与失败阶段诊断收口:
|
||||||
|
|
||||||
|
- 人工复跑先因 NotificationShade 覆盖和 USB/Wi-Fi 双 ADB 通道同时在线而安全失败;收敛为仅保留 Wi-Fi ADB、拼多多前台为 HomeActivity 后,仍以 generic failure 安全停止且未发布证据目录。
|
||||||
|
- 主线提交 `870e5f8` 为真机 CLI 增加只来自固定白名单的 failure stage;异常正文、serial、路径与 XML 均不回显,hostile getter、`str` 子类和 setter 全部失败闭合为 `unknown`,原异常类型、finally 安全调和、单击不重试与失败不发布语义保持不变。
|
||||||
|
- 独立终审 PASS;T-103 33 tests PASS,client 全量 233 tests PASS,仓库根 `init.ps1` PASS。
|
||||||
|
- 尚待项目所有者使用新 CLI 再次真机复跑;T-103 保持 done=false / Doing,并继续保留 needs_device 与 needs_human_review 门禁。
|
||||||
|
|
||||||
|
### 2026-08-05T02:14:16Z · ila
|
||||||
|
|
||||||
|
2026-08-05 T-103 真机复跑与入口稳定性证据:
|
||||||
|
|
||||||
|
- 项目所有者复跑返回 `stage=sku_entry`,流程安全停止且未发布本地证据目录。
|
||||||
|
- root 只读保存本机证据 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-entry-stability-937122477375-20260805-093541`,manifest 位于同目录。证据绑定 PKG110 / Android 16 / 拼多多 8.17.0 / goods_id `937122477375` / 1080×2376;5 份完整 XML 的 hash 与节点数发生变化,但完整、精确的入口动作子树投影连续一致,截图由项目所有者确认是目标商品页。
|
||||||
|
- 同时确认 SystemUI“肉包采购辅助”浮窗覆盖入口中心,会截获入口点击;因此真机复跑前必须先关闭该浮窗。
|
||||||
|
- 提交 `a09c58b`:pre-intent 只按完整物理旧链防止误点;动态整页稳定性改为精确入口动作子树连续稳定;任何覆盖入口中心的 live clickable 节点一律拒绝;危险语义和重复入口继续拒绝。入口仍只点击一次且不重试,不包含数量、确认页、提交或支付能力。
|
||||||
|
- 验证通过:聚焦测试 9/9、T-103 40/40、仓库根 `init.ps1` PASS(client 240 项)。
|
||||||
|
- `needs_device=true` / `needs_human_review=true`,T-103 继续 Doing;等待项目所有者关闭浮窗、返回拼多多首页并再次真机复跑。
|
||||||
|
|
||||||
|
### 2026-08-05T02:15:29Z · ila
|
||||||
|
|
||||||
|
更正 comment 105:最新 sku-entry-stability 证据的 screenshot.png 由 root 只读视觉核对为目标商品页;项目所有者尚未对本次证据做最终人工页面对应性验收。该更正不改变 T-103 Doing/needs_device/needs_human_review。
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|||||||
+50
-29
@@ -6,7 +6,7 @@ deps: [T-103]
|
|||||||
status: TODO
|
status: TODO
|
||||||
created: 2026-08-04
|
created: 2026-08-04
|
||||||
vikunja_task_id: 32
|
vikunja_task_id: 32
|
||||||
context_ref: 9a4d11f
|
context_ref: 3a0a41d
|
||||||
work_branch: task/t-104-safe-exit
|
work_branch: task/t-104-safe-exit
|
||||||
needs_device: true
|
needs_device: true
|
||||||
needs_human_review: true
|
needs_human_review: true
|
||||||
@@ -21,62 +21,83 @@ write_paths:
|
|||||||
- docs/current-state.md
|
- docs/current-state.md
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=32 synced=2026-08-04T10:47:05Z sha256=d0fe6a30708d16440962b8ab9ec81ce33065f96363597bd55e0a1fb4b442b6fd -->
|
<!-- BEGIN VIKUNJA EXPORT id=32 synced=2026-08-04T15:33:56Z sha256=cd649f78ae8d79edcfe94fb3702f8dbf99b12d1d4cbf2c0069b14cffa8703464 -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-103 已实现受控规格选择、读价、截图和单次 Back,但当前 `exit_sku_panel_safely()` 在 hierarchy 变化且规格面板判据不再成立时就返回成功。2026-08-04 的只读诊断已证明:通知栏覆盖时 `app_current` 仍可能报告拼多多,而节点树全部属于 SystemUI。因此“已不在面板”不是安全退出的充分条件。T-104 用独立真机证据把后置条件收紧为“稳定回到同一目标商品详情页”。
|
T-103 已实现受控规格选择、闸门一读价、规格面板原始截图和单次 Back,但当前 `exit_sku_panel_safely()` 只要 hierarchy 变化且规格面板判据不再成立就返回成功。现有纯 mock 测试还把 Back 后的空 hierarchy 当成成功并允许 runner 发布 `safe_exit=completed`。2026-08-04 的只读诊断同时证明:通知栏覆盖时 `app_current` 仍可能报告拼多多,而节点树全部属于 SystemUI。因此“面板消失”或“前台包仍是 PDD”都不是安全退出的充分条件;T-104 必须先取得独立 post-exit 真机证据,再把后置条件收紧为稳定回到同一目标商品详情页。
|
||||||
|
|
||||||
## 关联需求与交互
|
## 关联需求与交互
|
||||||
|
|
||||||
- 功能:F-006 单趟流程中的安全退出边界。
|
- 功能:F-006 单趟流程中的安全退出边界。
|
||||||
- 架构/API:复用 `SkuSelectionFlow.exit_sku_panel_safely()`;不改变服务端 API。
|
- 架构/API:复用并收紧 `SkuSelectionFlow.exit_sku_panel_safely()` 与现有 runner;不改变服务端 API,不新建第二套采购流程。
|
||||||
- 依赖:T-103 完成真机规格选择/读价/人眼安全退出验收后才开工。
|
- 依赖:T-103 完成真机规格选择、读价、原始截图和人工安全退出验收后才可开工;T-105 与 T-306 消费本任务结果。
|
||||||
|
- `status` 保持 `TODO`,`needs_device` 与 `needs_human_review` 均保持 true;开始后也必须由人完成真机验收。
|
||||||
|
|
||||||
## 方案
|
## 方案
|
||||||
|
|
||||||
1. 先新增窄取证脚本:只在已验证 PDD 8.17.0 目标规格面板上发送一次 Back,随后采集本机截图/XML/App/设备/goods_id 元数据;不得打开确认页、设置数量或点击任何页面控件。
|
1. 阶段 A 只新增窄取证脚本。人先把手机停在已验证的拼多多 8.17.0、goods_id `937122477375` 目标规格面板;脚本必须先在内存中核验版本、PDD 前台、已知面板结构、目标颜色/尺码选中态与已取证当前价,任一不符时零动作、零产物。
|
||||||
2. 由人确认 post-exit 截图确为 goods_id `937122477375` 商品详情、规格面板已关闭、未进入确认页/外部支付页,并确认截图/XML 对应;`needs_device=true`,agent 保持 DOING。
|
2. 阶段 A 唯一状态改变是一次命名 Android Back。脚本不得发送商品 intent,不得调用任何页面 `click`,不得点击关闭坐标、空白、购买、数量、确认、提交或支付控件;Back 调用前即封存本次机会,超时、响应不明或故障调和均不得重试。
|
||||||
3. 从本项目 post-exit XML 提取只含判据节点的最小 fixture。成功判据必须稳定命中该商品详情的证据绑定正结构;可复用 T-103 的“快要抢光”五层入口链,但必须由新的 post-exit 证据再次验证。
|
3. Back 返回后只读采集 post-exit screenshot/XML/App/设备/goods_id 摘要并原子发布本机证据。此阶段 manifest 只能声明 `human_review_required` 与 `back_attempts=1`,人工确认前绝不能写 `safe_exit=completed`,也不得由 agent 根据“面板消失”自行判定成功。
|
||||||
4. 收紧 `exit_sku_panel_safely()`:版本和前台包正确、节点树仅含 PDD 受控页面、目标商品详情正判据精确唯一并连续稳定;面板仍在、SystemUI/锁屏、PDD 其他页、确认/提交/支付语义、入口缺失/重复或结构漂移均不得记成功。
|
4. 人必须确认 post-exit 截图确为同一 goods_id `937122477375` 商品详情、规格面板已关闭、未进入确认/提交/支付或外部支付页、未创建订单,且截图/XML 对应。只有完成该确认后,阶段 B 才能从本项目 post-exit XML 提取不含个人信息或支付凭据的最小 `product_exit_8_17_0.xml` fixture。
|
||||||
5. Back 最多发送一次。超时或响应不明不重试;故障调和也不能产生第二次 Back。失败不得发布 `safe_exit=completed` manifest。
|
5. T-103 的旧 `product_entry_8_17_0.xml` 只有通用“快要抢光”五层入口链和“免拼购买”,不能单独证明是同一商品。它最多作为新证据中的候选结构,必须由本轮 post-exit 证据重新验证,并与新证据里的同商品唯一正锚共同成立;不得把 Activity、旧 XML、前序项目、OCR、裸坐标、相似文本或其他购买文案当作同商品证明。
|
||||||
6. 复用 T-103 runner/flow,不新建第二套采购流程;可增加 post-exit 本机原始截图和无页面正文摘要,完整 XML 仍只留本机,不上传、Git 或日志。
|
6. 如果新的真实 XML 没有可唯一绑定同一目标商品的正锚,任务开始后保持 `DOING` 并记录等待新证据,不编造 selector、不把通用入口链升级为同商品判据,也不放宽为“任意 PDD 商品页”。
|
||||||
|
7. 阶段 B 只收紧现有 Flow/runner:版本和前台包正确、节点树属于证据绑定的 PDD 受控页面、同一商品详情正判据精确唯一,并连续两次轮询命中后才算退出成功。两次之间发生漂移必须重新计数,超时仍失败。
|
||||||
|
8. 空 hierarchy、SystemUI、锁屏、非 PDD overlay、PDD 其他页、面板仍在、正锚缺失/重复/属性漂移、版本或前台漂移、确认/提交/支付危险语义均不得成功,且不得发布 completed manifest。危险词只能作为保守硬拒绝;没有 T-106/T-107 真机证据时不得把合成反例宣称为真实确认页或支付页分类器。
|
||||||
|
9. 所有正常、失败、超时和 finally 调和路径的 Back 总数都必须小于等于 1;Back 结果不明不重试。只有规格选择、闸门一读价、规格面板截图和严格安全退出全部成功,runner 才能原子发布结果与 `safe_exit=completed`。
|
||||||
|
10. `SkuSelectionRunResult` 必须显式返回规格面板原始 `screenshot_path` 和 UTC-aware `captured_at`。`captured_at` 在截图 RPC 成功附近获取,并把同一个值写入 manifest,禁止在退出后重新猜时间。若本机另存 post-exit 截图,必须使用独立文件名和独立角色;它没有 `SKU_PANEL_GATE_1` 资格,不能成为 `result.screenshot_path`,也不能被 T-306 上传。
|
||||||
|
11. 原始完整 post-exit XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入 Vikunja 或普通日志;runner/CLI 不输出页面正文、设备 serial 或敏感路径。
|
||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- 真机证据记录路径、截图/XML hash、PKG110、Android 16、Wi-Fi/USB、拼多多 8.17.0 和 goods_id;只有人能完成页面对应性与无订单创建确认。
|
- 阶段 A 证据记录 screenshot/XML 路径与 SHA-256、PKG110、Android 16、Wi-Fi/USB、拼多多 8.17.0、goods_id 和 `back_attempts=1`;人工确认前没有 `safe_exit=completed`。
|
||||||
- 离线测试覆盖成功稳定详情页,以及面板未退、SystemUI/锁屏、PDD 其他页、确认/提交/支付页、入口缺失/重复/漂移、版本/前台漂移。
|
- 用新的 `product_exit_8_17_0.xml` 正例替换旧“空 hierarchy 即成功”的 fake;空 hierarchy 必须失败。成功需连续两次正判据,单次命中后漂移必须重置。
|
||||||
- 所有失败分支 Back 总数最多 1;超时不重试;失败不发布 completed manifest。
|
- 离线覆盖面板未退、SystemUI/锁屏、非 PDD overlay、PDD 其他页、正锚缺失/重复/漂移、版本/前台漂移和保守危险语义反例;所有分支 Back 调用总数小于等于 1,失败无 target、manifest 或 staging 残留。
|
||||||
- 静态证明没有数量、确认页导航、提交订单、围栏或支付能力。
|
- runner 成功结果的 `screenshot_path` 精确指向规格面板原始 PNG,`captured_at` 是 UTC-aware 且与 manifest 同值;post-exit 图不能冒充 Gate1。
|
||||||
- client 全量单测、compileall、完整 init、上下文校验与 diff-check 通过。
|
- 静态 API、AST 和导入检查证明取证脚本零 intent/页面 click,T-104 不可达数量、确认页导航、授权、提交围栏、`submit_order_once()` 或任何支付能力。
|
||||||
|
- 只有人能确认真实最终页面为同一商品详情、未进入确认/提交/支付页且未创建订单;agent 完成离线实现后仍保持 `DOING`,不得自行标 `DONE`。
|
||||||
|
- client focused/full unittest、compileall、完整 init、上下文校验、Vikunja 导出检查与 diff-check 全部通过。
|
||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
等待 T-103 完成后认领。
|
### 2026-08-04T13:46:33Z · ila
|
||||||
|
|
||||||
## 执行记录
|
2026-08-04 T-306 预研反向约束 T-104:成功 SkuSelectionRunResult 必须显式携带规格面板原始 screenshot_path 与实际 UTC captured_at;只有规格选择、闸门一读价、截图和严格安全退出全成功才返回。T-306 不枚举目录、不读 manifest 猜文件/时间,post-exit 商品详情截图不得冒充 gate1 证据。
|
||||||
|
|
||||||
(暂无)
|
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
- T-104 必须等待 T-103 完成人工真机验收后才可转 `DOING`。本任务先取证、后写退出成功判据;不得从
|
- T-104 必须等待 T-103 完成人工真机验收后才可转 `DOING`。本任务先取证、后写退出成功判据;不得从
|
||||||
当前实现、前序项目、旧 XML、Activity 名或推理直接声明商品详情页。
|
当前实现、前序项目、旧 XML、Activity 名或推理直接声明商品详情页。
|
||||||
- 取证动作只允许在已证明为拼多多 8.17.0、goods_id `937122477375` 的目标规格面板上发送一次
|
- 阶段 A 取证脚本必须先在内存中核验拼多多 8.17.0、goods_id `937122477375` 的已知目标规格面板、
|
||||||
Android Back,随后只读采集截图/XML/App/设备摘要。不得点击关闭坐标、空白处、购买、数量、确认、
|
目标颜色/尺码选中态和已取证当前价。唯一状态改变是一次命名 Android Back;不得发送 intent,不得调用
|
||||||
提交或支付控件,也不得在 Back 失败或结果不明时重试。
|
页面 `click`,不得点击关闭坐标、空白、购买、数量、确认、提交或支付控件。Back 调用前即封存机会,
|
||||||
- “规格面板已消失”不是退出成功。成功必须由本项目 post-exit 真机证据证明:版本和前台包正确、节点树
|
超时、响应不明和 finally 调和均不得重试。
|
||||||
仅属于 PDD 受控页面、同一目标商品详情正判据精确唯一并连续稳定;SystemUI/锁屏、PDD 其他页面、
|
- 阶段 A 在 Back 后只能只读采集 post-exit screenshot/XML/App/设备摘要并原子发布本机证据;manifest
|
||||||
确认页、提交页、外部支付页、入口缺失/重复或结构漂移一律停止且不得发布 completed manifest。
|
只能声明 `human_review_required` 与 `back_attempts=1`。人工确认前绝不能写 `safe_exit=completed`,
|
||||||
- 可以复用 T-103 已取证的“快要抢光”五层入口链作为候选结构,但必须在新的 post-exit 截图/XML 上
|
agent 也不得把“面板消失”或“前台包仍是 PDD”自行解释为安全退出。
|
||||||
重新验证并由人确认页面对应性;不得使用相似文本、包含/前缀、OCR、裸坐标或其他购买文案兜底。
|
- 人确认 post-exit 截图确为同一目标商品详情、面板已关闭、未进入确认/提交/支付或外部支付页、未创建
|
||||||
|
订单,且截图/XML 对应后,才允许从本项目 XML 提取最小 `product_exit_8_17_0.xml` fixture。
|
||||||
|
- 旧 `product_entry_8_17_0.xml` 只有通用“快要抢光”五层入口链和“免拼购买”,不能单独证明同一
|
||||||
|
商品;它最多是新证据中的候选结构,必须与新证据的同商品唯一正锚共同成立。若真实 XML 没有唯一
|
||||||
|
正锚,任务开始后保持 `DOING` 等待新证据,不臆造 selector、不放宽为任意 PDD 商品页。
|
||||||
|
- “规格面板已消失”不是退出成功。阶段 B 成功必须由本项目 post-exit 证据证明:版本和前台包正确、
|
||||||
|
节点树属于证据绑定的 PDD 受控页面、同一目标商品详情正判据精确唯一,并连续两次轮询命中;中间漂移
|
||||||
|
必须重新计数。空 hierarchy、SystemUI/锁屏、非 PDD overlay、PDD 其他页、面板仍在、正锚缺失/
|
||||||
|
重复/属性漂移、版本或前台漂移一律失败且不得发布 completed manifest。
|
||||||
|
- 确认/提交/支付文字只能作为保守硬拒绝。T-106/T-107 真机取证前,不得把合成反例宣称为真实确认页、
|
||||||
|
提交页或支付页分类器;未知页面只能 fail closed,不能加同义、包含、OCR、裸坐标或 Activity 兜底。
|
||||||
- 只收紧现有 `SkuSelectionFlow` 与 runner 的退出后置条件,不新建第二套采购流程,不新增通用 `click`、
|
- 只收紧现有 `SkuSelectionFlow` 与 runner 的退出后置条件,不新建第二套采购流程,不新增通用 `click`、
|
||||||
数量、确认页导航、授权、提交围栏、创建待付款订单或支付能力。第一趟与任何真实提交函数继续静态隔离。
|
数量、确认页导航、授权、提交围栏、创建待付款订单或支付能力。第一趟与任何真实提交函数继续静态隔离。
|
||||||
- 原始完整 XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入
|
- 原始完整 XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入
|
||||||
Vikunja 或日志。Git 中最小 fixture 只能保留退出判据所需结构,并由人确认不含地址、手机号或支付凭据。
|
Vikunja 或日志。Git 中最小 fixture 只能保留退出判据所需结构,并由人确认不含地址、手机号或支付凭据。
|
||||||
|
- 所有正常、失败、超时和 finally 调和路径的 Back 总数都必须小于等于 1;Back 结果不明不重试。
|
||||||
|
只有规格选择、闸门一读价、规格面板截图和严格安全退出全部成功,runner 才能原子发布结果与
|
||||||
|
`safe_exit=completed`;旧的空 hierarchy success 测试必须改成失败反例。
|
||||||
|
- 供 T-306 消费的 `SkuSelectionRunResult` 必须显式携带规格面板原始 `screenshot_path` 与截图 RPC
|
||||||
|
成功附近取得的 UTC-aware `captured_at`,并把同一个时间值写入 manifest。不得枚举目录或读取
|
||||||
|
manifest 猜文件/时间;post-exit 商品详情截图必须使用独立文件名和角色,绝不能冒充 Gate1 证据。
|
||||||
- `needs_device: true`:agent 不得自行标 `DONE`。只有人确认 post-exit 截图为同一目标商品详情、面板
|
- `needs_device: true`:agent 不得自行标 `DONE`。只有人确认 post-exit 截图为同一目标商品详情、面板
|
||||||
已关闭、未进入确认/提交/支付页、未创建订单,且截图/XML 对应后,任务才可完成。
|
已关闭、未进入确认/提交/支付页、未创建订单,且截图/XML 对应后,任务才可完成。
|
||||||
- 本任务不实现、不调用 `set_quantity_and_readback()`、`go_to_order_confirm()`、
|
- 本任务不实现、不调用 `set_quantity_and_readback()`、`go_to_order_confirm()`、
|
||||||
`create_submission_fence()`、`submit_order_once()`、支付、免密支付、先用后付或任何扣款能力;既有
|
`create_submission_fence()`、`submit_order_once()`、支付、免密支付、先用后付或任何扣款能力;既有
|
||||||
三道价格闸门、服务端围栏、只点一次且不重试的约束不得放宽。
|
三道价格闸门、服务端围栏、只点一次且不重试的约束不得放宽。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
id: T-105
|
||||||
|
title: 数量设置、读回与闸门二
|
||||||
|
phase: 1
|
||||||
|
deps: [T-104]
|
||||||
|
status: TODO
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 40
|
||||||
|
context_ref: 8c50e15
|
||||||
|
work_branch: task/t-105-quantity-gate2
|
||||||
|
needs_device: true
|
||||||
|
needs_human_review: true
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-105.md
|
||||||
|
- client/src/cmbuyer_client/pdd/quantity_gate2_spike.py
|
||||||
|
- client/src/cmbuyer_client/pdd/quantity_gate2.py
|
||||||
|
- client/src/cmbuyer_client/pdd/quantity_gate2_runner.py
|
||||||
|
- client/tests/pdd/test_quantity_gate2.py
|
||||||
|
- client/tests/pdd/fixtures/quantity_gate2_initial_8_17_0.xml
|
||||||
|
- client/tests/pdd/fixtures/quantity_gate2_target_8_17_0.xml
|
||||||
|
- client/scripts/capture_quantity_gate2_spike.py
|
||||||
|
- client/scripts/run_t105_quantity_gate2.py
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=40 synced=2026-08-04T13:59:13Z sha256=86e0079c5db57033818f75763d3e9012764e11f805a1ab12cafbe58a8ba9a526 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-104 将把规格选择、闸门一读价、原始截图和安全退出固化为受控能力。T-105 先对当前真机数量控件和读回位置取证,再实现规格面板内的数量设置、精确读回与闸门二;不进入确认页、不接触最终提交。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-010、F-015。
|
||||||
|
- 用户故事:US-004、US-005。
|
||||||
|
- 依赖:T-104;T-103 由其传递满足。
|
||||||
|
- 后续消费者:T-106、T-305。
|
||||||
|
- 本任务需要真机与人工证据确认。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 阶段一只读取证:人分别准备初始数量与目标数量状态,脚本只采集 screenshot/XML/App/设备摘要;记录 PDD 版本、goods_id、规格、目标数量、路径和 hash。人工确认数量控件、读回位置和页面对应性前,不写点击判据。
|
||||||
|
2. 阶段二从本项目证据提取最小 fixture,固化数量显示、增减控件、选中规格和价格节点的精确唯一结构;版本/包/面板/规格/控件任一失配都零点击。
|
||||||
|
3. QuantityGate2Flow 接受 T-104 Gate1Observation、目标数量和最高总价。数量必须为服务端任务正整数,不接受浮点、字符串猜测或超上限值。
|
||||||
|
4. 每次数量修改只点击证据证明的唯一控件一次,并等待明确后置条件;超时或结果不明不得重试该次点击。达到目标前每一步都重新定位和读回,不使用裸坐标或缓存节点。
|
||||||
|
5. 闸门二重新读取颜色、尺码、数量和规格面板单价;颜色/尺码必须等于目标,数量精确等于请求,gate2 单价逐字规范化后等于 gate1 单价,并用十进制运算确认单价×数量不超最高总价。
|
||||||
|
6. 成功结果是不可变 Gate2Observation,包含请求/实际规格、quantity_read、gate1/gate2_unit_price、max_total_price、规格面板原始 screenshot_path 与 UTC captured_at。
|
||||||
|
7. 失败、价格不可读/多候选、规格漂移、数量不符或超价都安全停止并使用 T-104 一次性安全退出;Back 结果不明不重试。
|
||||||
|
8. 能力与 T-103/T-104 分层:T-103 不能反向导入本任务;本任务不暴露确认页导航、通用 click、最终提交控件、围栏或付款能力。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 先证据后实现,执行记录包含两态截图/XML/hash、版本、设备、goods_id 与人工确认。
|
||||||
|
- 离线覆盖数量边界、每步单击一次、后置条件、超时不重试、节点缺失/重复、SystemUI/锁屏/版本漂移。
|
||||||
|
- 覆盖闸门二规格不变、价格严格相等、Decimal 总价、数量读回及所有失败分支零确认页动作。
|
||||||
|
- 静态证明 T-103 不导入数量,本任务不可达确认页、围栏、submit_order_once 或付款。
|
||||||
|
- 真机人工确认目标数量、规格、两次价格、截图、一次安全退出,且未进入确认/提交/支付页、未创建订单。
|
||||||
|
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 保持 DOING 等人工验收。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T13:58:34Z · ila
|
||||||
|
|
||||||
|
2026-08-04 预研定值:T-105 依赖 T-104 的一次安全退出,严格分“数量两态只读取证”和“人工确认后实现”两个门禁;闸门二输出显式 screenshot_path/captured_at,且不可达确认页、提交或付款。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 必须先由人在拼多多 8.17.0 的目标规格面板分别准备初始数量与目标数量状态,只读采集并人工确认
|
||||||
|
screenshot/XML/版本/goods_id/规格/数量的对应性,之后才允许从本项目证据编写数量判据和点击代码。
|
||||||
|
不得从前序项目、旧 XML、控件常识、OCR、裸坐标或推理直接实现。
|
||||||
|
- 数量控件、读回位置、规格选中态和两次价格都必须证据绑定且精确唯一。每次修改数量最多点击已批准
|
||||||
|
控件一次并等待明确后置条件;超时或结果不明不得重试同一次点击,不得缓存节点跨状态继续操作。
|
||||||
|
- 闸门二必须重新核对颜色/尺码未漂移、数量精确等于任务、gate2 单价严格等于 gate1 单价,并以
|
||||||
|
十进制运算验证单价乘数量不超最高总价。价格只从规格面板读取,不用详情页或其他数字兜底。
|
||||||
|
- 只有规格、数量、价格、总价上限和原始截图全部通过后才返回不可变 Gate2Observation;它必须显式
|
||||||
|
携带 `screenshot_path` 与 UTC `captured_at`,不得让后续任务枚举目录或读取 manifest 猜证据。
|
||||||
|
- 任一判据失败、规格漂移、数量不符、价格不可读/多候选/变化或超价都停止前进,只能调用 T-104
|
||||||
|
已验证的一次性安全退出;Back 超时或结果不明不重试,不把“面板消失”当作退出成功。
|
||||||
|
- T-103/T-104 不得反向导入本任务。本任务不进入确认页、不观察或获取最终提交控件,不实现通用 click、
|
||||||
|
事件、证据上传、围栏或结果接口;不编写或引用点击“提交订单”的代码,不编写任何付款能力。
|
||||||
|
- `needs_device: true`:agent 完成离线实现后仍保持 DOING。只有人确认真机数量、规格、两次价格、
|
||||||
|
截图和安全退出,且未进入确认/提交/支付页、未创建订单,才可完成任务。
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
id: T-106
|
||||||
|
title: 确认页、闸门三与返回路径真机取证
|
||||||
|
phase: 1
|
||||||
|
deps: [T-105]
|
||||||
|
status: TODO
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 41
|
||||||
|
context_ref: 0144054
|
||||||
|
work_branch: task/t-106-confirm-evidence
|
||||||
|
needs_device: true
|
||||||
|
needs_human_review: true
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-106.md
|
||||||
|
- client/src/cmbuyer_client/pdd/order_confirm_spike.py
|
||||||
|
- client/tests/pdd/test_order_confirm_spike.py
|
||||||
|
- client/scripts/capture_order_confirm_spike.py
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=41 synced=2026-08-04T14:02:41Z sha256=1b7fda4d3b4951f20504d45b88676fda9271f1aaeaacb67b0674e4a6357ca3cc -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-105 验证规格面板数量与闸门二后,下一步会进入更高风险的订单确认页。T-106 只由人手工准备确认页、最终提交控件可见态和一次返回后的页面,再用完全只读脚本采集真机事实;本任务不自动导航、不写确认页判据。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-011、F-016。
|
||||||
|
- 用户故事:US-004、US-005。
|
||||||
|
- 依赖:T-105。
|
||||||
|
- 后续消费者:T-107。
|
||||||
|
- 需要真机和人工页面对应性、安全结果确认。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 提供只读取证 CLI,唯一能力为核对指定设备/App 版本、app_current、screenshot 与 compressed=false XML、hash 和原子 manifest;静态禁止 click、press/back、swipe、input、intent 和任意 UI 修改。
|
||||||
|
2. 人先手工完成已取证规格/数量并进入确认页,停在规格、数量、应付总额及最终“提交订单”控件可见的安全状态;绝不能点击最终提交控件。
|
||||||
|
3. 只读采集确认页主体与最终控件状态,记录准确规格、数量、应付总额、控件精确文案/匹配数/启用态;这些只是人工观察,不转成生产 selector。
|
||||||
|
4. 随后由人只按一次返回并停手,再用同一只读取证 CLI 采集返回后的页面。Back 结果不明不再次按;记录页面身份与是否回到证据允许的安全页面。
|
||||||
|
5. 三组证据都记录设备型号、Android、USB/WiFi、serial、PDD 版本、goods_id、截图/XML 路径和 SHA-256;完整 XML 仅留本机。
|
||||||
|
6. 如果人误触最终提交、产生待付款订单、进入支付/安全验证/外部页面或页面结果不明,立即停止、不重试、不继续取证,并如实记录路径为不可用;绝不操作付款。
|
||||||
|
7. 当前规格面板中出现“提交订单 ¥...”不能仅凭文案推断为确认页导航或最终控件;T-107 只能消费本任务由人确认的结构事实。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 静态证明 spike/CLI 没有任何 UI 修改 API;mock 覆盖设备/版本/包/截图/XML/超时/原子发布/异常脱敏。
|
||||||
|
- 人工确认三组截图/XML对应:确认页规格数量金额、最终控件可见态、一次返回后页面。
|
||||||
|
- 执行记录列出准确控件文案与匹配数,但不把坐标/selector 作为结论;完整 XML、地址、手机号、支付信息不进 Git/Vikunja/日志。
|
||||||
|
- 明确记录未点击最终提交、未创建订单、未进入支付;若相反则任务保持 DOING/受阻且不自动处置。
|
||||||
|
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 不得自行标 DONE。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T14:01:13Z · ila
|
||||||
|
|
||||||
|
2026-08-04 预研定值:T-106 与 T-107 分开,严格只做人工准备页面后的只读取证;采集代码零 UI 修改,最终提交控件不点击,一次返回也由人执行且结果不明不重试。
|
||||||
|
|
||||||
|
### 2026-08-04T14:02:39Z · ila
|
||||||
|
|
||||||
|
2026-08-04 主审补齐导航来源事实:T-106 必须采集四态——闸门二后的确认页导航前状态、确认页闸门三、最终提交控件可见态、人工一次返回后状态;人记录进入确认页时实际单击的精确控件/容器与匹配数,agent 仍只读取证。缺少来源态时 T-107 不得从目标页反推入口。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 本任务只实现和运行只读取证能力:设备/App 摘要、`app_current`、screenshot、`compressed=false` XML、
|
||||||
|
hash 与原子 manifest。代码中不得出现 click、press/back、swipe、input、intent 或其他 UI 修改 API,
|
||||||
|
不得自动进入或退出确认页,也不得从前序项目、旧 XML 或推理写生产判据。
|
||||||
|
- 确认页、最终提交控件可见态和一次返回后的页面都由人手工准备;人不得点击最终“提交订单”控件。
|
||||||
|
一次返回结果不明时不得再次按 Back。agent 只采集已准备状态,不远程替人执行这些动作。
|
||||||
|
- 人工验收必须明确规格、数量、应付总额、最终控件精确文案/匹配数/启用态和返回后的页面身份;
|
||||||
|
本任务只记录真机事实,不把文案、坐标、节点或 selector 批准为生产能力。
|
||||||
|
- 取证必须包含四个稳定状态:闸门二通过后的**确认页导航前**页面、确认页闸门三、最终提交控件
|
||||||
|
可见态、一次返回后页面。人还需记录从第一态进入第二态时实际只点击的精确控件文本/容器及当时
|
||||||
|
匹配数量;agent 不执行该点击。缺少来源态时,T-107 不得仅凭“已到确认页”反推导航控件。
|
||||||
|
- 规格面板中出现的“提交订单 ¥...”不得凭文案推断为确认页导航或最终提交控件。任何确认页入口、
|
||||||
|
金额节点、最终控件和返回后置条件都必须等 T-107 从本任务已确认的证据中最小化固化。
|
||||||
|
- 若人工误触最终提交、产生待付款订单、进入支付/安全验证/外部页面或结果不明,立即停止、不重试、
|
||||||
|
不继续取证并如实记录;绝不点击支付、免密支付、先用后付或任何扣款控件。
|
||||||
|
- 原始 screenshot/XML 只留 `%LOCALAPPDATA%\cmbuyer\artifacts\T-106\...`,不提交 Git、不上传采购服务,
|
||||||
|
不把完整 XML、地址、手机号、支付信息、页面全文或本机路径写入普通日志/Vikunja/fixture。
|
||||||
|
- `needs_device: true`:agent 不得自行标 DONE。只有人确认四组证据对应、一次返回安全、未点击最终提交、
|
||||||
|
未创建订单且未进入支付相关页面,任务才可完成。
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
id: T-107
|
||||||
|
title: 固化确认页、闸门三与提交控件只读判据
|
||||||
|
phase: 1
|
||||||
|
deps: [T-106]
|
||||||
|
status: TODO
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 42
|
||||||
|
context_ref: 83b244f
|
||||||
|
work_branch: task/t-107-confirm-gate3
|
||||||
|
needs_device: true
|
||||||
|
needs_human_review: true
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-107.md
|
||||||
|
- client/src/cmbuyer_client/pdd/order_confirm.py
|
||||||
|
- client/src/cmbuyer_client/pdd/order_confirm_runner.py
|
||||||
|
- client/tests/pdd/test_order_confirm.py
|
||||||
|
- client/tests/pdd/fixtures/order_confirm_source_8_17_0.xml
|
||||||
|
- client/tests/pdd/fixtures/order_confirm_gate3_8_17_0.xml
|
||||||
|
- client/tests/pdd/fixtures/order_submit_control_8_17_0.xml
|
||||||
|
- client/tests/pdd/fixtures/order_confirm_exit_8_17_0.xml
|
||||||
|
- client/scripts/run_t107_order_confirm_dry_run.py
|
||||||
|
- docs/api.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=42 synced=2026-08-04T14:05:17Z sha256=56bb3d2575e60ac2ede9ba9c86178f5eefc4470d06b973d9f701fbc8c91635da -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-106 将形成确认页导航前、闸门三、最终提交控件可见态和一次返回后的四组真机证据。T-107 只从这些已由人确认的本项目事实固化一次性确认页导航、闸门三读取、最终控件纯观察与一次安全返回;绝不点击最终提交。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-011、F-016、F-017 的围栏前只读部分。
|
||||||
|
- 用户故事:US-004、US-005。
|
||||||
|
- 依赖:T-106。
|
||||||
|
- 后续消费者:T-210、T-305、T-208。
|
||||||
|
- 需要真机与人工安全验收。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 从 T-106 四态证据提取只含必要节点且去隐私的最小 fixture;页面版本、前台包、来源页面、导航控件或目标页面任一漂移都零点击。
|
||||||
|
2. go_to_order_confirm 只允许点击 T-106 人工证明的导航前状态中精确唯一的已批准控件一次;点击后等待确认页精确后置条件。超时或结果不明不重试,不使用相似文案、OCR、裸坐标或其他购买按钮兜底。
|
||||||
|
3. observe_gate3 只从确认页读取颜色、尺码、数量和应付总额;必须等于目标规格/数量且金额为规范十进制、不超最高总价。不得从详情页、规格面板或其他数字补值。
|
||||||
|
4. 最终“提交订单”控件由纯 XML observer 只读:记录精确文本、匹配数、启用态和是否存在唯一可点击祖先,但不返回 selector、坐标、节点、祖先句柄或任何可点击对象;observer 不持有 device。
|
||||||
|
5. Gate3Observation 包含规格、数量、确认页金额、最高总价、submit_control_text/match_count/enabled、确认页原始 screenshot_path 和 UTC captured_at。
|
||||||
|
6. 观察完成后 exit_order_confirm_safely 只发送一次 Android Back,并稳定验证 T-106 post-back 正判据;超时/结果不明不重试,SystemUI/锁屏/其他 PDD 页/外部页都不是成功。
|
||||||
|
7. runner 是永久围栏前 dry-run capability,不接受 allow_submit/dry_run=False 开关;composition/import 静态不可达 fence、SubmissionPermit、submit_order_once、payment。
|
||||||
|
8. T-107 不上传证据、不定义 T-205 事件码、不申请围栏、不创建待付款订单。任何失败只停止并等待人工,不自动回 DRAFT/释放/转领。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- fixture 正反例覆盖来源页、唯一导航控件、确认页、规格/数量/金额、最终控件 0/1/2 个、一次返回后置条件与版本漂移。
|
||||||
|
- 动作轨迹证明成功路径仅一个已批准导航点击和一次 Back;导航结果不明与 Back 结果不明均不重试。
|
||||||
|
- Gate3 金额用 Decimal,规格/数量/上限严格;最终控件观察 DTO 不含 selector/坐标/节点/可点击对象。
|
||||||
|
- AST/import closure 证明没有 fence/result/SubmissionPermit/click_permitted/submit_order_once/payment,最终控件 observer 无 device/click。
|
||||||
|
- 真机人工确认闸门三、submit match count=1、一次安全返回、未点击最终提交、未创建订单/进入支付。
|
||||||
|
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 保持 DOING 等人工验收。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T14:04:47Z · ila
|
||||||
|
|
||||||
|
2026-08-04 预研定值:T-107 只消费 T-106 四态证据;确认页导航最多一次,最终提交控件由无 device 的纯 observer 读取且 DTO 不暴露节点/坐标/selector,随后最多一次 Back 并验证返回后置条件。全路径静态不可达提交与付款。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 任何确认页来源判据、导航控件、目标页结构、闸门三节点、最终提交控件和返回后置条件都只能来自
|
||||||
|
T-106 已由人确认的四态真机证据,并与拼多多 8.17.0/goods_id 绑定。不得从前序项目、旧 XML、
|
||||||
|
Activity 名、相似文案、OCR、裸坐标或推理补判据。
|
||||||
|
- `go_to_order_confirm()` 只允许在精确来源页点击 T-106 已证明的唯一导航控件一次;超时或结果不明
|
||||||
|
不重试,不尝试其他购买按钮,不把规格面板中任意“提交订单 ¥...”文本直接当成已批准入口。
|
||||||
|
- 闸门三只从确认页读取颜色、尺码、数量和应付总额;必须严格等于目标规格/数量且金额为规范十进制、
|
||||||
|
不超最高总价。不得用详情页、规格面板或其他位置数字凑齐,不得使用浮点数。
|
||||||
|
- 最终“提交订单”控件只能由不持有 device/click 能力的纯 XML observer 只读。结果可含精确文本、
|
||||||
|
匹配数、启用态和“唯一可点击祖先是否存在”的布尔值,但不得返回 selector、坐标、节点、祖先句柄
|
||||||
|
或任何可点击对象;runner 不得对该控件或祖先调用 click。
|
||||||
|
- 观察完成后只允许一次 Android Back,并必须命中 T-106 固化的稳定 post-back 正判据;超时或结果
|
||||||
|
不明不重试。SystemUI/锁屏、其他 PDD 页面、外部支付/安全验证页或仅“确认页消失”都不是退出成功。
|
||||||
|
- Runner 是永久围栏前 dry-run capability,不接受 `allow_submit`、`dry_run=False` 等开关;构造和
|
||||||
|
import 闭包不得引用 submission fence、SubmissionPermit、`click_permitted`、`submit_order_once()`、
|
||||||
|
结果调和、支付、免密支付、先用后付或任何扣款能力。
|
||||||
|
- T-107 不上传证据、不定义 T-205 事件、不申请围栏、不创建待付款订单。任何失败只停止并等待人工,
|
||||||
|
不自动释放 claim/授权、不回 DRAFT、不转领或重试页面动作。
|
||||||
|
- `needs_device: true`:agent 不得自行标 DONE。只有人确认闸门三、最终控件 match count=1、一次安全
|
||||||
|
返回,且未点击最终提交、未创建订单、未进入支付相关页面,任务才可完成。
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
id: T-205
|
||||||
|
title: purchase attempt、事件与执行摘要 API
|
||||||
|
phase: 2
|
||||||
|
deps: [T-210]
|
||||||
|
status: TODO
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 35
|
||||||
|
context_ref: cd47d0c
|
||||||
|
work_branch: task/t-205-attempt-events
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-205.md
|
||||||
|
- admin/migrations/00007_attempt_events.sql
|
||||||
|
- admin/internal/migrations/migrations_test.go
|
||||||
|
- admin/internal/attempts/**
|
||||||
|
- admin/internal/taskclaim/**
|
||||||
|
- admin/internal/server/**
|
||||||
|
- admin/cmd/server/**
|
||||||
|
- admin/README.md
|
||||||
|
- docs/api.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=35 synced=2026-08-04T14:41:47Z sha256=870653415736bd7756b058467f0d6ea0114a78dba1c97ed62115067e07c32e8a -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-302 负责创建并租赁唯一 purchase attempt,T-103 提供已取证的规格选择与闸门一能力。后续单趟执行需要把每个安全步骤以最小结构化事件追加到采购服务,并在围栏前失败时形成不可覆盖的执行摘要。T-205 只记录既有 claim/attempt 的事实和安全失败;不创建 attempt、不释放授权、不建立提交围栏、不点击或付款。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-006、F-007。
|
||||||
|
- 用户故事:US-003、US-004、US-005。
|
||||||
|
- 依赖:T-210;T-107/T-204/T-302/T-301 由 T-210 传递满足。
|
||||||
|
- 后续消费者:T-206 状态视图、T-307 客户端 attempt sink、T-305 围栏前 dry-run、T-208 提交围栏。
|
||||||
|
- API:`POST /api/v1/tasks/{id}/events`、`POST /api/v1/purchase-attempts/{aid}/fail`。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 新增 `00007_attempt_events.sql`,保存 append-only attempt events、批次幂等请求和必要执行摘要;事件以 attempt 内单调序号、事件 id/批次 id、固定 step/outcome/reason、服务端接收时间及允许的最小字段组成。数据库约束禁止更新/覆盖旧序号、跨 task/attempt 或围栏后继续写。
|
||||||
|
2. 复用 T-302 的统一 claim validator:每个请求都绑定当前设备 Bearer、session、task、attempt、generation、claim token 和未过期租约;认证在 body 前,业务事务在线性化位置再次验证设备 ACTIVE、claim/authorization/attempt 归属与状态。旧 generation/token、跨设备/会话、租约或授权过期均不能写事件。
|
||||||
|
3. 事件 API 只接受有界批次和严格 JSON。每个事件的 step/outcome/reason 来自版本化白名单,不接收自由页面文本、XML、地址、手机号、路径、token、选择器、坐标或动作脚本。金额为规范正十进制字符串,数量为正整数;未知字段、额外 JSON、序号缺口/倒退/重复异载荷全部拒绝。
|
||||||
|
4. 同一 batch id 同载荷在响应丢失/服务重启后重放原结果;同键异载荷冲突。一个事务内校验整批连续性、步骤状态机和 summary 兼容性后全批追加,任一事件非法则零写入。
|
||||||
|
5. 三条成功步骤固定为:`GATE_1_PASSED` 只携带 color、size、unit_price 和 `SKU_PANEL_GATE_1` asset id;`GATE_2_PASSED` 只携带 color、size、quantity、unit_price 和 `SKU_PANEL_GATE_2` asset id;`GATE_3_PASSED` 只携带 color、size、quantity、confirm_amount、submit_control_text、submit_control_match_count、submit_control_enabled 和 `ORDER_CONFIRM_GATE_3` asset id。每个 asset 必须属于同一 task/attempt/device/generation 且 kind 精确匹配;缺失、错 kind、跨归属或证据未明确成功时整批零写入。
|
||||||
|
6. 第一个明确的执行开始事件只允许把 attempt `CLAIMED→ORDERING`、task `CLAIMED→ORDERING`;不得隐式续租。后续事件只在 ORDERING 中追加。规格、两次规格面板价格、数量读回、确认页金额仅由对应固定成功 step 写入 summary,不能由客户端直接覆盖已有摘要。
|
||||||
|
7. 围栏前 fail API 只接受固定 reason:`DEVICE_UNAVAILABLE`、`APP_VERSION_MISMATCH`、`PRODUCT_OPEN_FAILED`、`SKU_PANEL_UNAVAILABLE`、`SKU_SELECTION_MISMATCH`、`GATE_1_PRICE_INVALID`、`QUANTITY_MISMATCH`、`GATE_2_PRICE_INVALID`、`ORDER_CONFIRM_UNAVAILABLE`、`GATE_3_MISMATCH`、`SUBMIT_CONTROL_NOT_UNIQUE`、`CLAIM_NOT_CURRENT`、`AUTHORIZATION_EXPIRED`、`EVIDENCE_UPLOAD_UNRESOLVED`、`PAGE_RESULT_UNKNOWN`、`STEP_TIMEOUT`、`DRY_RUN_COMPLETED_BEFORE_FENCE`,以及已记录的最后安全步骤。幂等地把当前 attempt 置 FAILED、task 置 NEEDS_MANUAL,并写终止事件/finished_at。authorization 和 claim 保持占用,等待 T-207 人工检查后显式关闭;失败请求不自动回 DRAFT、不释放、不转领。`EXTERNAL_PAYMENT_HANDOFF`、`SECURITY_CHALLENGE` 与 `UNKNOWN` 是围栏后 result observation,不得作为围栏前失败码。
|
||||||
|
8. 已失败、已围栏、存在 order_submission、claim/授权/租约不再有效时拒绝新事件或新的失败结果。围栏相关状态只能由 T-208 创建,T-205 不读取或返回 `click_permitted`。
|
||||||
|
9. 原始截图继续走 T-204/T-306;事件只引用服务端已有的受控 evidence asset id(如需要),不得上传文件或枚举本地证据目录。完整 XML 永不上传。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- Migration 覆盖升级/重开、外键/唯一/append-only 约束、既有事实与安全降级保护。
|
||||||
|
- 覆盖严格 HTTP/JSON、body 前认证、claim/session/generation/token/lease/authorization 校验和跨设备拒绝。
|
||||||
|
- 覆盖批次原子性、序号连续、并发追加、相同 key 重放、异载荷冲突、服务重启恢复。
|
||||||
|
- 覆盖步骤状态机及 summary 单向写入;闸门金额保持十进制字符串,冲突值不得覆盖。
|
||||||
|
- 覆盖 `CLAIMED→ORDERING` 唯一转换、固定失败到 NEEDS_MANUAL、重复失败幂等、失败后无事件、授权/claim 不释放。
|
||||||
|
- 静态检查确认没有 selector/coordinate/free script、claim 创建/续租、围栏、提交订单点击、付款或自动重试能力。
|
||||||
|
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文校验与 diff-check 全部通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T13:44:42Z · ila
|
||||||
|
|
||||||
|
2026-08-04 与 T-302 恢复契约定值:第一个执行开始事件在同一事务把 attempt/task 从 CLAIMED 转为 ORDERING,并把 task.version 恰好递增一次;后续事件不再递增。T-302 同会话恢复只接受 ORDERING 且当前版本等于领取快照版本+1,其他版本漂移转人工。
|
||||||
|
|
||||||
|
### 2026-08-04T14:39:03Z · ila
|
||||||
|
|
||||||
|
2026-08-04 契约收紧:T-205 改为依赖 T-210、迁移 00007;冻结 GATE_1/2/3_PASSED 的完整字段与对应 evidence kind,并冻结围栏前失败码和 DRY_RUN_COMPLETED_BEFORE_FENCE。围栏后 observation 不得伪装成围栏前失败。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- T-205 只向 T-302 已创建且由当前设备/会话持有的 attempt 追加固定结构事件和摘要;不得创建
|
||||||
|
purchase attempt、领取任务、续租、签发或释放授权。所有权、generation、claim token、租约、
|
||||||
|
authorization 与状态任一不匹配时零业务写入。
|
||||||
|
- 事件必须 append-only、attempt 内严格连续且批次全有或全无;重放只能返回原结果。任何旧序号、
|
||||||
|
缺口、异载荷、跨 attempt、跨设备或并发冲突不得覆盖、跳过或重排既有事实。
|
||||||
|
- 只接收版本化白名单中的 step/outcome/reason 及该步骤必需的规范金额、正整数数量和受控引用。
|
||||||
|
不接收自由页面文本、完整 XML、地址、手机号、本机路径、token、动作脚本、选择器或坐标。
|
||||||
|
- 三条成功步骤只能是 `GATE_1_PASSED`、`GATE_2_PASSED`、`GATE_3_PASSED`,且分别引用同一
|
||||||
|
task/attempt/device/generation 下精确 kind 为 `SKU_PANEL_GATE_1`、`SKU_PANEL_GATE_2`、
|
||||||
|
`ORDER_CONFIRM_GATE_3` 的已成功 asset。缺证据、错 kind、跨归属或上传结果不明时零事件写入。
|
||||||
|
- 实际规格、闸门一/二单价、数量读回和确认页金额只能由对应固定成功步骤首次写入执行摘要;后续
|
||||||
|
冲突值必须拒绝,不能最后写入者覆盖。闸门三还必须首次固定提交控件精确文案、匹配数和启用态;
|
||||||
|
金额始终是十进制字符串,不用浮点数。
|
||||||
|
- 第一个执行开始事件必须在同一事务中把 attempt/task 从 `CLAIMED` 转为 `ORDERING`,并把 task
|
||||||
|
`version` **恰好递增一次**;后续事件不得继续递增。T-302 的同会话恢复只可把 ORDERING 解释为
|
||||||
|
领取快照版本加一,其他版本漂移必须转人工,不能用“状态是 ORDERING”跳过版本一致性。
|
||||||
|
- 围栏前失败只把 attempt 置 `FAILED`、task 置 `NEEDS_MANUAL` 并留下终止事实;authorization 与
|
||||||
|
claim 保持占用,等待 T-207 人工检查。不得自动回 `DRAFT`、释放、重新授权、转领或重试页面动作。
|
||||||
|
- 围栏前 reason 必须来自任务方案冻结的精确白名单,包含 `DRY_RUN_COMPLETED_BEFORE_FENCE`;
|
||||||
|
`EXTERNAL_PAYMENT_HANDOFF`、`SECURITY_CHALLENGE` 与 `UNKNOWN` 只属于围栏后 result observation,
|
||||||
|
不得伪装成可恢复的围栏前失败。
|
||||||
|
- 不实现或引用 submission fence、`click_permitted`、订单结果、提交订单点击。不得编写支付、免密
|
||||||
|
支付、先用后付或任何扣款控件代码;第一趟试选/围栏前路径不得可达下单函数。
|
||||||
|
- 不在 T-210 三种证据 kind 之外继续扩展截图,不上传 XML/manifest/目录,不接触真机页面判据或
|
||||||
|
client 代码。
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
id: T-206
|
||||||
|
title: 单趟状态详情与安全主动作
|
||||||
|
phase: 2
|
||||||
|
deps: [T-205]
|
||||||
|
status: TODO
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 45
|
||||||
|
context_ref: cd47d0c
|
||||||
|
work_branch: task/t-206-status-safe-actions
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-206.md
|
||||||
|
- admin/internal/taskdetail/**
|
||||||
|
- admin/internal/server/task_detail.go
|
||||||
|
- admin/internal/server/task_detail_test.go
|
||||||
|
- admin/internal/transport/webui/webui.go
|
||||||
|
- admin/internal/transport/webui/templates/task-detail.html
|
||||||
|
- docs/routes.md
|
||||||
|
- docs/08-interaction-checklist.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=45 synced=2026-08-04T14:41:45Z sha256=357ce372339f0d744849742f5dd2a38e79d6fc3bef0bd4053287f864fb778460 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-204 已有只读详情底座,T-205 将提供 attempt 事件、执行摘要和围栏前失败事实。当前详情仍不能可靠回答“目前在哪一步、是否已建立围栏、唯一安全下一步是什么”。T-206 只扩展只读详情和安全主动作推导;不实现重置、调和、付款或设备动作。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-004、F-007、F-011、F-017 的状态展示部分。
|
||||||
|
- 用户故事:US-002、US-004、US-008。
|
||||||
|
- 交互:IX-004、IX-009、IX-010、IX-011 的只读状态和下一步提示。
|
||||||
|
- 依赖:T-205;由其传递 T-204、T-210、T-302 及事件/证据事实。
|
||||||
|
- 后续消费者:T-207 围栏前人工恢复、T-208 提交围栏与调和。
|
||||||
|
- 本任务不新增管理或设备写接口。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 在同一个只读事务中加载任务/版本、当前及历史 authorization、T-302 claim 的非秘密摘要、attempt、T-205 append-only 时间线/执行摘要、三闸门 evidence 摘要和 order_submission/调和摘要。设备只展示非秘密名称、generation 和租约状态,不返回 token、nonce 或 session。
|
||||||
|
2. 增加纯函数式 SafePrimaryAction 推导。任何 order_submission 的存在都优先于 task.status;其次处理 RECONCILIATION_REQUIRED、WAITING_PAYMENT,再处理围栏前状态。事实互相矛盾时进入 SAFETY_HOLD,不显示写动作。
|
||||||
|
3. DRAFT 只引导返回列表勾选,不新增逐行开始按钮;PENDING/CLAIMED/ORDERING 只读展示授权、领取和步骤进度;过期授权只提示等待人工关闭,不能表述为可安全重领。
|
||||||
|
4. NEEDS_MANUAL 且无 submission 时显示固定失败码、最后安全步骤和“查看围栏前恢复条件”,本任务只做页内导航;不提前渲染 T-207 的 POST 表单。
|
||||||
|
5. 只要存在 submission,即使 task 仍错误地显示 ORDERING/NEEDS_MANUAL,也必须显示围栏锁定和同一 submission;不显示重置、取消、重新授权或再次提交。
|
||||||
|
6. RECONCILIATION_REQUIRED 只引导查看同一提交;WAITING_PAYMENT 明确“订单已创建,系统尚未付款”并只引导核对证据;标记付款属于 T-402。
|
||||||
|
7. Gate1/Gate2/Gate3 只按服务端固定 kind 和数据库事实展示,不解析图片、不自行计算或推断闸门通过。未知状态、缺少关联记录和归属冲突均 fail closed。
|
||||||
|
8. 完整页和抽屉继续共享同一详情模型与模板,保持返回、焦点、滚动、键盘、响应式及 no-store 证据边界。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 覆盖 DRAFT、PENDING、CLAIMED、ORDERING、NEEDS_MANUAL、WAITING_PAYMENT、RECONCILIATION_REQUIRED、终态、空态和 SAFETY_HOLD 的完整矩阵。
|
||||||
|
- ORDERING/NEEDS_MANUAL 与 order_submission 同时存在时必须显示围栏锁定;绝不出现围栏前恢复动作。
|
||||||
|
- 过期 lease/authorization 只显示事实,不把它推导成手机已停止、可释放或可转领。
|
||||||
|
- 页面不出现旧两趟、中途确认、自动付款、重新提交或普通重试术语;DRAFT 不增加逐行开始采购。
|
||||||
|
- 完整页和抽屉内容一致,匿名请求在查询前拒绝;页面不泄露 token、nonce、session、SQL 或路径。
|
||||||
|
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
(暂无)
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 本任务只读取并展示 T-204/T-205/T-302 已保存的任务、授权、claim、attempt、事件、证据和
|
||||||
|
submission 事实;不得新增管理或设备写接口,不得在读取详情时隐式续租、关闭、释放或改变状态。
|
||||||
|
- 任意 `order_submission` 的存在都必须压倒 `task.status` 和围栏前提示。即使任务错误地仍为
|
||||||
|
`ORDERING` / `NEEDS_MANUAL`,也只能展示同一 submission 的围栏/调和路径,绝不显示重置、取消、
|
||||||
|
重新授权、重新领取或再次提交。
|
||||||
|
- 租约或授权过期只是一项只读事实,不证明手机已经停止,不得把它推导成可释放、可转领或可安全
|
||||||
|
重试。事实缺失、归属不完整或互相冲突时进入 `SAFETY_HOLD`,不猜测、不渲染写动作。
|
||||||
|
- T-206 的安全主动作只允许返回列表、页内定位或查看既有事实;不得提前实现 T-207 重置/取消、
|
||||||
|
T-208 围栏/调和或 T-402 标记付款,不得增加逐行“开始采购”按钮或中途确认。
|
||||||
|
- Gate1/Gate2/Gate3 只按服务端固定 kind、事件和数据库记录展示;不解析图片、不自行认定页面、
|
||||||
|
不重新计算为可提交许可。截图仍只供内部审计,不替代实时三闸门。
|
||||||
|
- 不接触真机页面判据、client 代码、`submit_order_once()` 或任何支付、免密支付、先用后付和扣款能力。
|
||||||
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
id: T-207
|
||||||
|
title: 授权有效期与围栏前人工恢复
|
||||||
|
phase: 2
|
||||||
|
deps: [T-206]
|
||||||
|
status: TODO
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 46
|
||||||
|
context_ref: cd47d0c
|
||||||
|
work_branch: task/t-207-prefence-recovery
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-207.md
|
||||||
|
- admin/internal/taskrecovery/**
|
||||||
|
- admin/internal/taskdetail/**
|
||||||
|
- admin/internal/server/task_recovery.go
|
||||||
|
- admin/internal/server/task_recovery_test.go
|
||||||
|
- admin/internal/server/router.go
|
||||||
|
- admin/internal/transport/webui/webui.go
|
||||||
|
- admin/internal/transport/webui/templates/task-detail.html
|
||||||
|
- admin/cmd/server/**
|
||||||
|
- admin/README.md
|
||||||
|
- docs/api.md
|
||||||
|
- docs/routes.md
|
||||||
|
- docs/08-interaction-checklist.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=46 synced=2026-08-04T14:44:03Z sha256=550593f5197409b814d6c2c2bca81d2d21c65bfc62abd1a3f20b107d837fb9e8 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-205 的围栏前失败只把 attempt 置 FAILED、task 置 NEEDS_MANUAL,并刻意保留 authorization 与 claim;T-302 也明确租约丢失不等于手机停止。T-207 提供管理员人工检查后的围栏前恢复,使旧授权永久关闭后任务回到 DRAFT 或 CANCELED,同时保证任何围栏事实都永远不可释放。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-010、F-011、F-017 的围栏前恢复部分。
|
||||||
|
- 用户故事:US-005、US-008。
|
||||||
|
- 交互:IX-009、IX-010;T-206 负责状态与安全主动作,本任务接通被允许的写操作。
|
||||||
|
- 依赖:T-206;由其传递 T-205/T-302 的 claim、attempt 和详情事实。
|
||||||
|
- API:POST /tasks/{id}/reset-to-draft、POST /tasks/{id}/cancel。
|
||||||
|
- 后续消费者:T-208 submission fence 与恢复/围栏竞态测试。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 管理写请求只接受有效管理员 session + CSRF 和有界严格表单;设备 Bearer 无权调用。请求绑定 expected_task_version、authorization_id、可选 expected_attempt_id、可选 expected_claim_generation、必要时的 confirmed_device_stopped 和安全 return_to。
|
||||||
|
2. 定义围栏前恢复资格:PENDING 且无 attempt/claim/submission 可关闭;NEEDS_MANUAL 且 T-205 attempt 已明确 FAILED、无 submission 可重置/取消;租约过期或设备撤销但 attempt 曾进入 CLAIMED/ORDERING 时,只有管理员明确确认设备已安全停止才可继续。活跃租约、仍在 ORDERING 且无明确停止事实时拒绝。
|
||||||
|
3. authorization/attempt 已 FENCED 或任何关联 order_submission 存在时永久拒绝,不受 task.status、租约、授权过期、人工确认或调和终态影响。数据归属缺失或互相冲突时 fail closed。
|
||||||
|
4. 在一个有界 SQLite 写事务中先取得写入线性化位置,再重新读取 task、authorization、attempt、claim 和 submission。首先检查 submission;随后精确比较版本、authorization、attempt 和 generation,最后校验恢复资格。
|
||||||
|
5. 成功只做状态关闭,不删除事实:过期 authorization 转 EXPIRED,其余围栏前关闭转 ABANDONED;CLAIMED/ORDERING attempt 可安全关闭为 ABANDONED,已 FAILED 保持 FAILED;claim 留存并只写 closed_at,恢复原因由 authorization/attempt/task 的固定终态组合表达,不新增自由文本字段;task 转 DRAFT 或 CANCELED、version 恰好加一。
|
||||||
|
6. 本任务不新增 migration。利用旧 authorization/attempt/claim 终止足迹和 task 版本形成自然幂等:相同旧版本与旧授权的相同终态请求稳定返回原成功;一旦出现新版本/新授权,旧请求必须冲突,不能关闭新授权。
|
||||||
|
7. 重置成功不创建新授权。重新开始必须回到列表,由管理员重新点击“开始采购(只创建待付款订单)”并通过 T-203 产生新版本、新授权。
|
||||||
|
8. T-206 详情仅在服务端计算确定可恢复时渲染 POST 表单。重置是高影响动作,明确说明旧授权永久关闭和必须重新开始;取消为独立后果,不把两者混为同一动作。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 覆盖无领取 PENDING、明确 FAILED、租约过期但未证明停止、人工停止确认、活跃 ORDERING 及归属冲突。
|
||||||
|
- 覆盖 expires_at 边界相等、旧 generation、task/version 漂移、不同 authorization/attempt 和设备撤销。
|
||||||
|
- 覆盖响应丢失、服务重启、同请求重放、同键异终态、reset/cancel 并发和新授权后旧请求重放。
|
||||||
|
- 覆盖恢复与模拟围栏插入并发:只要任一 order_submission 已存在或先提交,恢复必须零释放;已 MANUAL_RESOLVED 也仍拒绝。
|
||||||
|
- 成功后 authorization、claim、attempt、events、evidence 全部保留;只改变获准状态,不删除审计事实。
|
||||||
|
- 管理/设备身份隔离、CSRF、请求大小/字段、错误不泄露均有测试。
|
||||||
|
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
(暂无)
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 只有有效管理员 session + CSRF 的显式人工动作可以执行围栏前重置或取消;设备 Bearer、后台过期
|
||||||
|
扫描、租约超时、设备撤销、停止轮询和进程重启都不得自动关闭授权或把任务送回 `DRAFT`。
|
||||||
|
- 租约过期或设备撤销不证明手机已停止。曾进入 `CLAIMED` / `ORDERING` 且没有 T-205 明确失败事实
|
||||||
|
的 attempt,必须由管理员明确确认设备已安全停止;活跃执行或事实不完整时一律拒绝恢复。
|
||||||
|
- 事务必须先取得写入线性化位置,并把任何关联 `order_submission` 的存在作为最高优先级硬拒绝。
|
||||||
|
authorization/attempt 已 `FENCED`,或 submission 已 `FENCED`、`SUBMITTED`、
|
||||||
|
`RECONCILIATION_REQUIRED`、`MANUAL_RESOLVED`,均不得释放、取消、重置或重新授权。
|
||||||
|
- 恢复只能关闭既有围栏前状态并保留全部审计事实;不得删除 authorization、claim、attempt、event、
|
||||||
|
evidence 或 submission。重置不得创建新授权,重新开始必须由管理员重新执行 T-203。
|
||||||
|
- 自然幂等必须绑定旧 task version、旧 authorization、预期 attempt/generation 和精确终态;不能仅因
|
||||||
|
当前已是 `DRAFT` / `CANCELED` 就返回成功。出现新版本或新授权后,旧请求必须冲突,不能影响新事实。
|
||||||
|
- 本任务不建立、查询为许可或模拟 submission fence,不实现设备 result、人工 submission 调和、
|
||||||
|
真机动作、点击“提交订单”或任何支付、免密支付、先用后付和扣款能力。
|
||||||
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
id: T-208
|
||||||
|
title: 三闸门提交围栏与结果调和 API
|
||||||
|
phase: 2
|
||||||
|
deps: [T-207, T-210]
|
||||||
|
status: TODO
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 47
|
||||||
|
context_ref: cd47d0c
|
||||||
|
work_branch: task/t-208-submission-fence
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-208.md
|
||||||
|
- admin/migrations/00008_submission_fence.sql
|
||||||
|
- admin/internal/migrations/migrations_test.go
|
||||||
|
- admin/internal/domain/submission.go
|
||||||
|
- admin/internal/domain/submission_test.go
|
||||||
|
- admin/internal/submission/**
|
||||||
|
- admin/internal/taskclaim/**
|
||||||
|
- admin/internal/taskdetail/**
|
||||||
|
- admin/internal/server/submission_fence.go
|
||||||
|
- admin/internal/server/submission_fence_test.go
|
||||||
|
- admin/internal/server/router.go
|
||||||
|
- admin/internal/transport/webui/webui.go
|
||||||
|
- admin/internal/transport/webui/templates/task-detail.html
|
||||||
|
- admin/cmd/server/**
|
||||||
|
- admin/README.md
|
||||||
|
- docs/api.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/routes.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=47 synced=2026-08-04T14:41:55Z sha256=a1109c837b096ecebfbda2200aa3c95c84b31ac2ff8e52404acb95060f148b3b -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-205 将保存同一 attempt 的 append-only 三闸门事件和执行摘要,T-210 将开放 Gate1/Gate2/Gate3 三种固定证据资产,T-207 只允许围栏前人工恢复。真实点击“提交订单”前仍缺少服务端唯一提交围栏、首次一次性许可、围栏响应不明处理、点击后一次性结果以及同一 submission 的人工调和。T-208 只实现采购服务端围栏与调和;不连接真机、不点击提交订单、更不付款。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-006、F-007、F-011、F-017。
|
||||||
|
- 用户故事:US-004、US-005、US-008。
|
||||||
|
- 交互:IX-009、IX-011;围栏由后台申请,Web 只显示和调和同一 submission。
|
||||||
|
- 依赖:T-207、T-210;显式保留 T-210 作为三证据类型安全门禁。T-207 传递 T-205/T-302,T-210 传递 T-107/T-204/T-302。
|
||||||
|
- API:POST /api/v1/purchase-attempts/{aid}/submission-fence、POST /api/v1/order-submissions/{sid}/result、POST /order-submissions/{sid}/reconcile。
|
||||||
|
- 后续消费者:T-401 单趟真实提交。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 新增 `00008_submission_fence.sql`,保存 fence request 幂等事实、唯一 order_submission proof、三个 gate evidence 外键、一次性 result request、append-only 人工调和记录,以及冻结的 device/session/claim generation/claim token hash 归属。authorization 与 attempt 各最多一个 submission;降级遇到新事实原子拒绝。
|
||||||
|
2. 设备认证必须在读取 body 前完成;严格 JSON 请求绑定 task/version、authorization、attempt、session、generation、claim token、最后事件序号、规格、两次单价、数量、确认页金额、精确 submit_control_text/match_count 和三个 evidence asset id,不接受未知字段、自由页面文本、XML、路径、selector、坐标或动作脚本。
|
||||||
|
3. `operation=CREATE_ONCE` 才可能创建围栏。事务必须先取得 SQLite 写线性化位置并再次确认设备 ACTIVE,再复用 T-302 的 current claim validator:设备/session/task/authorization/attempt/generation/token 全部一致,claim lease 与 authorization 严格未过期,task/attempt 为 ORDERING,authorization 为 CLAIMED。当前 validator 不得为结果上报放宽。
|
||||||
|
4. 围栏事务逐字段比对请求、T-205 append-only 事件、purchase attempt summary 与 T-210 evidence assets。三个资产必须精确为 SKU_PANEL_GATE_1、SKU_PANEL_GATE_2、ORDER_CONFIRM_GATE_3,属于同一 task/attempt/device;服务端只验证 kind/归属/hash 事实,不解析图片内容。
|
||||||
|
5. 服务端重新执行三闸门:颜色/尺码精确等于授权;Gate1/Gate2 为规范十进制正金额且完全相等;Gate1 × 授权数量不超 total_price_cap;数量精确相等;Gate3 规格/数量一致且确认页总额不超上限;submit_control_text 精确等于“提交订单”,可点击祖先计数精确为一。任一缺失、歧义、顺序错误或冲突都零围栏、零许可。
|
||||||
|
6. 首次 CREATE_ONCE 在一个事务中创建唯一 submission,并把 authorization CLAIMED→FENCED、attempt ORDERING→FENCED,使 task 永久不可重领。只有这次首次明确成功响应可含 `click_permitted=true`;许可不是数据库中可重取的开关,也不能由业务代码手工构造。
|
||||||
|
7. 同 fence_key 同规范 proof 的任何重放都返回同一 submission_id,但必须 `click_permitted=false`、`reconciliation_required=true`;同 key 异 proof、不同 key 命中同 authorization/attempt、并发失败或服务重启都绝不再次许可。
|
||||||
|
8. `operation=LOOKUP_ONLY` 只用于围栏响应不明后的同键调和:永远不创建 submission、永远不许可点击。找到原围栏时返回同一 submission 并保持/转入调和;找不到时明确无围栏事实,但恢复路径仍不得重新申请首次点击许可。
|
||||||
|
9. 围栏后的 result 不再要求活跃租约,也不得放宽 current claim validator;新增独立 fenced ownership validator,只按 order_submission 冻结的 device/session/generation/token hash 验证原设备归属。它不能创建、续租、释放或转领 claim。
|
||||||
|
10. 每个 submission 只接受一条一次性 result 事实。SUBMITTED 转 WAITING_PAYMENT 并永久消费授权;EXTERNAL_PAYMENT_HANDOFF、SECURITY_CHALLENGE、UNKNOWN 转 RECONCILIATION_REQUIRED 并保持围栏。相同 result_key 同载荷稳定重放,不同 key/observation 不得覆盖。没有 retry observation。
|
||||||
|
11. 本任务从 result wire 移除旧 `evidence_asset_id`。首次真实提交后的页面尚未经本项目真机取证,不新增 SUBMISSION_RESULT kind;UNKNOWN 允许无截图。绝不把 Gate3 图冒充结果图,绝不上传外部支付页、支付凭据或完整 XML。后续结果截图必须另立取证和 evidence kind 任务。
|
||||||
|
12. 管理调和只处理指定 submission,结论固定为 CONFIRMED_CREATED、CONFIRMED_NOT_CREATED_OR_CANNOT_COMPLETE、STILL_UNKNOWN;记录 reviewer、固定结论、服务端时间和幂等键,不触发设备动作。前两种形成最终人工结论后 authorization 永久 CONSUMED;STILL_UNKNOWN 保持围栏和待调和。
|
||||||
|
13. T-206 详情显示同一 submission 的 proof、首次许可事实、result 和调和记录。任何围栏后页面都没有重置、取消、重新授权、重新领取、再次提交或普通重试入口;WAITING_PAYMENT 仍明确系统尚未付款。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 两设备、同设备、同/异 key 并发中最多一个首次响应拥有 `click_permitted=true`;事务回滚时零 submission/状态变化。
|
||||||
|
- 覆盖首次成功、同键重放、响应截断、服务重启、异载荷/异 key 冲突;所有非首次路径 click_permitted 都为 false。
|
||||||
|
- LOOKUP_ONLY 找到/未找到、并发和重启都零创建、零许可;围栏响应不明不通过新 key 或恢复模式获得许可。
|
||||||
|
- 覆盖错误/缺失/跨 task/attempt/device 的三类 evidence、事件缺口/乱序、summary 冲突,以及规格、数量、金额、上限、提交文本和 match count 全部负例。
|
||||||
|
- 覆盖 current claim validator 的 lease/authorization 边界;静态和运行测试证明 fenced ownership validator 只验证冻结归属,不能被用于领取、续租、围栏创建或释放。
|
||||||
|
- 覆盖 fence 与 T-207 reset/cancel 并发;任一 submission 一旦存在,所有 authorization 状态和人工结论下都永久拒绝释放。
|
||||||
|
- 覆盖四种 result、同键重放、异 observation 冲突、UNKNOWN 无截图,以及三种人工调和结论;任何结果均不开放第二次点击。
|
||||||
|
- Migration 覆盖升级/重开、唯一/FK/trigger、已有安全事实、降级拒绝和 foreign_key_check。
|
||||||
|
- 静态检查没有 client/PDD selector、通用点击、submit_order_once、结果截图 kind、支付、免密支付、先用后付或扣款能力。
|
||||||
|
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
(暂无)
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- `CREATE_ONCE` 建立围栏时必须复用 T-302 的 **current claim validator**,严格要求当前设备、session、
|
||||||
|
task、authorization、attempt、generation、claim token、未过期 lease/authorization 与当前状态
|
||||||
|
全部一致。不得为了让围栏或结果流程跑通而放宽、复活或绕过该 validator。
|
||||||
|
- 围栏后的 result 必须使用独立 **fenced ownership validator**,只验证 order_submission 已冻结的
|
||||||
|
device/session/generation/token hash 归属。它不得被用于领取、续租、创建第二个围栏、释放、转领,
|
||||||
|
也不得反向削弱 current claim validator。
|
||||||
|
- 只有首次 `CREATE_ONCE` 原子创建唯一 submission 的明确成功响应可以包含
|
||||||
|
`click_permitted=true`。同 key 重放、异 key 冲突、并发输家、服务重启、响应不明恢复及任何
|
||||||
|
`LOOKUP_ONLY` 都必须 `click_permitted=false`;许可不得作为可重取数据库开关或手工构造对象。
|
||||||
|
- `LOOKUP_ONLY` 永远零创建、零许可。围栏响应不明时不得换 key、再次申请首次许可或凭页面猜测点击;
|
||||||
|
找到原围栏只能调和同一 submission,找不到也只能按围栏前安全失败处置。
|
||||||
|
- 围栏事务必须逐字段比对请求、T-205 append-only events/summary 和 T-210 三类 evidence 资产,
|
||||||
|
并在服务端重算三闸门与精确唯一“提交订单”控件。缺失、歧义、跨 task/attempt/device、价格/规格/
|
||||||
|
数量冲突或事件乱序均零围栏、零许可。
|
||||||
|
- 本任务不新增 `SUBMISSION_RESULT` 或其他结果截图 kind,并从 result wire 移除旧
|
||||||
|
`evidence_asset_id`。首次真实提交后的页面尚未完成本项目真机取证;`UNKNOWN` 允许无截图,不得把
|
||||||
|
Gate3 图片冒充结果图,外部支付页、支付凭据和完整 XML 永不上传。
|
||||||
|
- 任一 submission 建立后,authorization、attempt 和 submission 永久不可释放、重置、取消、重新
|
||||||
|
授权、重新领取或再次点击。result 或人工调和只能收紧到 `WAITING_PAYMENT` / `FAILED` / 保持
|
||||||
|
`RECONCILIATION_REQUIRED`,绝不能回到围栏前状态。
|
||||||
|
- 本任务只实现服务端围栏、一次性结果和同一 submission 调和;不修改 client/PDD 页面判据,不连接
|
||||||
|
真机,不实现 `submit_order_once()`,不点击“提交订单”,不编写支付、免密支付、先用后付或扣款能力。
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
id: T-210
|
||||||
|
title: 扩展闸门二与闸门三证据类型
|
||||||
|
phase: 2
|
||||||
|
deps: [T-107, T-204, T-302]
|
||||||
|
status: TODO
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 43
|
||||||
|
context_ref: cd47d0c
|
||||||
|
work_branch: task/t-210-gate-evidence-kinds
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-210.md
|
||||||
|
- admin/migrations/00006_gate_evidence_kinds.sql
|
||||||
|
- admin/internal/migrations/migrations_test.go
|
||||||
|
- admin/internal/evidence/**
|
||||||
|
- admin/internal/storage/evidence/**
|
||||||
|
- admin/internal/server/evidence_test.go
|
||||||
|
- docs/api.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/06-tasks.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=43 synced=2026-08-04T14:39:13Z sha256=db95cfcdb109c264b188336cbf1625411d3457baf8525532613aac7e7487c3fd -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-204 当前数据库和 API 只允许 SKU_PANEL_GATE_1;T-107 将以真机证据批准闸门二规格面板和闸门三确认页截图。T-210 在不改变上传身份、大小、隐私或幂等边界的前提下,安全扩展两个固定证据类型,为 T-305 提供完整三闸门审计。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-007、F-013、F-017 的证据部分。
|
||||||
|
- 依赖:T-107、T-204、T-302。
|
||||||
|
- 后续消费者:T-205、T-305、T-208。
|
||||||
|
- 只修改采购服务证据 schema/validation,不修改 client 或页面判据。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 新增 00006 迁移,把 evidence kind 白名单精确扩展为 SKU_PANEL_GATE_1、SKU_PANEL_GATE_2、ORDER_CONFIRM_GATE_3;保留所有旧 gate1 资产和审计身份,不把 kind 变成任意字符串。
|
||||||
|
2. SQLite 表重建/约束迁移必须在事务内保持 foreign_keys=ON,迁移后运行 foreign_key_check;保留 T-302 claim ownership trigger、唯一键、索引、已有外键和所有列约束。不得通过关闭 FK 或丢弃历史行绕过。
|
||||||
|
3. 降级只在没有 gate2/gate3 资产时允许;存在新事实必须原子拒绝,不能静默把新 kind 改名为 gate1 或删除记录。
|
||||||
|
4. 领域白名单、multipart handler、store 与返回 DTO 只增加这两个常量;设备认证仍先于 Content-Type/body,首次写入仍要求当前设备未关闭 claim,closed claim 只允许旧 key replay。
|
||||||
|
5. 三个 kind 均只接受单个显式 INTERNAL_RAW PNG,沿用 10 MiB、像素、SHA-256、原子文件发布、固定字段和不上传 XML/path 的边界。
|
||||||
|
6. kind 与页面事实固定映射:Gate1/Gate2 只能规格面板,Gate3 只能订单确认页。服务端不解析图片或推断页面;真实性由 T-107/T-305 事件顺序和人工验收保证。
|
||||||
|
7. 不增加支付页、待付款页、任意截图或自定义 kind,不新增页面自动化、事件写入、围栏、结果或付款能力。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 迁移覆盖升级/重开/保留 gate1、全部约束/trigger/index/FK、错 kind、down 无事实成功和有新事实原子拒绝。
|
||||||
|
- HTTP/store 覆盖三种精确 kind、大小写/相似/未知拒绝、设备 A/B 隔离、closed claim replay/new key 拒绝。
|
||||||
|
- 原始 PNG 可含页面显示地址/手机号;仍不解析为字段,不接收 XML/manifest/path/支付页/支付凭据。
|
||||||
|
- 静态检查没有 client/PDD、event/fail/fence/result、提交订单或付款能力。
|
||||||
|
- Go full/race/vet/build、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T14:06:35Z · ila
|
||||||
|
|
||||||
|
2026-08-04 从 T-305 拆出服务端证据类型扩展:只在 T-107 取证批准后增加固定 Gate2/Gate3 kind,保留 Gate1 与 T-302 ownership/replay 边界;不让 T-306 或 dry-run 临时改 schema。
|
||||||
|
|
||||||
|
### 2026-08-04T14:39:05Z · ila
|
||||||
|
|
||||||
|
2026-08-04 依赖审计反转 T-210/T-205:三种 evidence kind 必须先进入真实 SQLite schema,T-205 才能对 Gate1/2/3 成功事件做真实正向与错 kind/跨归属测试。T-210 改为 00006,依赖 T-107/T-204/T-302。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 证据类型只允许精确的 `SKU_PANEL_GATE_1`、`SKU_PANEL_GATE_2`、
|
||||||
|
`ORDER_CONFIRM_GATE_3`;不得接受自由 kind、别名、大小写变体或客户端自定义值。
|
||||||
|
- 升级必须保留既有闸门一 asset、哈希、attempt/task/authorization/generation 归属、append-only
|
||||||
|
约束与 T-302 claim 所有权触发器。迁移期间保持 `foreign_keys=ON`,完成后必须通过
|
||||||
|
`foreign_key_check`;不得删除、重建为空或改写既有证据事实。
|
||||||
|
- 降级仅在库中不存在新增 kind 的事实时允许;一旦已有闸门二或闸门三证据必须拒绝降级,不能静默
|
||||||
|
丢弃、改名或降为闸门一。
|
||||||
|
- 三种证据继续只接受 T-204 已定值的 PNG、大小/分辨率/哈希/幂等与 `INTERNAL_RAW` 边界;不得
|
||||||
|
扩展文件类型、上传 XML/manifest/目录、提取或结构化地址与手机号。
|
||||||
|
- 本任务只扩展服务端证据 allowlist 与契约,不修改 client/PDD 页面判据,不实现事件、围栏、结果、
|
||||||
|
提交订单点击或任何付款能力。
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
id: T-211
|
||||||
|
title: claim wire 输出字段上限闭合
|
||||||
|
phase: 2
|
||||||
|
deps: [T-203, T-302, T-303]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-05
|
||||||
|
vikunja_task_id: 55
|
||||||
|
context_ref: cfb238d
|
||||||
|
work_branch: task/t-211-claim-wire-bounds
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-211.md
|
||||||
|
- docs/api.md
|
||||||
|
- docs/current-state.md
|
||||||
|
- admin/internal/tasks/**
|
||||||
|
- admin/internal/taskclaim/**
|
||||||
|
- admin/internal/server/task_claims.go
|
||||||
|
- admin/internal/server/task_claims_test.go
|
||||||
|
- client/src/cmbuyer_client/core/**
|
||||||
|
- client/tests/core/**
|
||||||
|
- client/tests/remote/**
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=55 synced=2026-08-04T17:52:41Z sha256=4bdc0bccc833e75d759de4f2270b372484e92e261f979c3b28f501687209b60a -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
claim 成功响应受 32 KiB 总 body 上限保护,但创建、授权快照、candidate 和重放链路尚未统一约束各自由文本字段,客户端也未冻结同一单字段合法域。异常或历史超长值可能让合法业务事实变成不可传输响应。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-005。
|
||||||
|
- 依赖:T-203、T-302、T-303。
|
||||||
|
- API:POST /api/v1/tasks/claim-next 及客户端 claim wire model。
|
||||||
|
- 本任务无 UI、真机或人工验收。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 统一 title 不超过 120 Unicode code points,sku_color 与 sku_size 各不超过 80 Unicode code points。
|
||||||
|
2. goods_id 仅允许 1--32 位 ASCII 数字;canonical money 仅允许不超过 32 个 ASCII 字符,并保留既有正数、两位小数、无前导零与 int64 约束。
|
||||||
|
3. 在创建、开始采购授权快照、claim candidate、持久化响应与幂等重放边界逐层 fail closed;不迁移、不截断或改写既有数据。
|
||||||
|
4. 客户端 wire model 使用相同边界,漂移响应按协议错误拒绝。
|
||||||
|
5. 增加最坏合法字段的 claim JSON 契约测试,明确编码后小于既有 32 KiB 响应上限。
|
||||||
|
6. HTTP claim handler 在序列化前重验完整 service 响应并保持 32 KiB 上限;因此将精确文件 admin/internal/server/task_claims.go 纳入写路径,不扩大到其他 server 文件。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 边界值通过,超过一个 Unicode code point/ASCII 字符即拒绝;组合字符按 code point 而非字节计数。
|
||||||
|
- 创建、授权、candidate、首次 claim 与重放均覆盖畸形既有数据并失败闭合。
|
||||||
|
- 客户端 title/SKU/goods_id/money 与服务端同界。
|
||||||
|
- 最坏合法 claim JSON 明确小于 32768 字节,保持现有 HTTP body cap。
|
||||||
|
- handler 拒绝越界或畸形 service 输出,且不泄露响应内容。
|
||||||
|
- 两端完整测试、vet/build、compileall、根 init、上下文与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T17:10:26Z · ila
|
||||||
|
|
||||||
|
2026-08-05 开始 T-211:基于 main@cfb238d,在独立 worktree 收紧 claim wire 字段上限;不迁移或截断既有数据,不涉及设备页面、提交订单或付款。
|
||||||
|
|
||||||
|
### 2026-08-04T17:11:07Z · ila
|
||||||
|
|
||||||
|
2026-08-05 主脑预审收紧:Go 必须先 utf8.ValidString,再按既有 TrimSpace 后实际持久化值计算 code point;start/authorization 遇既有畸形 task 原子失败,claim replay 重验持久化响应快照;不增加迁移。
|
||||||
|
|
||||||
|
### 2026-08-04T17:18:04Z · ila
|
||||||
|
|
||||||
|
2026-08-05 实现范围收紧:HTTP handler 是 service 输出的最后边界,已获总控批准将精确文件 admin/internal/server/task_claims.go 纳入 write_paths;handler 序列化前重验 claim response 与 32 KiB 上限,不扩大到其他 server 文件。
|
||||||
|
|
||||||
|
### 2026-08-04T17:25:07Z · ila
|
||||||
|
|
||||||
|
2026-08-05 实现里程碑:服务端共享 validator 已覆盖创建持久化、start/authorization、candidate、claim snapshot/replay 和 handler 输出;客户端 wire 同界。最坏合法服务端 JSON 使用会被 escapeHTML 为 6 字节的 < 字符证明严格小于 32 KiB,客户端用 ensure_ascii=True 做保守预算证明。focused:Go tasks/taskclaim/server 全绿;client core 8/8、remote 15/15。
|
||||||
|
|
||||||
|
### 2026-08-04T17:38:44Z · ila
|
||||||
|
|
||||||
|
2026-08-05 审阅修复:统一 Go/Python 持久化文本域,客户端使用 Go Unicode White_Space 固定集,两端均拒绝任意位置 U+001C--U+001F,并补 NBSP/C0 回归。候选查询不再用 SQL 相等条件静默过滤:先读取 task 与 authorization 两侧快照并分别验证,单侧畸形立即回滚且不写 EMPTY/attempt;两侧均合法但不一致才按既有语义跳过。最坏合法 JSON 也覆盖 int64 版本编码上界。
|
||||||
|
|
||||||
|
### 2026-08-04T17:48:38Z · ila
|
||||||
|
|
||||||
|
2026-08-05 completion verification: main-agent review PASS; independent adversarial audit PASS; fixed SHA `f6cd65208db5f35155946a7855773ee56e7a9d65`; client 192 tests PASS; `go test -count=1 ./...` PASS; `go vet ./...` PASS; repository-root `init.ps1` PASS.
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个 Unicode
|
||||||
|
code point;边界以 Go `strings.TrimSpace` 的 Unicode White_Space 集处理后的实际持久化值为准。
|
||||||
|
客户端使用同一固定空白集,不依赖 Python `str.strip()` 默认语义;两端均拒绝任意位置的
|
||||||
|
U+001C--U+001F 四个 C0 分隔符。Go 必须先通过 `utf8.ValidString`,不能把
|
||||||
|
非法 UTF-8 解码出的 `RuneError` 当成合法 code point;不按 UTF-8 字节、不按视觉 grapheme,
|
||||||
|
也不得为通过校验截断或规范化改写非空内容。
|
||||||
|
- `goods_id` 只允许 1--32 位 ASCII 数字;canonical money 只允许 1--32 个 ASCII 字符,并继续
|
||||||
|
满足既有正数、恰好两位小数、无非法前导零的规范格式。数量、版本与 generation 的正 int64
|
||||||
|
边界保持不变,不能接受 bool、浮点数或字符串整数。
|
||||||
|
- 创建任务、开始采购的授权快照、claim candidate、首次响应快照与幂等重放必须使用同一字段合法域。
|
||||||
|
candidate 必须先读取并分别校验 task 与 authorization 两侧字段;任一侧畸形立即回滚且不写 `EMPTY`,
|
||||||
|
只有两侧均合法但快照不一致时才跳过。start/authorization 遇到既有畸形 task 必须原子失败,claim
|
||||||
|
replay 还必须重新校验持久化响应快照。任一层发现超限或畸形既有数据都失败闭合,不迁移、不截断、
|
||||||
|
不覆盖或修复历史行。
|
||||||
|
- claim 成功响应继续受既有 32 KiB HTTP body 上限约束;最坏合法字段组合的编码结果必须由契约测试
|
||||||
|
明确证明小于该上限。HTTP handler 在序列化前重验完整 claim response,并拒绝任何越界 service
|
||||||
|
输出;不得调高响应上限掩盖无界字段。
|
||||||
|
- 客户端 claim wire model 与服务端使用完全相同的上限;未知、缺失、超限或格式漂移响应必须拒绝,
|
||||||
|
不得静默接受后再截断入库。
|
||||||
|
- 本任务不修改数据库迁移或 schema,不连接设备,不改变授权、身份、租约、幂等、int64、金额或安全
|
||||||
|
边界,不实现事件、围栏、提交订单、付款或任何页面自动化能力。
|
||||||
+6
-2
@@ -3,7 +3,7 @@ id: T-301
|
|||||||
title: 设备凭据与身份隔离(F-013)
|
title: 设备凭据与身份隔离(F-013)
|
||||||
phase: 3
|
phase: 3
|
||||||
deps: [T-201, T-204]
|
deps: [T-201, T-204]
|
||||||
status: DOING
|
status: DONE
|
||||||
created: 2026-08-04
|
created: 2026-08-04
|
||||||
vikunja_task_id: 33
|
vikunja_task_id: 33
|
||||||
context_ref: a6ad560
|
context_ref: a6ad560
|
||||||
@@ -25,7 +25,7 @@ write_paths:
|
|||||||
- docs/04-architecture.md
|
- docs/04-architecture.md
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=33 synced=2026-08-04T12:06:22Z sha256=1d572a2bf01628ca25aa153baa6d51443535eb399c006f1f0c7cc2b99edc1d6b -->
|
<!-- BEGIN VIKUNJA EXPORT id=33 synced=2026-08-04T12:58:29Z sha256=4a81bf119f633f6e407817d2ad334cb4021fc3984a41ff0955875dec62e0a153 -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-204 已提供设备认证注入点,但生产实现固定拒绝全部设备请求。T-301 为采购工具建立可签发、可撤销、服务端逐请求校验的设备凭据,并把管理员会话与设备 Bearer 能力严格隔离;不提前实现领取、租约或客户端 HTTP 适配。
|
T-204 已提供设备认证注入点,但生产实现固定拒绝全部设备请求。T-301 为采购工具建立可签发、可撤销、服务端逐请求校验的设备凭据,并把管理员会话与设备 Bearer 能力严格隔离;不提前实现领取、租约或客户端 HTTP 适配。
|
||||||
@@ -69,6 +69,10 @@ T-204 已提供设备认证注入点,但生产实现固定拒绝全部设备
|
|||||||
### 2026-08-04T12:06:14Z · ila
|
### 2026-08-04T12:06:14Z · ila
|
||||||
|
|
||||||
2026-08-04 编码前错误语义定值:请求头非法、未知、token/device 错配和已撤销统一空 401;SQLite 查询/连接故障统一空 503。两类都必须在读取 body 和调用业务处理器前失败闭合,响应不含内部原因。token_sha256 采用 32 字节 BLOB。
|
2026-08-04 编码前错误语义定值:请求头非法、未知、token/device 错配和已撤销统一空 401;SQLite 查询/连接故障统一空 503。两类都必须在读取 body 和调用业务处理器前失败闭合,响应不含内部原因。token_sha256 采用 32 字节 BLOB。
|
||||||
|
|
||||||
|
### 2026-08-04T12:58:11Z · ila
|
||||||
|
|
||||||
|
2026-08-04 完成 T-301:实现设备凭据签发/list/幂等撤销、逐请求 SQLite Bearer+设备 ID 认证、401/503 请求体前失败闭合、管理/设备/混合身份隔离,并把生产服务收紧到 127.0.0.1:8080。两轮独立审计发现并修复损坏凭据行判定、CLI 缺失库创建以及 FILE: scheme 绕过;最终审计 PASS(无 P0/P1/P2)。实现提交 66355a7,合并提交 e674b7f;完整 init、Go 全量/竞态/vet/build、客户端 112 项测试、上下文校验与 diff-check 通过。
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
id: T-302
|
||||||
|
title: 已授权任务原子领取与租约(F-005)
|
||||||
|
phase: 3
|
||||||
|
deps: [T-301, T-203]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 34
|
||||||
|
context_ref: 2331882
|
||||||
|
work_branch: task/t-302-atomic-claim
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-302.md
|
||||||
|
- admin/migrations/00005_task_claims.sql
|
||||||
|
- admin/internal/migrations/migrations_test.go
|
||||||
|
- admin/internal/taskclaim/**
|
||||||
|
- admin/internal/config/**
|
||||||
|
- admin/internal/storage/evidence/**
|
||||||
|
- admin/internal/taskdetail/store_test.go
|
||||||
|
- admin/internal/server/**
|
||||||
|
- admin/cmd/server/**
|
||||||
|
- admin/README.md
|
||||||
|
- docs/api.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=34 synced=2026-08-04T14:33:59Z sha256=85cf1ed0ce7ba219accfaee3bcbb39c9f1f584541446ea37df846db9ac63fa1e -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-203 已把管理员明确授权的任务原子转为 PENDING,T-301 已提供可撤销的真实设备身份,但采购工具还不能安全领取任务。T-302 建立服务端原子领取、幂等重放和租约续期边界,使同一授权只能产生一条可恢复 attempt;不实现客户端轮询、页面执行、事件、提交围栏或结果上报。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-005。
|
||||||
|
- 用户故事:US-003、US-005、US-007。
|
||||||
|
- 交互:为 IX-007、IX-008 提供服务端契约;本任务无新增管理页面。
|
||||||
|
- 依赖:T-301、T-203;后续消费者:T-303、T-205、T-208、T-306。
|
||||||
|
- API:`POST /api/v1/tasks/claim-next`、`POST /api/v1/tasks/{id}/lease/renew`。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 新增 `00005_task_claims.sql`。用独立 claim、claim request、lease renewal 表绑定 task、authorization、attempt、设备、session、generation、随机 nonce、token hash 和租约;同一授权最多一个 attempt,同一设备最多一个未关闭 claim。迁移遇到无法推断归属的既有 attempt/submission/evidence 事实时拒绝升级,已有领取事实时拒绝降级。
|
||||||
|
2. Claim token 使用独立 `CMBUYER_CLAIM_TOKEN_SECRET`(64 位小写十六进制,解码为 32 字节),不复用管理员 session 或设备 token。每条 claim 生成 32 字节随机 nonce,用带版本域分隔的 HMAC-SHA256 绑定 device/task/authorization/attempt/generation/nonce;返回 64 位小写十六进制 token,数据库只保存 nonce 和 token SHA-256。服务重启后可重建同一 token;secret 不匹配已有 claim 时启动失败闭合。
|
||||||
|
3. 新增显式 `CMBUYER_CLAIM_LEASE_TTL`,必须为正且严格短于授权 TTL。领取和续租只使用服务端 UTC 时间;边界相等视为已过期,没有宽限或隐式续租。
|
||||||
|
4. `claim-next` JSON 只接受规范 UUIDv4 的 `session_id`、`claim_request_id`;设备 id 只来自 T-301 认证主体。严格校验 Content-Type、UTF-8、大小、未知字段和额外 JSON。设备认证仍先于请求体读取。
|
||||||
|
5. Claim/renew 的有界 SQLite 事务必须先取得写入线性化位置,并以条件更新确认设备仍为 ACTIVE(RowsAffected 必须为 1);只有在此之后才可读取或重放 request、返回 EMPTY/冲突、选择候选或续租。领取再稳定选择最早的 `PENDING + ACTIVE + 未过期 + task/version/规格/数量/总价快照一致` 授权,创建 attempt/claim/request,并原子执行 authorization `ACTIVE→CLAIMED`、task `PENDING→CLAIMED` 且 version+1。任一步行数或约束不符全部回滚;并发设备只能一个成功。SQLite 无行锁,不得用先 SELECT 或先返回 replay 代替该写入线性化。
|
||||||
|
6. 同一 claim request 同载荷在响应丢失和服务重启后返回原结果与同一 claim token;无候选也持久化 EMPTY 并稳定重放。同一设备已有未关闭 claim 时,同 session 且租约有效重放原 attempt;不同 session、租约过期或状态异常固定返回需人工处理,不转领、不创建第二个 attempt。
|
||||||
|
7. 续租请求绑定 renew_request_id、session、attempt、generation、claim token 和 expected_lease_expires_at。只允许原设备/会话/attempt,当前租约和授权都严格未过期,且 expected 值精确匹配;新到期时间为 `min(server_now + lease_ttl, authorization.expires_at)`。同键同载荷稳定重放,异载荷冲突;续租不改变 token、generation、任务版本或业务状态,过期租约不能复活。
|
||||||
|
8. 设备撤销与领取并发必须在线性化位置再次查 ACTIVE:撤销先提交则领取/续租失败,领取先提交后撤销不自动释放 claim。撤销、租约过期、停止轮询均不能证明手机已停止,另一设备不得自动接管;后续 T-207 负责人工安全恢复。
|
||||||
|
9. 用统一 claim 所有权校验收紧 T-204 证据存储:设备只能向自己当前 attempt 上传截图;不得因本任务扩大截图 kind、文件类型或隐私边界。
|
||||||
|
10. 本任务不新增 heartbeat/events/fail/fence/result,不实现 client HTTP、真机选择器、下单函数、提交订单点击或任何付款动作。claim token 永远不是提交许可,响应不得包含自由动作脚本、坐标、选择器或 `click_permitted`。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- Migration 覆盖升级/重开、外键/唯一/partial index、既有事实拒绝升级和有领取事实拒绝降级。
|
||||||
|
- 覆盖领取 eligibility、快照一致、稳定排序、事务回滚、两设备并发唯一、同设备单开放 claim、成功/EMPTY/冲突幂等及服务重启重放。
|
||||||
|
- 覆盖 HMAC 域隔离、nonce 随机、数据库无明文、错误 secret 启动失败、跨设备/attempt/generation/token 拒绝和常量时间比较。
|
||||||
|
- 覆盖续租 CAS、授权到期封顶、无宽限、过期不复活、乱序/并发/同键异载荷,以及续租不改变业务状态。
|
||||||
|
- 覆盖撤销并发线性化、认证失败 body 零读取、设备 A 不能上传设备 B attempt 证据。
|
||||||
|
- HTTP 错误固定且不泄露 token、SQL、路径或候选任务;静态检查确认没有事件、围栏、提交、付款或页面自动化能力。
|
||||||
|
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文校验与 diff-check 全部通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T13:03:26Z · ila
|
||||||
|
|
||||||
|
2026-08-04 开始 T-302:依赖 T-301、T-203 均已完成,任务定义提交 590c846。实现范围限于服务端原子 claim/renew、可恢复 HMAC claim token、租约 CAS、撤销并发线性化和证据 attempt 所有权收紧;不新增事件、围栏、提交、付款、客户端 HTTP 或真机能力。工作分支 task/t-302-atomic-claim。
|
||||||
|
|
||||||
|
### 2026-08-04T13:10:15Z · ila
|
||||||
|
|
||||||
|
2026-08-04 预实现审计收紧撤销线性化:claim/renew 事务必须先取得 SQLite 写入位置并条件确认设备 ACTIVE,之后才允许 request 重放、EMPTY/冲突返回或业务写入;只 SELECT 或先返回 replay 均不成立。同时补充 docs/current-state.md 为显式 write_path,避免实现后共享文档越界。
|
||||||
|
|
||||||
|
### 2026-08-04T13:11:10Z · ila
|
||||||
|
|
||||||
|
2026-08-04 write_path 更正:上下文门禁发现 T-103 仍为 DOING 且已拥有共享文档 docs/current-state.md,T-302 同时声明会违反唯一写入者规则,因此不纳入、不修改。T-302 完成事实先记录在本任务;待 T-103 释放路径后由项目级文档同步任务统一更新。撤销线性化 P1 收紧不变。
|
||||||
|
|
||||||
|
### 2026-08-04T13:15:18Z · ila
|
||||||
|
|
||||||
|
2026-08-04 预实现审计继续定值:claim 的 CLAIMED/EMPTY/需人工结果与 renew 成功都必须持久化幂等,同键重放不再次 CAS/延长;secret 启动时拒绝与 session 原始值或 key bytes 相同,并拒绝命中任何设备 token hash,所有 claim 逐行重建恒定时复核;证据首次写入要求同设备未关闭 claim,但已成功 upload_key 在 claim 关闭后仍先按原载荷稳定重放。HTTP JSON 上限 4096 bytes;claim 200、EMPTY 204,renew 200;400/413/415 固定错误,401/503 空,409 只用 idempotency_conflict、claim_requires_manual、claim_not_current。
|
||||||
|
|
||||||
|
### 2026-08-04T14:32:06Z · ila
|
||||||
|
|
||||||
|
2026-08-04 完成:实现已授权任务原子 claim/renew、可恢复 HMAC claim token、租约 CAS、设备撤销线性化、attempt/generation 四列数据库约束及 evidence claim owner 收紧。三轮独立审计后修复 ORDERING/CLAIMED 状态版本一致性、generation lineage、双 sql.DB 确定性并发测试和 API UUID/token 示例。主 agent 与终审均通过 focused -count=25、go test、race、vet、build、完整 init(client 112 tests)、上下文和 diff-check;未实现 event/fence/submit/payment。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 只领取 `PENDING` 且存在同版本 `ACTIVE`、未过期、完整快照一致的授权;领取事务必须再次验证
|
||||||
|
设备仍为 `ACTIVE`。任何缺失、畸形、并发冲突或存储异常都失败闭合,不能用应用层先读后写代替
|
||||||
|
数据库条件更新与唯一约束。
|
||||||
|
- 一条授权最多创建一个 attempt;同一设备最多一个未关闭 claim。响应丢失、服务重启、同请求重放、
|
||||||
|
续租或同会话恢复均不得递增 generation、轮换 claim token、领取另一任务或创建第二条 attempt。
|
||||||
|
- Claim token 是 attempt 归属凭据,不是采购授权,更不是提交许可。明文不得进入 SQLite、日志、错误、
|
||||||
|
Git、Vikunja 或测试 fixture;HMAC secret 必须与管理员 session secret、设备 token 分离。错误 secret
|
||||||
|
面对已有 claim 时必须拒绝启动,不能签发替代 token。
|
||||||
|
- Secret 分离必须由启动检查执行:claim secret 的原始配置或解码 key 不得等于 session secret,
|
||||||
|
其 SHA-256 不得命中任何设备 token hash;所有既有 claim(包括以后已关闭的)都必须用当前 secret
|
||||||
|
逐条重建并恒定时复核,不能只检查开放 claim,也不能为通过启动而改写旧 hash。
|
||||||
|
- 租约和授权边界相等即过期,无宽限。过期租约不得续租、复活、自动关闭 attempt、释放授权或转给
|
||||||
|
另一设备;设备撤销、停止轮询和进程退出也不得触发自动接管。安全恢复归 T-207。
|
||||||
|
- 设备身份认证仍必须在解析 Content-Type 或读取 body 前完成;claim/renew 事务的第一条数据库业务
|
||||||
|
语句还必须先取得 SQLite 写入线性化位置并条件确认设备 `ACTIVE`,之后才允许查询或重放 request、
|
||||||
|
返回 EMPTY/冲突或写业务事实。认证、token、session、generation、当前租约或快照任一不匹配均
|
||||||
|
不得产生业务写入。
|
||||||
|
- 本任务只增加 claim/renew,并按 claim 所有权收紧已有证据上传;不得新增或提前实现 heartbeat、event、
|
||||||
|
fail、submission-fence、result、客户端 HTTP 适配、截图 kind 或页面自动化。
|
||||||
|
- 证据首次写入必须属于当前认证设备的未关闭 claim;但相同设备与 `upload_key` 已成功落库的同载荷
|
||||||
|
在 claim 后续关闭后仍须先按 T-204 原结果稳定重放,不能把关闭 claim 变成幂等契约失效。
|
||||||
|
- 不接触拼多多页面判据、规格选择、数量、确认页或真机流程;不编写或引用点击“提交订单”的代码,
|
||||||
|
不编写支付、免密支付、先用后付或任何扣款控件代码。响应不得返回自由动作脚本、坐标、选择器或
|
||||||
|
`click_permitted`。
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
id: T-303
|
||||||
|
title: 客户端 HTTP 任务源、证据 sink 与可恢复本地状态
|
||||||
|
phase: 3
|
||||||
|
deps: [T-002, T-204, T-302]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 36
|
||||||
|
context_ref: 3a0a41d
|
||||||
|
work_branch: task/t-303-http-localstate
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-303.md
|
||||||
|
- client/src/cmbuyer_client/core/**
|
||||||
|
- client/src/cmbuyer_client/remote/**
|
||||||
|
- client/src/cmbuyer_client/localstate/**
|
||||||
|
- client/src/cmbuyer_client/runtime.py
|
||||||
|
- client/src/cmbuyer_client/logging_policy.py
|
||||||
|
- client/tests/core/**
|
||||||
|
- client/tests/remote/**
|
||||||
|
- client/tests/localstate/**
|
||||||
|
- client/tests/test_runtime.py
|
||||||
|
- client/tests/test_logging_policy.py
|
||||||
|
- docs/api.md
|
||||||
|
- docs/03-tech-stack.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/06-tasks.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=36 synced=2026-08-04T17:04:20Z sha256=b63f054754fcbc56b123abaea81915eaaf10ca486dd790730c68d8e06fbbd8c4 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-302 完成后,采购工具需要真实、安全、可恢复地领取/续租任务;T-204 已提供受控截图上传接口。但 client 当前只有真机能力和最小窗口,没有 HTTP 抽象、设备凭据存储或断网/重启幂等状态。T-303 建立 `HttpTaskSource`、窄 `HttpEvidenceSink` 与 Windows 本地恢复底座;事件/fail/fence/result 尚分别依赖 T-205/T-208,本任务不伪造完整 `HttpResultSink` 或占位请求。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-005、F-007、F-013。
|
||||||
|
- 用户故事:US-003、US-004、US-007。
|
||||||
|
- 依赖:T-002、T-204、T-302;T-301/T-203 已由 T-302 传递满足。
|
||||||
|
- 后续消费者:T-304 定时轮询/配置 UI、T-306 真截图接入;完整结果 sink 在 T-205/T-208 后组合。
|
||||||
|
- 本任务不修改桌面 UI;只提供核心契约、HTTP 传输和可恢复本地状态。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 定义不依赖 HTTP/UI/PDD 的 TaskSource、EvidenceSink、任务/授权/attempt/租约值对象;金额保持规范十进制字符串,UUID/时间/整数/布尔严格区分。不要创建运行时 `NotImplementedError` 的假 ResultSink。
|
||||||
|
2. HTTP 只接受精确 `http://127.0.0.1:8080`,使用标准库直连,不使用系统代理、不跟随重定向。每个方法只发一次请求,不在传输层隐藏重试;设置连接/读取超时和请求/响应上限。Bearer、device id、Content-Type 均由调用边界唯一生成。
|
||||||
|
3. 严格实现 T-302 最终 wire:claim/renew 的字段、4 KiB JSON 上限、200/204、固定 400/409/413/415、空 401/503;2xx 仍严格校验 Content-Type、UTF-8、重复 key、未知/缺失字段、UUIDv4、UTC `Z` RFC3339、64 位小写 token、金额和整数,超限或 schema 漂移视为结果不明而非成功。
|
||||||
|
4. `%LOCALAPPDATA%/cmbuyer/state/client-state.sqlite3` 以事务和 `synchronous=FULL` 保存配置、polling session、pending claim/renew/evidence 请求和 active claim。设备 token 与 claim token 只以 Windows 当前用户 DPAPI 密文 BLOB 保存;生产非 Windows 或 DPAPI/SQLite 异常失败闭合,测试注入 fake protector。数据库/WAL、日志、异常均不得出现明文 token。
|
||||||
|
5. Windows named mutex 保证同一 client 配置只有一个执行进程;服务端的单设备 claim 不能替代本机单实例。配置存在 pending/active claim 时禁止切换 service/device 身份。
|
||||||
|
6. 领取前先原子持久化 session_id+claim_request_id 再发请求。网络/503/截断/非法 2xx 后只保留并重放同一请求;明确 EMPTY 后下一轮才生成新 key。成功响应先 DPAPI 加密 token,再与完整快照单事务落库;响应与落库间崩溃可凭原 key 从服务端恢复同一 token。active 或未知 claim 结果存在时禁止领取第二条。
|
||||||
|
7. 续租前持久化 renew_request_id、session、attempt、generation、token 与 expected expiration;结果不明只重放完全相同载荷。成功后原子更新租约,不轮换 token、不递增 generation;过期、CAS/归属/幂等冲突不得换 key 续租、重新领取或自动转领。
|
||||||
|
8. EvidenceSink 只接收调用方显式传入的单个 PNG 与 T-204 元数据。首次调用前持久化 upload_key、hash 和文件身份;断网只重放同一 key/字节/元数据。文件缺失或 hash/大小变化停止,不枚举目录、不换截图、不上传 XML/manifest/本机路径/外部支付页,也不自行增加 claim token/session 字段。T-306 才把真实流程截图接入。
|
||||||
|
9. 停止/关闭语义冻结给 T-304:只阻止下一次新领取;已发 claim 必须处理或保留恢复,返回任务必须落 active;pending/active claim、renew 和证据不 release/abandon,不生成新 session/key,不自动恢复真机点击。租约过期交 T-207 人工处理。
|
||||||
|
10. 错误分类:本地状态/DPAPI 失败零 HTTP;401 停止并等凭据修复;403/协议型 4xx 停止;网络/超时/5xx 只允许同幂等键重放并由 T-304 计数;409 按固定 code 转人工且不换 key;`retryable` 绝不表示页面点击、围栏或提交可重试。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 覆盖 claim 成功/EMPTY/结果不明同 key 重放、进程重启恢复、响应漂移拒绝及 active claim 阻止第二次领取。
|
||||||
|
- 覆盖 renew CAS、同 key 重放、乱序/过期/冲突不复活,generation/token 不变化。
|
||||||
|
- 覆盖 DPAPI round-trip、损坏密文、SQLite/WAL 无明文 token、pending/active 时禁止换身份和本机双进程唯一。
|
||||||
|
- 覆盖精确 loopback URL、禁代理/重定向、严格 header/JSON/响应大小/RFC3339/UUID/金额/整数。
|
||||||
|
- 覆盖 evidence 显式 PNG、hash/大小、首次/重放、文件变化拒绝和绝不枚举/上传 XML/path。
|
||||||
|
- 覆盖停止发生在 claim 前/中/成功后均不丢 claim、不释放、不再领取;日志不泄露 Authorization/token。
|
||||||
|
- 静态检查 core/remote/localstate 不导入 PDD 点击、数量、确认页、event/fence/submit/payment 能力。
|
||||||
|
- client 全量 unittest、compileall、wheel metadata、根目录完整 init、上下文校验与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T13:27:53Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-304 预研反向约束 T-303:localstate 必须提供唯一的原子 API 给 UI 使用,至少覆盖 profile 配置读写、同 profile 单实例 guard、recovery snapshot、polling session start/resume/stop、pending claim/active claim 查询与原 session_id+claim_request_id 恢复;T-304 不得另写 SQLite/DPAPI/mutex。profile 需承载 service_url/device_id+加密 token、adb_path、serial、transport、poll interval、failure threshold、request/step timeout。当前服务端没有 heartbeat,配置页不能拿 claim-next 做无副作用连接探测,只能显示“凭据已安全保存,将在领取时验证”。
|
||||||
|
|
||||||
|
### 2026-08-04T13:41:49Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-306 预研反向收紧 T-303:localstate 必须按 (attempt_id, evidence_kind) 暴露唯一原子上传槽。首次 HTTP 前保存 upload_key、完整元数据、文件 identity/hash;结果不明只能恢复同一槽;成功后持久保留 AssetRef,重复调用返回原结果,不能清 pending 后生成新 key 或上传第二张。T-306 不得另建 SQLite。
|
||||||
|
|
||||||
|
### 2026-08-04T15:25:33Z · ila
|
||||||
|
|
||||||
|
2026-08-04 开始 T-303:基于 main@3a0a41d,在独立 worktree 实现客户端 HTTP 任务源、证据 sink 与可恢复本地状态;严格限制为 localhost 服务端契约,不触碰真机、页面操作、提交订单或付款能力。
|
||||||
|
|
||||||
|
### 2026-08-04T16:54:36Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-303 实现完成并进入审阅冻结:已落地严格 localhost HttpTaskSource/HttpEvidenceSink、durable gateway、DPAPI 身份绑定密文、SQLite append-only 状态图与 Global named mutex。claim/renew/evidence 均先持久化后最多一次 HTTP;business snapshot、renew response、evidence receipt 使用不可变约束与摘要,renew 从初始租约锚重放;证据绑定 PNG IHDR 尺寸并精确重放首次 metadata。独立对抗审计已通过。验证:client unittest 189/189,compileall、diff-check、agent-context 通过;根 init.ps1 通过 admin test/vet/build、client install/test/compile 与上下文门禁。任务保持 DOING,等待大脑固定提交复审,不标 DONE。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T13:27:53Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-304 预研反向约束 T-303:localstate 必须提供唯一的原子 API 给 UI 使用,至少覆盖 profile 配置读写、同 profile 单实例 guard、recovery snapshot、polling session start/resume/stop、pending claim/active claim 查询与原 session_id+claim_request_id 恢复;T-304 不得另写 SQLite/DPAPI/mutex。profile 需承载 service_url/device_id+加密 token、adb_path、serial、transport、poll interval、failure threshold、request/step timeout。当前服务端没有 heartbeat,配置页不能拿 claim-next 做无副作用连接探测,只能显示“凭据已安全保存,将在领取时验证”。
|
||||||
|
|
||||||
|
### 2026-08-04T13:41:49Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-306 预研反向收紧 T-303:localstate 必须按 (attempt_id, evidence_kind) 暴露唯一原子上传槽。首次 HTTP 前保存 upload_key、完整元数据、文件 identity/hash;结果不明只能恢复同一槽;成功后持久保留 AssetRef,重复调用返回原结果,不能清 pending 后生成新 key 或上传第二张。T-306 不得另建 SQLite。
|
||||||
|
|
||||||
|
### 2026-08-04T15:25:33Z · ila
|
||||||
|
|
||||||
|
2026-08-04 开始 T-303:基于 main@3a0a41d,在独立 worktree 实现客户端 HTTP 任务源、证据 sink 与可恢复本地状态;严格限制为 localhost 服务端契约,不触碰真机、页面操作、提交订单或付款能力。
|
||||||
|
|
||||||
|
### 2026-08-04T16:54:36Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-303 实现完成并进入审阅冻结:已落地严格 localhost HttpTaskSource/HttpEvidenceSink、durable gateway、DPAPI 身份绑定密文、SQLite append-only 状态图与 Global named mutex。claim/renew/evidence 均先持久化后最多一次 HTTP;business snapshot、renew response、evidence receipt 使用不可变约束与摘要,renew 从初始租约锚重放;证据绑定 PNG IHDR 尺寸并精确重放首次 metadata。独立对抗审计已通过。验证:client unittest 189/189,compileall、diff-check、agent-context 通过;根 init.ps1 通过 admin test/vet/build、client install/test/compile 与上下文门禁。任务保持 DOING,等待大脑固定提交复审,不标 DONE。
|
||||||
|
|
||||||
|
### 2026-08-04T17:01:28Z · ila
|
||||||
|
|
||||||
|
2026-08-05 主脑终审与合并完成:T-303 固定实现提交 b5bb45c 已通过第三方终验,无新阻断;主脑已合入 main@31f07ac,并在 main 上重跑 init.ps1 全绿。现按验收结果将线上任务标记完成并移动到 Done;title、description 与 labels 保持不变。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- MVP 设备 Bearer 只允许发往精确 `http://127.0.0.1:8080`;不得接受用户信息、远程 host、其他
|
||||||
|
端口、重定向、系统代理或明文局域网地址。每个 transport 方法最多发送一次请求,重试决策必须
|
||||||
|
留给持有原幂等键的上层恢复流程。
|
||||||
|
- 设备 token 与 claim token 只能以当前 Windows 用户范围 DPAPI 密文进入本地 SQLite;不得以明文
|
||||||
|
进入数据库/WAL、日志、异常、Git、Vikunja 或测试 fixture。DPAPI、SQLite、密文或状态校验失败时
|
||||||
|
必须在零 HTTP 下失败闭合;生产非 Windows 不得降级为明文存储。
|
||||||
|
- Claim/renew/evidence 的幂等键和完整载荷必须先持久化再发请求。超时、断网、503、截断或非法 2xx
|
||||||
|
只能重放同一键同一载荷;不得生成新 key、领取第二条、续租复活、替换截图或覆盖本地 active claim。
|
||||||
|
- 停止轮询、Esc、关闭窗口和进程退出都不 release/abandon 当前 claim,也不自动恢复真机页面动作。
|
||||||
|
已发 claim 的未知结果和 active claim 必须跨重启保留;租约过期交 T-207 人工处理,不自动转领。
|
||||||
|
- T-303 必须向 T-304 暴露唯一的原子 profile/session/recovery API;T-304 不得另建 SQLite、DPAPI
|
||||||
|
或 mutex 状态源。服务端尚无 heartbeat 时,不得把 `claim-next` 当作配置页连接探测,否则“测试连接”
|
||||||
|
会产生 EMPTY 幂等事实甚至领取任务;配置页只能说明凭据将在真实领取时验证。
|
||||||
|
- EvidenceSink 只传调用方显式给出的单个 PNG 和 T-204 固定字段;不得枚举目录、读取或上传 XML、
|
||||||
|
manifest、本机路径、外部支付页或支付凭据,不得自行向 multipart 增加 claim token/session 字段。
|
||||||
|
- Localstate 必须按 `(attempt_id, evidence_kind)` 提供唯一、原子的上传槽:首次 HTTP 前持久化 upload key、
|
||||||
|
完整元数据、文件身份和 hash;结果不明只能恢复同一槽,同一槽成功后继续保留服务端 AssetRef,后续
|
||||||
|
调用只能返回该结果,不能清掉 pending 后生成新 key 或上传第二张。T-306 不得另建 SQLite 绕过它。
|
||||||
|
- 本任务不修改 `app.py` 或实现配置/轮询 UI,不连接真机,不导入 PDD 点击能力。T-304 负责桌面交互,
|
||||||
|
T-306 负责把真实截图接入 sink。
|
||||||
|
- 不伪造尚不存在的 event/fail/fence/result HTTP,不提供假 `HttpResultSink`,不实现或引用
|
||||||
|
`click_permitted`、提交订单点击、支付、免密支付、先用后付或任何扣款控件代码。
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
---
|
||||||
|
id: T-304
|
||||||
|
title: 定时轮询、会话边界与采购工具主界面
|
||||||
|
phase: 3
|
||||||
|
deps: [T-303, T-006]
|
||||||
|
status: DOING
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 37
|
||||||
|
context_ref: 526af1e
|
||||||
|
work_branch: task/t-304-polling-session-ui
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: true
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-304.md
|
||||||
|
- client/src/cmbuyer_client/app.py
|
||||||
|
- client/src/cmbuyer_client/polling/**
|
||||||
|
- client/src/cmbuyer_client/ui/**
|
||||||
|
- client/src/cmbuyer_client/localstate/models.py
|
||||||
|
- client/src/cmbuyer_client/localstate/store.py
|
||||||
|
- client/tests/test_app.py
|
||||||
|
- client/tests/polling/**
|
||||||
|
- client/tests/ui/**
|
||||||
|
- client/tests/localstate/test_store.py
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=37 synced=2026-08-05T01:23:52Z sha256=7d7650322518979f6122417383d74ee2c399f3c93eacd6935c178c93dbb59e11 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-006 已确认采购工具双 Tab 原型,T-303 将提供唯一的 HTTP、本地恢复、DPAPI 与单实例底座。T-304 把原型转换为可测试的原生 PySide6 主界面和定时轮询协调器,但在真实单趟执行器接入前不得领取真实任务,避免形成无人消费的 active claim。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-005、F-008、F-012、F-013。
|
||||||
|
- 用户故事:US-003、US-004、US-007。
|
||||||
|
- 依赖:T-303、T-006;T-302/T-301/T-203/T-204 由 T-303 传递满足。
|
||||||
|
- 实现已确认的“采购工具”固定双 Tab:默认“采购执行”,另一个为“配置”。
|
||||||
|
- 不接入真机选择、事件、围栏、提交或付款;T-305 才注入受控单趟执行器。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 使用 `QMainWindow + QTabWidget` 建立不可关闭的固定 Tab,使用稳定 page id 而非显示索引;Tab 切换保留内部状态、选择、滚动和焦点。
|
||||||
|
2. 采购执行页顶部显示连接、轮询、当前任务与连续失败状态,开始/停止复用同一 `QAction`。独立运行且未注入 execution consumer 时开始按钮禁用,并显示“单趟执行能力尚未接入,不能领取真实任务”。
|
||||||
|
3. 主体使用 `QSplitter`。左侧 `QStackedWidget` 在“当前任务”和“采购记录详情”间切换;当前任务左侧文字、右侧图片约 2:1,下方为滚动日志。右侧使用 `QTableView + QAbstractTableModel` 显示按时间倒序的标题和状态,不放假记录。
|
||||||
|
4. 记录表单击只选择;双击非控件区域、Enter、显式“查看所选记录”和上下文命令复用同一 QAction。详情态选择另一行立即更新;Esc 仅从详情返回当前任务,不停止轮询、不关闭程序、不改变服务端状态,并恢复记录选择、滚动和焦点。
|
||||||
|
5. 配置页使用 `QScrollArea + QFormLayout` 显式保存。service URL 为只读精确 `http://127.0.0.1:8080`;token 使用密码框且永不回填,只显示“已保存/未保存”布尔状态。首次保存且 `has_stored_device_token=false` 时 token 必填;已有凭据时,规范化后的空输入表示保留现有 DPAPI 密文,非空输入才通过 T-303 原子 profile API 替换。成功后清空输入框;失败保留全部输入并聚焦首个错误。MVP 不提供清除/删除凭据命令,空值绝不能覆盖或删除既有凭据。
|
||||||
|
6. 配置承载 device id、ADB 路径、serial/transport、轮询间隔默认 15 秒且范围 5–300、连续失败阈值默认 3 且范围 1–10、HTTP 超时默认 10 秒且范围 1–120、真机步骤超时默认 45 秒且范围 5–300。只执行必填、格式、范围以及本地路径形态/存在性校验,不执行路径所指程序;pending/active claim 时冻结 service/device 身份。
|
||||||
|
7. 配置页不提供“检查连接/测试连接”按钮、QAction、worker 或隐藏探测。保存和本地校验不得发送 HTTP health、`claim-next`、`renew`、`evidence`,也不得调用 ADB、uiautomator2 或拼多多。稳定内联提示为“服务身份将在首次真实领取时验证;设备与 App 状态由后续已取证执行能力验证”。本地校验通过不等于服务、设备或 App 已就绪。
|
||||||
|
8. 轮询协调器状态固定为 STOPPED、STARTING、BLOCKED、RECOVERING、WAITING、CLAIMING、ACTIVE、RECOVERY_REQUIRED。启动必须先取得 T-303 单实例 guard 并加载 recovery snapshot;重启不自动开始,active claim 只显示待安全恢复。
|
||||||
|
9. 停止只设置 `accept_new_claims=false`。WAITING 取消下一计时器;CLAIMING 等待有界结果并先落库;返回 claim 后进入 RECOVERY_REQUIRED。不得中断已发请求、清 pending/active、release/abandon、生成新 key/session 或调用真机返回动作。
|
||||||
|
10. `QTimer` 只调度下一轮;阻塞 I/O 放长期 worker QObject/QThread,worker 不接触 Widget。claim 业务结果必须先交 T-303 持久化,不能因 UI generation 过期而丢弃;迟到的纯视图结果按稳定 request id 丢弃。
|
||||||
|
11. 连续失败只统计 T-303 标记为可用同幂等键安全重放的网络、超时、5xx 或截断;同 pending request 按轮询间隔重放,达到阈值立即停止。401、协议错误、409、DPAPI/SQLite 错误立即转持久 Banner,不进入普通重试。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 纯状态机覆盖开始、EMPTY、等待、停止、请求中停止、claim 落库、无重叠请求、同键恢复、失败阈值、401/403/409/本地错误和 active 阻止新领取。
|
||||||
|
- QtTest 覆盖默认 Tab、固定 Tab 状态、按钮禁用原因、记录倒序、单击/双击/Enter/按钮、详情切换、Esc 层级与焦点恢复。
|
||||||
|
- 配置测试覆盖:首次无凭据时空 token 拒绝且零 T-303 写入;已有凭据时空 token 原样保留既有 DPAPI 密文;非空 token 只替换一次且成功后清空输入;失败保留输入并聚焦首个错误;界面无 token 回读和清除/删除命令,日志与异常均无 token。
|
||||||
|
- 配置页不存在连接检查命令;保存、本地校验以及 consumer 未注入时点击禁用的开始入口均产生零 HTTP health、零 `claim-next`/`renew`/`evidence`、零 ADB/uiautomator2/PDD,并显示延后验证提示。静态检查不得出现网络或 ADB 探测调用链。
|
||||||
|
- compact/medium/wide 不重建模型或丢选择;长文案、100%–200% DPI、浅色/深色/高对比与纯键盘行为可人工复核。
|
||||||
|
- 应用独立启动、切换 Tab、Esc、查看记录与关闭窗口均产生零 claim、零 release、零 ADB;代码不导入 PDD 点击、数量、确认页、围栏、提交或付款能力。
|
||||||
|
- client 全量 unittest、compileall、wheel metadata、根目录完整 init、Vikunja 导出、上下文校验与 diff-check 通过。
|
||||||
|
|
||||||
|
### T-303 metadata-only 配置摘要与 Stop epoch 补充
|
||||||
|
|
||||||
|
1. 允许精确扩展 localstate/models.py、store.py 与对应 test_store.py,只增加 metadata-only profile summary/settings 读取;不得调用 DPAPI 或返回 token/密文。
|
||||||
|
2. Stop 必须提升 epoch/latch,使旧 timer dispatch 失效;飞行中结果先 durable commit,再按 CLAIMED/EMPTY/错误进入安全停止或恢复态。
|
||||||
|
3. 存在 pending/active 时冻结全部非 token 配置,只允许同 device id 替换 token;关闭窗口不得 terminate 正在 I/O 的 QThread,须等待有界返回与落库。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T13:35:53Z · ila
|
||||||
|
|
||||||
|
2026-08-04 已按确认原型与 Windows UI/UX 规范落成任务:采用固定双 Tab、稳定 ID 的主从工作区、非模态持久错误、Esc 只退出记录详情、显式配置保存,以及停止仅阻止下一次领取的会话边界。独立运行时未注入单趟执行器,开始轮询必须禁用,确保零真实 claim、零 ADB;依赖 T-303、T-006 完成后再开工。
|
||||||
|
|
||||||
|
### 2026-08-04T17:56:06Z · ila
|
||||||
|
|
||||||
|
2026-08-05 T-304 代码实现与自动门禁已完成,任务继续保持 DOING,等待 needs_human_review 的原生 Windows 人工复核。
|
||||||
|
|
||||||
|
实现提交:42d52c8638f3d7fb3b500749ec2c9dd66d14303f;审计修复提交:73c438710bd975176d50166631a7ea2f6dbdc13f。已落地固定双 Tab、无真实 consumer 时禁领、长期 QThread worker 与 QTimer 调度、重启恢复与 Stop epoch/latch、严格歧义重放白名单、无秘密 UI DTO、配置保存/冻结语义、非模态记录详情和 Esc 返回。
|
||||||
|
|
||||||
|
第一轮 fixed-commit 审计发现并修复:65 位 token 被控件静默截断、consumer 无法获得本次 Start 冻结配置、metadata-only summary 对损坏密文/存储配置未充分失败闭合。新增首次及替换 token 零写入、consumer 趟内配置快照、空 BLOB/错误 storage class/损坏 URL、transport、range 且零 unprotect 回归。第二轮独立终审对 73c4387 结论为 PASS。
|
||||||
|
|
||||||
|
验证:聚焦 41 tests PASS;client 全量 229 tests PASS;compileall PASS;wheel build 与 verify_wheel_metadata PASS;仓库根 init.ps1 PASS(admin test/vet/build、client 229 tests、compileall、agent-context validator)。Qt offscreen 环境缺少可用字体目录,结构与交互截图已检查,但中文字体、DPI、主题、高对比度和纯键盘体验仍须在原生 Windows 人工验收,因此 done=false。
|
||||||
|
|
||||||
|
### 2026-08-04T18:02:15Z · ila
|
||||||
|
|
||||||
|
2026-08-05 主脑已将 T-304 合并到 main 并完成合并后验证。main 最终代码 SHA:3a27225;其中 UI 主提交:dfd88c3。
|
||||||
|
|
||||||
|
合并后仓库根 init.ps1 PASS;client 全量 232 tests PASS。设置 QT_QPA_FONTDIR=C:\Windows\Fonts 后完成离线截图复核,中文字与整体结构均可读,主脑视觉结构复核结论为 PASS。
|
||||||
|
|
||||||
|
当前仅完成自动化门禁与离线视觉结构复核。原生 Windows 下的 DPI、浅色/深色主题、高对比度、Narrator 以及纯键盘体验仍需人工验收,因此任务继续保持 DOING,Vikunja 保持 done=false 与 Doing。
|
||||||
|
|
||||||
|
### 2026-08-05T01:19:13Z · ila
|
||||||
|
|
||||||
|
2026-08-05 T-304 完整套件偶发测试修复:
|
||||||
|
|
||||||
|
- 完整测试套件暴露 `test_stop_during_claim_commits_then_requires_recovery_without_consumer_delivery` 与 FakeGateway 旧 2 秒等待位于同一时间边界的竞态;这是测试同步问题,不是生产轮询语义变化。
|
||||||
|
- 提交 `c9b39a4` 仅在测试替身增加 worker `entered` event,并把 gate 改为 5 秒有界、超时显式失败;未修改生产代码。测试继续验证重复 dispatch、Stop epoch/latch、旧 epoch 拒绝、consumer 零投递与 durable recovery。
|
||||||
|
- 独立审计 PASS;polling module 13 tests PASS,client 全量 233 tests PASS,仓库根 `init.ps1` PASS。
|
||||||
|
- 原生 Windows 人工 UI 验收仍未完成;T-304 保持 done=false / Doing 与 needs_human_review。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 本任务只能消费 T-303 暴露的 profile/session/recovery API,不得另建 SQLite、DPAPI、mutex、
|
||||||
|
HTTP task source 或第二套 active-claim 状态源。配置或恢复状态损坏必须失败闭合,不能用界面默认值
|
||||||
|
覆盖持久业务事实。
|
||||||
|
- 独立应用在未注入单趟 execution consumer 时必须禁用“开始轮询”,且启动、切换 Tab、Esc、查看记录、
|
||||||
|
保存配置和关闭窗口都必须产生零 claim、零 ADB。不能为了演示界面领取真实任务,也不能填充会被当作
|
||||||
|
真实状态的假记录。
|
||||||
|
- 停止轮询只阻止下一次新领取。不得取消已发出的 claim、清除 pending/active、release/abandon、
|
||||||
|
生成新 session 或幂等键、自动转领,也不得触发真机 Back、退出页面或其他设备动作;返回的 claim
|
||||||
|
必须先由 T-303 原子落库再更新 UI。
|
||||||
|
- 重启不自动开始轮询,不自动恢复任何真机点击。pending claim 只能用原 session/request 恢复;active
|
||||||
|
claim 只显示“待安全恢复”并阻止新领取,租约过期或 409 交 T-207 人工处理。
|
||||||
|
- 配置页只允许必填、格式、范围以及本地路径形态/存在性校验,不得提供或隐藏任何网络/ADB“连接检查”;
|
||||||
|
保存、校验都不得调用 HTTP health、`claim-next`、`renew`、`evidence`,也不得执行 ADB、uiautomator2
|
||||||
|
或拼多多探测。界面必须明确提示“服务身份将在首次真实领取时验证;设备与 App 状态由后续已取证执行
|
||||||
|
能力验证”,本地校验通过不能显示服务、设备或 App 已就绪。
|
||||||
|
- 服务地址只读为精确 `http://127.0.0.1:8080`。首次保存且没有既有 DPAPI 凭据时 token 必填;已有
|
||||||
|
凭据时空输入表示原样保留现有密文,非空输入才通过 T-303 的原子 profile API 替换,绝不能用空值
|
||||||
|
覆盖或删除。保存成功后清空 token 输入框且永不回填;保存失败保留输入并聚焦首个错误。MVP 不提供
|
||||||
|
清除/删除凭据命令,token 不得进入日志或异常文本。
|
||||||
|
- Qt worker 不得直接访问 Widget,UI 回调不得丢弃迟到的 claim 业务结果。连续失败只允许用 T-303
|
||||||
|
已持久化的同一幂等键重放安全网络操作;`retryable` 绝不推导为页面点击、证据替换、下单或提交可重试。
|
||||||
|
- 记录表的双击仅是非破坏性详情入口,并必须有 Enter 和显式按钮等价路径。Esc 只在详情态返回当前任务,
|
||||||
|
不停止轮询、不关闭窗口、不释放 claim、不改变服务端状态;返回后按稳定记录 ID 恢复选择、滚动和焦点。
|
||||||
|
- 不导入或实现 PDD 页面判据、规格选择、数量、确认页、证据上传、事件、失败上报、提交围栏或结果接口;
|
||||||
|
不编写或引用点击“提交订单”的代码,不编写支付、免密支付、先用后付或任何扣款控件代码。
|
||||||
|
- T-303 的 `load_profile()` 会解密设备 token,配置页不得为了读取普通设置或“已保存”布尔状态调用它。
|
||||||
|
本任务只可在既有 localstate store 增加 metadata-only 的 profile summary/settings 只读 API:沿用同一
|
||||||
|
SQLite 表与事务,不调用 DPAPI、不返回密文或明文 token、不新增存储、mutex 或第二套状态源。
|
||||||
|
- Stop 必须提升会话 epoch/latch,先取消 timer 并使已经排队的旧 epoch dispatch 失效。飞行中领取仍等待
|
||||||
|
HTTP 自身有界结果并由 T-303 完成 durable commit:成功领取转 `RECOVERY_REQUIRED`,EMPTY 转
|
||||||
|
`STOPPED`,结果不明、401、409、协议或本地错误保留原槽并停止;只有下一次显式 Start 才可同 key 恢复。
|
||||||
|
关闭窗口同样不得 terminate 正在 I/O 的 QThread,必须等待其有界返回与落库后再结束 worker。
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
id: T-305
|
||||||
|
title: 围栏前单趟 dry-run 编排
|
||||||
|
phase: 3
|
||||||
|
deps: [T-107, T-304, T-306, T-307, T-210]
|
||||||
|
status: TODO
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 44
|
||||||
|
context_ref: c1cee49
|
||||||
|
work_branch: task/t-305-prefence-dry-run
|
||||||
|
needs_device: true
|
||||||
|
needs_human_review: true
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-305.md
|
||||||
|
- client/src/cmbuyer_client/execution/**
|
||||||
|
- client/src/cmbuyer_client/core/**
|
||||||
|
- client/src/cmbuyer_client/remote/**
|
||||||
|
- client/src/cmbuyer_client/localstate/**
|
||||||
|
- client/src/cmbuyer_client/polling/**
|
||||||
|
- client/src/cmbuyer_client/ui/**
|
||||||
|
- client/src/cmbuyer_client/app.py
|
||||||
|
- client/tests/execution/**
|
||||||
|
- client/tests/core/**
|
||||||
|
- client/tests/remote/**
|
||||||
|
- client/tests/localstate/**
|
||||||
|
- client/tests/polling/**
|
||||||
|
- client/tests/ui/**
|
||||||
|
- client/tests/test_app.py
|
||||||
|
- docs/api.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/06-tasks.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=44 synced=2026-08-04T14:13:33Z sha256=01cdc47330b88791356618a90caed5e884666ad1b153ed136e6d3aacf51d8faf -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-103~T-107 分段固化真机页面能力,T-303/T-304 提供本地恢复与桌面执行壳,T-306/T-307 提供三闸门证据上传和 attempt 事件/失败 sink,T-210 扩展完整三闸门证据类型。T-305 只把这些已完成能力组合为围栏前 dry-run,验证领取后同一趟执行可以安全到达最终提交控件观察点,并在任何不可逆动作前停止。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-005、F-006、F-007、F-013、F-017 的围栏前部分。
|
||||||
|
- 用户故事:US-003、US-004、US-005。
|
||||||
|
- 依赖:T-107、T-304、T-306、T-307、T-210。
|
||||||
|
- 后续消费者:T-208 提交围栏与真实单次提交。
|
||||||
|
- 本任务不得调用 submission fence、result 或订单提交能力。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 实现窄 PreFenceDryRunOrchestrator,只依赖已冻结的 claim/renew、attempt event/fail、三闸门 EvidenceSink、T-103/T-105/T-107 页面能力和 T-304 UI 执行外壳;不直接实现 HTTP、PDD selector 或新页面判据。
|
||||||
|
2. 首次领取后把 authorization/task/attempt/generation/claim/版本快照和执行载荷原子写入 T-303 localstate;页面动作前写 action journal。重启只调和 HTTP 与展示人工恢复状态,不自动续跑任何手机点击。
|
||||||
|
3. 同一趟严格执行:规格面板闸门一 → 数量设置与闸门二 → 唯一证据绑定的确认页导航 → 闸门三与最终提交控件只读观察 → 一次安全 Back。任一前置条件、读回、价格或证据失败立即 fail closed。
|
||||||
|
4. 三闸门每次都先把原始 PNG 交 T-306 持久化固定 upload_key/metadata/hash,再用 T-307 追加对应成功事件;事件只能引用服务端确认的 AssetRef。未知上传结果只重放同键同文件,不重做页面动作或替换证据。
|
||||||
|
5. 动作日志使用固定动作类型、attempt/generation/阶段、开始时间与明确后置状态;不保存 selector、坐标、自由页面文本、XML、地址、手机号或 token。点击结果不明时停止并转人工,不以重启或超时自动重试。
|
||||||
|
6. 完成闸门三观察且一次 Back 验证安全返回后,写固定失败终止 reason DRY_RUN_COMPLETED_BEFORE_FENCE,使 attempt/task 进入围栏前人工状态。不得请求或模拟围栏,不得创建 order_submission。
|
||||||
|
7. HTTP 出站仅允许 T-303 transport 固定 loopback API;import 闭包和静态测试禁止 SubmissionPermit、fence/result client、click_permitted、submitter、submit_order_once、支付模块。
|
||||||
|
8. UI 显示当前执行阶段、已确认的最小任务摘要和滚动日志;Esc 只处理详情/当前任务切换,不取消在途请求、不清本地 journal、不释放 claim、不触发真机动作。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 离线组合测试覆盖正常三闸门、每一步失败、价格/规格/数量漂移、证据未知结果、事件未知结果、租约/授权到期、重启和 active claim 漂移。
|
||||||
|
- 证明每个页面动作前已有 journal;不明结果和重启均零自动页面动作,只有同键 HTTP 调和。
|
||||||
|
- 证明 Gate1/Gate2/Gate3 的 PNG、captured_at 与 AssetRef 顺序正确,事件不能先于证据成功,也不能串 attempt/generation。
|
||||||
|
- 正常 dry-run 最终只产生 DRY_RUN_COMPLETED_BEFORE_FENCE,不存在 fence、order_submission、result 或提交点击。
|
||||||
|
- 静态调用图/import 闭包证明不可达 submission fence、SubmissionPermit、click_permitted、submit_order_once 与任何付款能力。
|
||||||
|
- 真机验收必须由人完成,记录设备/Android/拼多多版本、三个证据路径、三闸门读回、最终控件只读观察和一次 Back 后置状态;确认前保持 DOING。
|
||||||
|
- client 全量 unittest、compileall、wheel metadata、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T14:11:20Z · ila
|
||||||
|
|
||||||
|
2026-08-04 任务定值:T-305 只做围栏前单趟 dry-run,总装已验收的 T-107/T-304/T-306/T-307/T-210 能力;正常路径最终写 DRY_RUN_COMPLETED_BEFORE_FENCE 并安全返回,不请求围栏、不创建 order_submission、不点击提交订单、不涉及付款。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 只实现 `PreFenceDryRunOrchestrator` 与围栏前 UI 组合;不得在本任务的构造、import 闭包或运行时
|
||||||
|
引入 submission fence/result client、`SubmissionPermit`、`click_permitted`、submitter、
|
||||||
|
`submit_order_once()`、`order_submission` 或任何付款能力。
|
||||||
|
- 本任务不得修改 `client/src/cmbuyer_client/pdd/**` 或新增页面 selector/坐标/判据;只调用依赖任务
|
||||||
|
已完成人工验收且版本绑定的窄能力。未完成验收、版本不匹配或观察不唯一时必须停止,不能猜测。
|
||||||
|
- 同一趟动作顺序固定为闸门一、数量与闸门二、证据绑定的唯一确认页导航、闸门三/最终控件只读观察、
|
||||||
|
一次安全 Back。最终控件不得暴露节点/selector/坐标/句柄,也不得发生点击。
|
||||||
|
- 每个真机动作前必须先写固定 action journal。结果不明、超时、停止、Esc、关闭窗口或重启均不得
|
||||||
|
自动重试页面动作、续跑下一步、清 journal、释放 claim 或转领;重启只允许同键同载荷 HTTP 调和。
|
||||||
|
- 三闸门截图必须先经 T-306 取得服务端确认的 `AssetRef`,再由 T-307 写对应固定事件;不得先写成功
|
||||||
|
事件、替换不明证据、串 attempt/generation,或上传 XML/manifest/目录。
|
||||||
|
- 正常 dry-run 必须以固定 reason `DRY_RUN_COMPLETED_BEFORE_FENCE` 在围栏前安全终止;不得请求、
|
||||||
|
模拟或伪造围栏,不得创建待付款订单。本任务不编写支付、免密支付、先用后付或任何扣款代码。
|
||||||
|
- `needs_device: true` 的最终验收只能由人完成。离线验证通过后仍保持 `DOING`,直到人确认三闸门
|
||||||
|
证据、最终提交控件仅被观察、一次 Back 安全返回且没有创建订单。
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
id: T-306
|
||||||
|
title: 规格面板原始截图与可靠证据上传
|
||||||
|
phase: 3
|
||||||
|
deps: [T-104, T-303]
|
||||||
|
status: TODO
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 38
|
||||||
|
context_ref: 3a0a41d
|
||||||
|
work_branch: task/t-306-evidence-upload
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-306.md
|
||||||
|
- client/src/cmbuyer_client/evidence/**
|
||||||
|
- client/tests/evidence/**
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=38 synced=2026-08-04T15:31:58Z sha256=9d3d86a2c34aab67a4ada76f3735ee8c79eddb89b1112b697f43b108a629af57 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-104 将在规格选择、闸门一读价、原始截图和严格安全退出全部成功后返回显式 PNG 路径与实际 UTC 截图时刻;T-303 提供受设备认证的 EvidenceSink 与可崩溃恢复的唯一上传槽。T-306 只把调用方显式给出的这张 Gate1 规格面板原始截图可靠上传为内部审计证据,不连接 UI/轮询、不扩展页面能力或服务端证据类型。
|
||||||
|
|
||||||
|
服务端当前只以设备主体与 upload_key 保证幂等,并没有数据库级 `(attempt_id, kind)` 唯一约束。因此“一个 attempt 只发布一张 Gate1”必须由 T-303 本地唯一槽失败闭合保证;T-306 不能在本地状态丢失或损坏后生成新 key、扫描目录或寻找替代资产。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-007、F-013。
|
||||||
|
- 用户故事:US-004、US-005。
|
||||||
|
- 依赖:T-104、T-303;T-103 由 T-104 传递,T-204/T-302 由 T-303 传递。
|
||||||
|
- 后续消费者:T-305 围栏前 dry-run。
|
||||||
|
- 本任务可与 T-304、T-205 并行,不修改 app/polling/ui、remote/localstate、PDD 或 admin。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 唯一公开接口固定为 `publish_gate1(active_claim, *, screenshot_path: Path, captured_at: datetime) -> AssetRef`。调用方不能传入 kind、privacy tier、task id 或 attempt id;接口不接受目录、manifest 或任意文件选择结果。
|
||||||
|
2. Publisher 内部固定 `kind=SKU_PANEL_GATE_1`、`privacy_tier=INTERNAL_RAW`。task/attempt/device/session/generation 归属只取 T-303 的完整 active_claim,不拆散、不重建,也不允许调用方参数覆盖。
|
||||||
|
3. `screenshot_path` 与 UTC-aware `captured_at` 只来自 T-104 成功结果。T-104 只有在规格选择、闸门一读价、原始截图和严格安全退出全部成功后才返回;post-exit 商品详情截图不得冒充 Gate1。
|
||||||
|
4. Publisher 只做窄参数适配并对 T-303 EvidenceSink 调用一次,不直接实现或调用 HTTP、SQLite、UUID、DPAPI、localstate,也不导入 PDD、ADB、uiautomator2。每次 publish 调用最多一次 sink;异常或结果不明不得内部循环重试。
|
||||||
|
5. `lstat`、常规文件与 PNG 判定、文件 identity/大小/SHA-256、固定安全 multipart 文件名、首次 HTTP 前持久化以及 `(attempt_id, evidence_kind)` 唯一 prepare-or-resume 槽全部由 T-303 负责。T-306 不另建缓存、数据库、key 或恢复状态。
|
||||||
|
6. 本地上传槽缺失、损坏或无法与完整 active_claim 核对时必须零 HTTP 失败闭合。不得生成新 upload key、枚举目录、读取 manifest、查询相似资产、改用另一张截图或重新截图;结果不明只能经 T-303 以原 key、原字节和原元数据恢复。
|
||||||
|
7. T-303 对 201 首次成功和 200 稳定重放严格解析 AssetRef;Publisher 再核对 AssetRef 的 task/attempt 与 active_claim 一致,kind/tier 与内部固定值一致。fake sink 返回错误 task、attempt、kind 或 tier 时必须拒绝,不能把不一致引用交给后续事件。
|
||||||
|
8. 成功 AssetRef 继续保存在原槽;重复调用只返回同一结果,不能清 pending/success 后生成新 key。claim 后续关闭时,只允许原 key 对既有成功资产的稳定重放,不允许创建新槽。
|
||||||
|
9. 原始 PNG 字节保持不变,可以包含页面已显示的地址和手机号;不遮罩、不裁剪、不重编码、不 OCR,也不把页面内容解析为字段、日志或异常。完整 XML、manifest、本机路径、原文件名、外部支付页和支付凭据永不上传。
|
||||||
|
10. multipart 只使用 T-204 已冻结的字段,不增加 claim token/session/path,不扩展 SKU_PANEL_GATE_2、确认页或其他 kind;不新增页面判据、PDD 动作、事件、围栏、结果或提交能力。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 静态签名精确为 `publish_gate1(active_claim, *, screenshot_path: Path, captured_at: datetime) -> AssetRef`;kind/tier/task/attempt 不能成为调用方参数,内部只固定 Gate1/InternalRaw。
|
||||||
|
- T-104 成功产物只调用一次 sink;安全退出失败、无 active claim、claim 归属不一致、UTC 时间非法、文件缺失或变化均零 HTTP,且 Publisher 不做内部重试。
|
||||||
|
- 首次传输前 T-303 唯一槽已持久化;结果不明和重启后 key/bytes/metadata 原样重放,重复成功返回同一 AssetRef,关闭 claim 后只能重放既有成功资产。
|
||||||
|
- 本地槽缺失、损坏或不可验证时零 HTTP,证明不会生成新 key、扫描目录、读取 manifest、寻找替代文件或重新截图。
|
||||||
|
- fake sink 分别返回错误 task、attempt、kind、tier 的 AssetRef 时 Publisher 全部拒绝;正确 AssetRef 原样返回。
|
||||||
|
- 用包含地址/手机号的合成有效 PNG 证明传给 sink 的原始字节完全不变;日志和异常不含页面正文、token、本机路径或原文件名。
|
||||||
|
- 拒绝目录、XML、manifest、JPEG、post-exit 截图和未批准 kind;静态检查没有目录枚举、图像变换/OCR、HTTP/SQLite/UUID、uiautomator2、PDD/ADB、数量、确认页、围栏、提交订单或付款能力。
|
||||||
|
- client 全量 unittest、compileall、wheel metadata、完整 init、Vikunja 导出、上下文校验与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T13:47:38Z · ila
|
||||||
|
|
||||||
|
2026-08-04 预研定值:T-306 收窄为 T-104 gate1 单张原始 PNG 到 T-303 唯一持久上传槽的可靠适配,因此可与 T-304/T-205 并行;不扩展服务端 kind、不另建 SQLite、不连接真机或 UI。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 唯一发布接口固定为
|
||||||
|
`publish_gate1(active_claim, *, screenshot_path: Path, captured_at: datetime) -> AssetRef`。调用方不得
|
||||||
|
传入 kind、privacy tier、task id 或 attempt id;`kind=SKU_PANEL_GATE_1` 与
|
||||||
|
`privacy_tier=INTERNAL_RAW` 只能由 Publisher 内部固定,task/attempt/device/session/generation 归属
|
||||||
|
只能取自 T-303 的完整 `active_claim`,不得拆散、重建或由其他参数覆盖。
|
||||||
|
- `screenshot_path` 与 UTC-aware `captured_at` 只能来自 T-104 在规格选择、闸门一读价、规格面板原始
|
||||||
|
截图和严格安全退出全部成功后返回的显式结果。不得枚举目录、读取 manifest、使用文件选择器,也不得
|
||||||
|
把 post-exit 商品详情截图或其他文件冒充规格面板证据。
|
||||||
|
- 只发布一个 `kind=SKU_PANEL_GATE_1`、`privacy_tier=INTERNAL_RAW` 的原始 PNG;task/attempt 只来自
|
||||||
|
当前 active claim。不得增加 gate2/确认页 kind,不得修改 admin schema/API,也不得自行向 multipart
|
||||||
|
增加 claim token、session、路径、原文件名或其他字段。
|
||||||
|
- Publisher 每次调用只适配一次 T-303 `EvidenceSink`,不得直接实现或调用 HTTP、SQLite、UUID、DPAPI
|
||||||
|
或本地状态细节,也不得导入 PDD/ADB。`lstat`、常规 PNG 判定、文件 identity/大小/SHA-256、首次发送
|
||||||
|
前原子持久化和 `(attempt_id, evidence_kind)` 唯一恢复槽全部由 T-303 负责。
|
||||||
|
- 服务端当前只保证设备主体与 `upload_key` 幂等,不保证数据库级 `(attempt_id, kind)` 唯一。因此本地
|
||||||
|
上传槽缺失、损坏或无法验证时必须在零 HTTP 下失败闭合;绝不能生成新 key、扫描目录、读取 manifest、
|
||||||
|
查询相似资产或重新截图来“恢复”。结果不明只能经 T-303 以原 key、原字节、原元数据恢复。
|
||||||
|
- T-303 返回的 AssetRef 必须再次匹配当前 active claim 的 task/attempt 以及固定 kind/tier;fake sink
|
||||||
|
返回错 task、attempt、kind 或 tier 时 Publisher 必须拒绝,不能把不一致引用交给后续事件。成功
|
||||||
|
AssetRef 继续保存在原槽,重复调用只能返回同一结果,不能清槽后生成第二个 key。
|
||||||
|
- 每次调用最多一次 HTTP,不在内部循环重试。文件缺失或 identity/大小/hash 变化时必须零 HTTP 并
|
||||||
|
转人工;不得重新截图、替换文件或在目录中寻找“相似”截图。
|
||||||
|
- 停止轮询、Esc、关闭窗口和进程退出不取消在途上传、不清 pending、不删除原图、不 release/abandon
|
||||||
|
claim,也不触发任何 ADB/PDD 动作。重启只可恢复同一 HTTP 请求,不自动恢复设备页面操作。
|
||||||
|
- 原始 PNG 字节必须保持不变,可以包含页面已显示的地址和手机号;不得遮罩、裁剪、重编码、OCR,
|
||||||
|
也不得把地址或手机号解析为业务字段、日志或异常文本。完整 XML、manifest、本机路径、页面全文、
|
||||||
|
外部支付页和支付凭据不上传。
|
||||||
|
- 不导入或实现 uiautomator2、PDD 页面判据、通用 click、规格/数量/确认页动作、事件、失败上报、
|
||||||
|
提交围栏或结果接口;不编写或引用点击“提交订单”的代码,不编写支付或任何扣款能力。
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
id: T-307
|
||||||
|
title: 客户端 attempt 事件与失败 sink
|
||||||
|
phase: 3
|
||||||
|
deps: [T-205, T-303]
|
||||||
|
status: TODO
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 39
|
||||||
|
context_ref: 35d7ce1
|
||||||
|
work_branch: task/t-307-attempt-sink
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-307.md
|
||||||
|
- client/src/cmbuyer_client/attempts/**
|
||||||
|
- client/src/cmbuyer_client/core/**
|
||||||
|
- client/src/cmbuyer_client/remote/**
|
||||||
|
- client/src/cmbuyer_client/localstate/**
|
||||||
|
- client/tests/attempts/**
|
||||||
|
- client/tests/core/**
|
||||||
|
- client/tests/remote/**
|
||||||
|
- client/tests/localstate/**
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=39 synced=2026-08-04T13:50:06Z sha256=3f13f517446bbcab0a4415eaf2232a990053d35fad510ef296fe38b075282ee0 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-205 将冻结 attempt events/fail 的严格服务端 wire,T-303 提供安全 HTTP 传输原则、DPAPI/SQLite 与恢复快照,但刻意不伪造完整 ResultSink。T-307 补齐客户端围栏前 HttpAttemptSink 和事件/失败幂等状态,使 T-305 只组合已完成能力;不连接真机、UI、证据、围栏或结果接口。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-006、F-007。
|
||||||
|
- 用户故事:US-003、US-004、US-005。
|
||||||
|
- 依赖:T-205、T-303。
|
||||||
|
- 后续消费者:T-305。
|
||||||
|
- API:只消费 T-205 的 events 与 fail;不实现 fence/result。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 定义窄 AttemptSink 协议:append_events 与 fail_attempt。模型只含 T-205 固定 step/outcome/reason、规范十进制金额、正整数和受控 AssetRef;不接收自由页面文本、XML、路径、token、selector、坐标或动作脚本。
|
||||||
|
2. HttpAttemptSink 复用 T-303 直连 transport 规则:精确 loopback、禁代理/重定向、有界请求/响应、严格 Content-Type/UTF-8/重复 key/未知字段;每次方法最多一个 HTTP 请求,不隐藏重试。
|
||||||
|
3. 扩展 T-303 localstate,而不创建第二个数据库:event batch 与 fail 在首次 HTTP 前原子保存幂等 key、active claim 绑定、完整载荷与预期序号/状态;结果不明和重启只能重放原键原载荷。
|
||||||
|
4. event 成功必须严格核对服务端批次结果并持久化已确认序号/摘要;同批重复只返回原结果。异载荷、序号漂移、服务端摘要漂移或 active claim 不一致转协议冲突,不覆盖本地事实。
|
||||||
|
5. fail 仅消费 T-205 固定 reason 与 last_safe_step;成功后原子保存终态响应。结果不明保留同 key,不能创建第二个 fail 或把任务本地假设为已释放。
|
||||||
|
6. 401、403、409、协议错误、本地 DPAPI/SQLite 错误按 T-303 分类停止;网络/超时/5xx 只标记同键可恢复。retryable 永不表示页面动作、证据替换、围栏或提交可重试。
|
||||||
|
7. 停止轮询/关闭窗口不取消在途请求、不清 pending、不 release/abandon claim。重启可调和 HTTP,但绝不自动恢复真机点击。
|
||||||
|
8. 构造与 import 闭包中没有 fence client、ResultSink、SubmissionPermit、click_permitted、submitter、PDD 或 payment 能力。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 覆盖 event/fail 成功、严格 schema、同键同载荷重放、异载荷冲突、响应截断/漂移、401/403/409/503。
|
||||||
|
- 证明首次网络前已持久化;崩溃与重启后键/载荷/claim 绑定完全一致,active claim 漂移时零 HTTP。
|
||||||
|
- 并发 append 序列化且不跳号;fail 与未决 event 的顺序失败闭合;成功结果不可覆盖。
|
||||||
|
- SQLite/WAL、日志、异常不含 claim/device token、页面全文、XML、本机路径或 selector。
|
||||||
|
- 静态检查没有 evidence 文件读取、uiautomator2/PDD、release/abandon、fence/result、提交订单或付款能力。
|
||||||
|
- client 全量 unittest、compileall、wheel metadata、完整 init、Vikunja 导出、上下文校验与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T13:49:39Z · ila
|
||||||
|
|
||||||
|
2026-08-04 从 T-305 预研拆出:只实现 events/fail 的客户端窄 sink 与本地幂等恢复,避免在 dry-run 集成任务中临时扩展 T-303;不实现完整 ResultSink、fence/result 或真机/UI。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 只实现 T-205 已冻结的 `append_events` 与 `fail_attempt` 窄客户端契约;不得提供完整 ResultSink,
|
||||||
|
不得访问 submission fence、result、`click_permitted`、SubmissionPermit 或提交许可。
|
||||||
|
- 事件只含版本化固定 step/outcome/reason、规范十进制金额、正整数和受控 AssetRef。不得接收或发送
|
||||||
|
自由页面文本、完整 XML、地址、手机号、本机路径、token、selector、坐标或动作脚本。
|
||||||
|
- HTTP 必须复用 T-303 精确 loopback、禁代理/重定向、严格解析与每方法一次传输边界。本任务不得
|
||||||
|
建第二个 transport 或隐藏重试循环;`retryable` 只允许同幂等键 HTTP 恢复,不能推导页面动作可重试。
|
||||||
|
- Event batch 与 fail 的幂等键、active-claim 绑定和完整载荷必须先进入 T-303 同一 localstate 事务再
|
||||||
|
发送。结果不明、重启和停止只能重放原键原载荷;不得另建 SQLite、换 key、跳序号或覆盖已确认摘要。
|
||||||
|
- 停止轮询、Esc、关闭窗口和进程退出不取消在途请求、不清 pending、不 release/abandon claim,
|
||||||
|
也不触发 ADB/PDD 动作。重启只可调和同一 attempt 的 HTTP 事实,不自动恢复任何真机点击。
|
||||||
|
- 不读取或上传 evidence 文件,不导入 uiautomator2/PDD,不实现规格、数量、确认页、围栏或结果动作;
|
||||||
|
不编写或引用点击“提交订单”的代码,不编写支付、免密支付、先用后付或任何扣款能力。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user