Compare commits

...
Author SHA1 Message Date
QiuSW 025edaf273 docs(tasks): record T-304 code-complete review 2026-08-05 02:03:02 +08:00
QiuSW 584c5601c5 task(T-304): record code-complete verification 2026-08-05 01:59:35 +08:00
QiuSW 3a27225977 fix(client): fail closed on polling configuration drift 2026-08-05 01:59:35 +08:00
QiuSW dfd88c3336 feat(client): add safe polling session UI 2026-08-05 01:59:35 +08:00
QiuSW 20112dbd61 feat(client): expose secret-free profile summary 2026-08-05 01:59:35 +08:00
QiuSW 404d0d3ca4 task(T-304): add metadata and stop-latch contract 2026-08-05 01:59:35 +08:00
QiuSW 85e5f1e56e task(T-304): begin polling session UI 2026-08-05 01:59:35 +08:00
QiuSW 6145fc4468 docs(tasks): complete T-211 claim wire bounds 2026-08-05 01:52:49 +08:00
QiuSW f6cd65208d fix(api): align claim bounds across runtime snapshots 2026-08-05 01:39:35 +08:00
QiuSW 7ea1c5349f fix(api): bound claim wire fields end to end 2026-08-05 01:25:23 +08:00
QiuSW 57a5b2e91c chore(tasks): include T-211 claim handler boundary 2026-08-05 01:18:23 +08:00
QiuSW 63c2c59b41 chore(tasks): start T-211 claim wire bounds 2026-08-05 01:11:21 +08:00
QiuSW cfb238d022 docs(tasks): complete T-303 durable client state 2026-08-05 01:04:35 +08:00
QiuSW 31f07ac245 feat(client): add durable HTTP task state 2026-08-05 00:59:32 +08:00
QiuSW 488005ac93 chore(tasks): start T-303 client HTTP state 2026-08-05 00:59:32 +08:00
QiuSW 4186f1315a docs(tasks): tighten exit and evidence contracts 2026-08-04 23:36:01 +08:00
QiuSW 3a0a41db00 docs(tasks): complete MVP safety task graph 2026-08-04 23:20:28 +08:00
QiuSW 8600c33391 docs(tasks): define admin safety closure chain 2026-08-04 22:44:13 +08:00
QiuSW 8092431208 docs(tasks): order evidence before attempt events 2026-08-04 22:41:59 +08:00
QiuSW cd47d0c959 docs(tasks): complete T-302 atomic claim 2026-08-04 22:34:46 +08:00
QiuSW ec42640123 Merge branch 'main' into task/t-302-atomic-claim 2026-08-04 22:33:25 +08:00
QiuSW 5f060f60ee feat(admin): add atomic task claim leases 2026-08-04 22:33:16 +08:00
QiuSW 19f81a5e58 docs(tasks): define T-305 prefence dry run 2026-08-04 22:14:55 +08:00
QiuSW c1cee49fb1 docs(tasks): define T-210 gate evidence kinds 2026-08-04 22:08:58 +08:00
QiuSW 772e379fd6 docs(tasks): define T-107 confirm gate 2026-08-04 22:05:20 +08:00
QiuSW 83b244ff7c docs(tasks): require T-106 navigation-source evidence 2026-08-04 22:02:43 +08:00
QiuSW d9a31cbfa6 docs(tasks): define T-106 confirm evidence 2026-08-04 22:01:45 +08:00
QiuSW 014405466a docs(tasks): define T-105 quantity gate 2026-08-04 21:59:21 +08:00
QiuSW 8c50e1579e docs(tasks): define T-307 attempt sink 2026-08-04 21:50:15 +08:00
QiuSW 35d7ce11d6 docs(tasks): define T-306 evidence publisher 2026-08-04 21:48:22 +08:00
QiuSW 41e54e313e docs(tasks): expose T-104 gate-one artifact 2026-08-04 21:46:54 +08:00
QiuSW e5de76503f docs(tasks): linearize T-205 ordering version 2026-08-04 21:45:31 +08:00
QiuSW 542b2283f4 docs(tasks): freeze T-303 evidence upload slot 2026-08-04 21:42:15 +08:00
QiuSW eb29bcd8b7 docs(tasks): define T-304 polling UI boundary 2026-08-04 21:36:52 +08:00
QiuSW 526af1eb31 docs(tasks): allow T-302 taskdetail fixture repair 2026-08-04 21:33:22 +08:00
QiuSW d9381a644b docs(tasks): define T-303 UI state boundary 2026-08-04 21:28:08 +08:00
QiuSW e863a29641 docs(tasks): allow T-303 roadmap sync 2026-08-04 21:19:58 +08:00
QiuSW 5c97954fa1 docs(tasks): define T-303 HTTP recovery client 2026-08-04 21:19:02 +08:00
QiuSW bd4c855ad2 docs(tasks): harden T-302 replay ownership 2026-08-04 21:15:59 +08:00
QiuSW ee90d76893 docs(tasks): linearize T-302 revocation checks 2026-08-04 21:11:26 +08:00
QiuSW 4a65138fd7 docs(tasks): define T-205 attempt events 2026-08-04 21:07:43 +08:00
QiuSW 8ec740dcef chore(tasks): start T-302 2026-08-04 21:03:38 +08:00
QiuSW 590c84660a docs(tasks): define T-302 atomic claiming 2026-08-04 21:02:57 +08:00
QiuSW 233188297a docs(tasks): complete T-301 2026-08-04 20:58:36 +08:00
QiuSW e674b7f131 merge: T-301 device credential isolation 2026-08-04 20:57:42 +08:00
102 changed files with 12805 additions and 201 deletions
+4
View File
@@ -13,6 +13,8 @@
| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
| `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 |
| `CMBUYER_CLAIM_TOKEN_SECRET` | claim token 专用 32 字节密钥的 64 位小写十六进制;不得复用 session 或设备 token。 |
| `CMBUYER_CLAIM_LEASE_TTL` | 正 Go duration,且必须严格短于 `CMBUYER_AUTHORIZATION_TTL`。 |
示例仅展示变量名,不提供可运行凭据:
@@ -26,6 +28,8 @@ $env:CMBUYER_AUTHORIZATION_TTL = '10m'
$env:CMBUYER_MAX_TASK_QUANTITY = '99'
$env:CMBUYER_MAX_TOTAL_PRICE = '999.99'
$env:CMBUYER_EVIDENCE_DIR = '<内部截图绝对目录>'
$env:CMBUYER_CLAIM_TOKEN_SECRET = '<64 位小写十六进制随机值>'
$env:CMBUYER_CLAIM_LEASE_TTL = '1m'
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
go run ./cmd/server
```
+6
View File
@@ -11,6 +11,7 @@ import (
"cmbuyer/admin/internal/server"
evidencestorage "cmbuyer/admin/internal/storage/evidence"
"cmbuyer/admin/internal/storage/sqlite"
"cmbuyer/admin/internal/taskclaim"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
)
@@ -52,6 +53,10 @@ func run() error {
if err != nil {
return err
}
claimStore, err := taskclaim.NewStore(database, configuration.ClaimTokenSecret, configuration.ClaimLeaseTTL)
if err != nil {
return err
}
router, err := server.NewRouter(server.Options{
AdminUsername: configuration.AdminUsername,
@@ -61,6 +66,7 @@ func run() error {
TaskDetails: detailStore,
Evidence: evidenceStore,
DeviceAuthenticator: deviceAuthenticator,
TaskClaims: claimStore,
})
if err != nil {
return err
+29
View File
@@ -2,6 +2,8 @@
package config
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"os"
@@ -23,6 +25,8 @@ const (
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
claimTokenSecretEnv = "CMBUYER_CLAIM_TOKEN_SECRET"
claimLeaseTTLEnv = "CMBUYER_CLAIM_LEASE_TTL"
minimumSecretLength = 32
)
@@ -37,6 +41,8 @@ type Config struct {
MaxTaskQuantity int
MaxTotalPrice string
EvidenceDirectory string
ClaimTokenSecret []byte
ClaimLeaseTTL time.Duration
}
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
@@ -112,6 +118,27 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
}
claimSecretText, err := required(lookup, claimTokenSecretEnv)
if err != nil {
return Config{}, err
}
claimSecret, err := hex.DecodeString(claimSecretText)
if err != nil || len(claimSecret) != 32 || hex.EncodeToString(claimSecret) != claimSecretText {
return Config{}, fmt.Errorf("%s must be exactly 64 lowercase hexadecimal characters", claimTokenSecretEnv)
}
// Claim ownership, admin sessions and device authentication are separate security domains.
// Reject both identical configuration text and identical effective key bytes.
if claimSecretText == secret || bytes.Equal(claimSecret, []byte(secret)) {
return Config{}, fmt.Errorf("%s must be isolated from %s", claimTokenSecretEnv, sessionSecretEnv)
}
claimTTLText, err := required(lookup, claimLeaseTTLEnv)
if err != nil {
return Config{}, err
}
claimTTL, err := time.ParseDuration(claimTTLText)
if err != nil || claimTTL <= 0 || claimTTL >= ttl {
return Config{}, fmt.Errorf("%s must be positive and shorter than %s", claimLeaseTTLEnv, authorizationTTLEnv)
}
return Config{
AdminUsername: username,
@@ -121,6 +148,8 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
DatabaseSource: databaseSource,
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
EvidenceDirectory: evidenceDirectory,
ClaimTokenSecret: claimSecret,
ClaimLeaseTTL: claimTTL,
}, nil
}
+12 -1
View File
@@ -3,6 +3,7 @@ package config_test
import (
"strings"
"testing"
"time"
"cmbuyer/admin/internal/config"
@@ -25,13 +26,15 @@ func TestLoad(t *testing.T) {
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
"CMBUYER_CLAIM_LEASE_TTL": "1m",
}
got, err := config.Load(lookup(values))
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.AdminUsername != "admin" || !got.CookieSecure {
if got.AdminUsername != "admin" || !got.CookieSecure || len(got.ClaimTokenSecret) != 32 || got.ClaimLeaseTTL != time.Minute {
t.Fatalf("Load returned unexpected public configuration: %#v", got)
}
}
@@ -51,6 +54,8 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
"CMBUYER_CLAIM_LEASE_TTL": "1m",
}
tests := []struct {
@@ -68,6 +73,12 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
{"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"},
{"missing evidence directory", func(values map[string]string) { delete(values, "CMBUYER_EVIDENCE_DIR") }, "CMBUYER_EVIDENCE_DIR"},
{"relative evidence directory", func(values map[string]string) { values["CMBUYER_EVIDENCE_DIR"] = "evidence" }, "CMBUYER_EVIDENCE_DIR"},
{"invalid claim secret", func(values map[string]string) { values["CMBUYER_CLAIM_TOKEN_SECRET"] = strings.Repeat("A", 64) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
{"claim secret same raw session secret", func(values map[string]string) {
values["CMBUYER_SESSION_SECRET"] = values["CMBUYER_CLAIM_TOKEN_SECRET"]
}, "CMBUYER_CLAIM_TOKEN_SECRET"},
{"claim secret same decoded session secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = strings.Repeat("\xab", 32) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
{"invalid claim lease ttl", func(values map[string]string) { values["CMBUYER_CLAIM_LEASE_TTL"] = "10m" }, "CMBUYER_CLAIM_LEASE_TTL"},
}
for _, test := range tests {
+157 -12
View File
@@ -26,7 +26,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("apply migrations: %v", err)
}
assertVersion(t, database, 4)
assertVersion(t, database, 5)
assertTableExists(t, database, "tasks", true)
assertTableExists(t, database, "spec_trials", false)
assertTableExists(t, database, "order_authorizations", true)
@@ -34,12 +34,20 @@ func TestUpDownAndIdempotence(t *testing.T) {
assertTableExists(t, database, "order_submissions", true)
assertTableExists(t, database, "evidence_assets", true)
assertTableExists(t, database, "device_credentials", true)
assertTableExists(t, database, "purchase_attempt_claims", true)
assertTableExists(t, database, "single_pass_upgrade_guard", false)
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply migrations: %v", err)
}
assertVersion(t, database, 5)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back task claim migration: %v", err)
}
assertVersion(t, database, 4)
assertTableExists(t, database, "purchase_attempt_claims", false)
assertTableExists(t, database, "device_credentials", true)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back device credential migration: %v", err)
@@ -65,7 +73,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply v2 after rollback: %v", err)
}
assertVersion(t, database, 4)
assertVersion(t, database, 5)
}
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
@@ -84,7 +92,7 @@ func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("upgrade v1 draft: %v", err)
}
assertVersion(t, database, 4)
assertVersion(t, database, 5)
var got struct {
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
quantity, version int
@@ -235,9 +243,7 @@ func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
migrateToV3(t, database)
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
@@ -271,9 +277,6 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
})
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("roll back empty device credential migration: %v", err)
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("evidence-bearing schema downgraded successfully")
}
@@ -287,9 +290,7 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
migrateToV4(t, database)
deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
hash := make([]byte, 32)
for index := range hash {
@@ -354,6 +355,132 @@ func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
}
}
func TestTaskClaimMigrationGuardsOwnershipConstraintsAndDowngradeFacts(t *testing.T) {
t.Run("upgrade rejects unmappable execution facts atomically", func(t *testing.T) {
database := openTestDatabase(t)
migrateToV4(t, database)
insertV2Task(t, database, "legacy-task", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "legacy-auth", "legacy-task", 1, "legacy-start")
insertV2Attempt(t, database, "legacy-attempt", "legacy-task", "legacy-auth", 1)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("v5 upgrade accepted an attempt without device/session ownership")
}
assertVersion(t, database, 4)
assertTableExists(t, database, "purchase_attempt_claims", false)
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&count); err != nil || count != 1 {
t.Fatalf("legacy attempt after rejected upgrade = %d, err %v", count, err)
}
})
t.Run("schema binds authorization device session generation and token", func(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
deviceA := "13c9f507-7473-4fa6-8d71-8786c34c6301"
deviceB := "23c9f507-7473-4fa6-8d71-8786c34c6301"
sessionA := "33c9f507-7473-4fa6-8d71-8786c34c6301"
sessionB := "43c9f507-7473-4fa6-8d71-8786c34c6301"
taskA := "53c9f507-7473-4fa6-8d71-8786c34c6301"
authA := "63c9f507-7473-4fa6-8d71-8786c34c6301"
attemptA := "73c9f507-7473-4fa6-8d71-8786c34c6301"
taskB := "83c9f507-7473-4fa6-8d71-8786c34c6301"
authB := "93c9f507-7473-4fa6-8d71-8786c34c6301"
attemptB := "a3c9f507-7473-4fa6-8d71-8786c34c6301"
tokenA := make([]byte, 32)
for index := range tokenA {
tokenA[index] = byte(index + 1)
}
for index, device := range []string{deviceA, deviceB} {
hash := make([]byte, 32)
hash[0] = byte(index + 100)
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, device, "device "+strconv.Itoa(index), hash, migrationTime); err != nil {
t.Fatalf("insert device: %v", err)
}
}
insertV2Task(t, database, taskA, "MANUAL", "DRAFT")
insertV2Authorization(t, database, authA, taskA, 1, "start-a")
insertV2Attempt(t, database, attemptA, taskA, authA, 1)
insertClaim := `INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, ?, ?, 2, 'task', 1, 'goods', 'white', 'XL', 1, '1.00',
'2026-08-04T01:00:00Z', ?, ?, '2026-08-04T00:05:00Z', ?, NULL)`
if _, err := database.Exec(insertClaim, attemptA, taskA, authA, deviceA, sessionA, 1, make([]byte, 32), tokenA, migrationTime); err != nil {
t.Fatalf("insert valid claim: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempts
(id,task_id,authorization_id,claim_generation,status,started_at)
VALUES ('b3c9f507-7473-4fa6-8d71-8786c34c6301', ?, ?, 2, 'CLAIMED', ?)`, taskA, authA, migrationTime); err == nil {
t.Fatal("second attempt for one authorization succeeded")
}
insertV2Task(t, database, taskB, "MANUAL", "DRAFT")
insertV2Authorization(t, database, authB, taskB, 1, "start-b")
insertV2Attempt(t, database, attemptB, taskB, authB, 1)
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceB, sessionB, 2, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
t.Fatal("claim with generation different from its attempt succeeded")
}
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceA, sessionB, 1, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
t.Fatal("second open claim for one device succeeded")
}
claimRequest := `INSERT INTO task_claim_requests
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
VALUES (?, ?, ?, 'CLAIMED', ?, '2026-08-04T00:05:00Z', NULL, ?)`
if _, err := database.Exec(claimRequest, "c3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionB, attemptA, migrationTime); err == nil {
t.Fatal("claim request with another session succeeded")
}
if _, err := database.Exec(claimRequest, "d3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionA, attemptA, migrationTime); err != nil {
t.Fatalf("insert bound claim request: %v", err)
}
renewal := `INSERT INTO purchase_attempt_lease_renewals
(renew_request_id,task_id,attempt_id,device_id,session_id,claim_generation,
claim_token_sha256,expected_lease_expires_at,lease_expires_at,created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, '2026-08-04T00:05:00Z', '2026-08-04T00:06:00Z', ?)`
if _, err := database.Exec(renewal, "e3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 2, tokenA, migrationTime); err == nil {
t.Fatal("renewal with another generation succeeded")
}
wrongHash := append([]byte(nil), tokenA...)
wrongHash[0] ^= 0xff
if _, err := database.Exec(renewal, "f3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 1, wrongHash, migrationTime); err == nil {
t.Fatal("renewal with another token hash succeeded")
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("claim-bearing schema downgraded successfully")
}
assertVersion(t, database, 5)
assertTableExists(t, database, "purchase_attempt_claims", true)
})
t.Run("empty request alone blocks downgrade", func(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatal(err)
}
device := "13c9f507-7473-4fa6-8d71-8786c34c6301"
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'device', ?, 'ACTIVE', ?, NULL)`, device, make([]byte, 32), migrationTime); err != nil {
t.Fatal(err)
}
if _, err := database.Exec(`INSERT INTO task_claim_requests
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
VALUES ('23c9f507-7473-4fa6-8d71-8786c34c6301', ?,
'33c9f507-7473-4fa6-8d71-8786c34c6301', 'EMPTY', NULL, NULL, NULL, ?)`, device, migrationTime); err != nil {
t.Fatal(err)
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("EMPTY request was silently dropped by downgrade")
}
assertVersion(t, database, 5)
})
}
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
tests := []struct {
name string
@@ -416,6 +543,24 @@ func migrateToV2(t *testing.T, database *sql.DB) {
assertVersion(t, database, 2)
}
func migrateToV3(t *testing.T, database *sql.DB) {
t.Helper()
migrateToV2(t, database)
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v3: %v", err)
}
assertVersion(t, database, 3)
}
func migrateToV4(t *testing.T, database *sql.DB) {
t.Helper()
migrateToV3(t, database)
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v4: %v", err)
}
assertVersion(t, database, 4)
}
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil {
+27
View File
@@ -134,6 +134,7 @@ func TestRealDeviceCredentialIdentityIsolationAndMixedCredentials(t *testing.T)
if err != nil {
t.Fatalf("issue credential: %v", err)
}
insertEvidenceClaim(t, database, issued.DeviceID)
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil {
t.Fatalf("new authenticator: %v", err)
@@ -353,6 +354,8 @@ func newEvidenceRouter(t *testing.T, authenticator deviceauth.Authenticator) (ht
t.Fatalf("migrate database: %v", err)
}
insertEvidenceAttempt(t, database)
insertEvidenceClaimDevice(t, database, evidenceDeviceID)
insertEvidenceClaim(t, database, evidenceDeviceID)
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("new evidence store: %v", err)
@@ -384,6 +387,30 @@ func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
}
}
func insertEvidenceClaimDevice(t *testing.T, database *sql.DB, deviceID string) {
t.Helper()
digest := sha256.Sum256([]byte("fake evidence device"))
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'fake evidence device', ?, 'ACTIVE', '2026-08-04T00:00:00Z', NULL)`, deviceID, digest[:]); err != nil {
t.Fatalf("insert evidence device: %v", err)
}
}
func insertEvidenceClaim(t *testing.T, database *sql.DB, deviceID string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, '23c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
1, '123', 'black', 'M', 1, '1.00', '2026-08-04T00:00:00Z', ?, ?,
'2026-08-04T02:00:00Z', '2026-08-04T00:00:00Z', NULL)`, evidenceAttemptID,
evidenceTaskID, evidenceAuthID, deviceID, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32)); err != nil {
t.Fatalf("insert evidence claim: %v", err)
}
}
func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder {
t.Helper()
request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra)
+5 -1
View File
@@ -16,6 +16,7 @@ import (
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/taskclaim"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
"cmbuyer/admin/internal/transport/webui"
@@ -36,11 +37,12 @@ type Options struct {
TaskDetails taskdetail.Store
Evidence evidence.Store
DeviceAuthenticator deviceauth.Authenticator
TaskClaims taskclaim.Service
}
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
func NewRouter(options Options) (*gin.Engine, error) {
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil {
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil || options.TaskClaims == nil {
return nil, errors.New("server authentication options are incomplete")
}
@@ -57,6 +59,8 @@ func NewRouter(options Options) (*gin.Engine, error) {
router.POST("/tasks", createTask(options))
router.POST("/tasks/start-purchases", startPurchases(options))
router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(options))
router.POST("/api/v1/tasks/claim-next", claimNext(options))
router.POST("/api/v1/tasks/:id/lease/renew", renewLease(options))
router.GET("/evidence/:asset_id", readEvidence(options))
router.GET("/static/tasks.js", func(context *gin.Context) {
context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript())
+16
View File
@@ -15,6 +15,7 @@ import (
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/server"
"cmbuyer/admin/internal/taskclaim"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
@@ -493,6 +494,10 @@ func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Man
}
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) {
return newRouterWithClaimService(t, store, details, evidenceStore, deviceAuthenticator, emptyTaskClaimService{})
}
func newRouterWithClaimService(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator, claims taskclaim.Service) (*gin.Engine, *auth.Manager) {
t.Helper()
gin.SetMode(gin.TestMode)
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
@@ -508,6 +513,7 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
TaskDetails: details,
Evidence: evidenceStore,
DeviceAuthenticator: deviceAuthenticator,
TaskClaims: claims,
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
@@ -517,6 +523,16 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
type emptyDetailStore struct{}
type emptyTaskClaimService struct{}
func (emptyTaskClaimService) ClaimNext(context.Context, string, taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
return taskclaim.ClaimResponse{}, false, nil
}
func (emptyTaskClaimService) Renew(context.Context, string, taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
return taskclaim.RenewResponse{}, taskclaim.ErrNotCurrent
}
func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
return taskdetail.Detail{}, taskdetail.ErrNotFound
}
+176
View File
@@ -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})
}
+215
View File
@@ -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
}
+8 -3
View File
@@ -185,11 +185,16 @@ func (store *Store) Commit(ctx context.Context, principal deviceauth.Principal,
return existing, true, nil
}
var attemptCount int
if err := transaction.QueryRowContext(ctx, "SELECT COUNT(*) FROM purchase_attempts WHERE task_id = ? AND id = ?", metadata.TaskID, metadata.AttemptID).Scan(&attemptCount); err != nil {
var ownedClaimCount int
if err := transaction.QueryRowContext(ctx, `SELECT COUNT(*) FROM purchase_attempt_claims
WHERE task_id = ? AND attempt_id = ? AND claimed_by_device_id = ? AND closed_at IS NULL`,
metadata.TaskID, metadata.AttemptID, principal.ID).Scan(&ownedClaimCount); err != nil {
return core.Asset{}, false, err
}
if attemptCount != 1 {
// Evidence is auditable only when the authenticated device owns the current attempt. The
// idempotent asset lookup above deliberately remains first so closing a claim later cannot
// destroy stable replay of an already committed screenshot.
if ownedClaimCount != 1 {
return core.Asset{}, false, core.ErrInvalid
}
@@ -82,6 +82,41 @@ func TestStageCommitReplayAndOpen(t *testing.T) {
}
}
func TestCommitRequiresCurrentClaimOwnerButClosedClaimKeepsHistoricalReplay(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
metadata := testMetadata(sha256Hex(pngBytes))
stage := func() core.StagedFile {
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
return staged
}
otherDevice := deviceauth.Principal{ID: "73c9f507-7473-4fa6-8d71-8786c34c6301"}
if _, _, err := store.Commit(context.Background(), otherDevice, metadata, stage()); !errors.Is(err, core.ErrInvalid) {
t.Fatalf("device B upload to device A attempt error = %v", err)
}
principal := deviceauth.Principal{ID: testDeviceID}
asset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
if err != nil || replayed {
t.Fatalf("owner first Commit = replayed %v, err %v", replayed, err)
}
if _, err := database.Exec("UPDATE purchase_attempt_claims SET closed_at='2026-08-04T03:00:00Z' WHERE attempt_id=?", testAttemptID); err != nil {
t.Fatalf("close claim: %v", err)
}
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
if err != nil || !replayed || replayedAsset.ID != asset.ID {
t.Fatalf("closed claim historical replay = %#v replayed %v err %v", replayedAsset, replayed, err)
}
newMetadata := metadata
newMetadata.UploadKey = "83c9f507-7473-4fa6-8d71-8786c34c6301"
if _, _, err := store.Commit(context.Background(), principal, newMetadata, stage()); !errors.Is(err, core.ErrInvalid) {
t.Fatalf("closed claim new upload error = %v", err)
}
}
func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
@@ -494,6 +529,12 @@ func newTestStore(t *testing.T) (*sql.DB, *Store) {
func insertAttemptFixture(t *testing.T, database *sql.DB) {
t.Helper()
timestamp := "2026-08-04T00:00:00Z"
tokenHash := sha256.Sum256([]byte("evidence-device-token"))
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'evidence device', ?, 'ACTIVE', ?, NULL)`, testDeviceID, tokenHash[:], timestamp); err != nil {
t.Fatalf("insert device: %v", err)
}
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, testTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err)
}
@@ -503,6 +544,15 @@ func insertAttemptFixture(t *testing.T, database *sql.DB) {
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, testAttemptID, testTaskID, testAuthID, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, '63c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
1, '123', 'black', 'M', 1, '1.00', ?, ?, ?, '2026-08-04T02:00:00Z', ?, NULL)`,
testAttemptID, testTaskID, testAuthID, testDeviceID, timestamp, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), timestamp); err != nil {
t.Fatalf("insert claim: %v", err)
}
}
func testMetadata(hash string) core.UploadMetadata {
+823
View File
@@ -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
+52
View File
@@ -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
}
+100
View File
@@ -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)
}
+19 -4
View File
@@ -14,14 +14,20 @@ import (
)
const (
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
detailDevice = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
database := openDetailDatabase(t)
timestamp := "2026-08-04T00:00:00Z"
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'detail test device', zeroblob(32), 'ACTIVE', ?, NULL)`, detailDevice, timestamp); err != nil {
t.Fatalf("insert device: %v", err)
}
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'shirt', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 3, ?, ?)`, detailTask, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err)
}
@@ -31,8 +37,17 @@ func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, detailTry, detailTask, detailAuth, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, 'f3c9f507-7473-4fa6-8d71-8786c34c6301', 1, 3, 'shirt',
2, '123', 'black', 'M', 2, '30.00', ?, zeroblob(32), zeroblob(32),
'2026-08-04T00:05:00Z', ?, NULL)`, detailTry, detailTask, detailAuth, detailDevice, timestamp, timestamp); err != nil {
t.Fatalf("insert claim: %v", err)
}
hash := strings.Repeat("a", 64)
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, 'device', ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", timestamp, timestamp); err != nil {
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, ?, ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", detailDevice, timestamp, timestamp); err != nil {
t.Fatalf("insert evidence: %v", err)
}
store, err := NewSQLiteStore(database)
+6 -1
View File
@@ -50,7 +50,7 @@ type TaskRow struct {
}
func normalizeCents(value string) (string, *big.Int, bool) {
if value == "" || strings.TrimSpace(value) != value {
if value == "" || len(value) > MaxMoneyASCIICharacters || strings.TrimSpace(value) != value {
return "", nil, false
}
parts := strings.Split(value, ".")
@@ -71,6 +71,11 @@ func normalizeCents(value string) (string, *big.Int, bool) {
return value, cents, true
}
func ValidCanonicalMoney(value string) bool {
canonical, _, ok := normalizeCents(value)
return ok && canonical == value
}
func startItems(command StartCommand) ([]StartItem, error) {
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
return nil, ErrInvalidStart
@@ -8,6 +8,7 @@ import (
"math"
"reflect"
"sort"
"strings"
"sync"
"testing"
"time"
@@ -164,12 +165,27 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
"nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='937x' WHERE id=?`, id)
},
"overlong goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id=? WHERE id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
},
"invalid utf8 title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), id)
},
"overlong title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
},
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
},
"overlong color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
},
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
},
"overlong size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
},
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxQuantity = 1
},
@@ -179,6 +195,9 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
"noncanonical leading zero": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='012.80' WHERE id=?`, id)
},
"overlong canonical price": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price=? WHERE id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
},
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxTotalPrice = "12.79"
},
@@ -199,6 +218,43 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
}
}
func TestStartPurchasesReplayRejectsMalformedAuthorizationOrTaskSnapshot(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB, string){
"authorization goods id": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE task_id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
},
"authorization color": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE task_id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
},
"authorization size": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE task_id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
},
"authorization money": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE task_id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
},
"task title": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
},
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}
if _, err := store.StartPurchases(context.Background(), command, "admin"); err != nil {
t.Fatal(err)
}
mutate(t, database, id)
if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("replay error = %v, want ErrStartConflict", err)
}
assertAuthorizationCount(t, database, 1)
})
}
}
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
for _, test := range []struct {
name string
+25 -15
View File
@@ -142,7 +142,9 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
}
return StartResult{}, err
}
if status != "DRAFT" || version != item.ExpectedTaskVersion || !goodsIDValid(goods) || color == "" || size == "" || quantity < 1 || quantity > store.policy.MaxQuantity {
if status != "DRAFT" || version != item.ExpectedTaskVersion ||
!ValidTaskWireFields(title, goods, color, size, price) ||
quantity < 1 || quantity > store.policy.MaxQuantity {
return StartResult{}, ErrStartConflict
}
canonical, cents, ok := normalizeCents(price)
@@ -179,20 +181,15 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
return result, nil
}
func goodsIDValid(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) {
rows, err := tx.QueryContext(ctx, "SELECT id,task_id,task_version,expires_at FROM order_authorizations WHERE start_key=? ORDER BY task_id", startKey)
rows, err := tx.QueryContext(ctx, `SELECT authorizations.id,authorizations.task_id,
authorizations.task_version,authorizations.expires_at,authorizations.goods_id,
authorizations.sku_color,authorizations.sku_size,authorizations.quantity,
authorizations.total_price_cap,tasks.title,tasks.goods_id,tasks.sku_color,
tasks.sku_size,tasks.quantity,tasks.max_total_price
FROM order_authorizations AS authorizations
JOIN tasks ON tasks.id = authorizations.task_id
WHERE authorizations.start_key=? ORDER BY authorizations.task_id`, startKey)
if err != nil {
return StartResult{}, false, err
}
@@ -201,9 +198,22 @@ func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []Start
for rows.Next() {
var item AuthorizedTask
var expires string
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires); err != nil {
var authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice string
var taskTitle, taskGoodsID, taskColor, taskSize, taskPrice string
var authorizationQuantity, taskQuantity int
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires,
&authorizationGoodsID, &authorizationColor, &authorizationSize, &authorizationQuantity,
&authorizationPrice, &taskTitle, &taskGoodsID, &taskColor, &taskSize, &taskQuantity,
&taskPrice); err != nil {
return StartResult{}, false, err
}
if !ValidAuthorizationFields(authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice) ||
authorizationQuantity <= 0 ||
!ValidTaskWireFields(taskTitle, taskGoodsID, taskColor, taskSize, taskPrice) || taskQuantity <= 0 ||
authorizationGoodsID != taskGoodsID || authorizationColor != taskColor ||
authorizationSize != taskSize || authorizationQuantity != taskQuantity || authorizationPrice != taskPrice {
return StartResult{}, false, ErrStartConflict
}
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
if err != nil {
return StartResult{}, false, err
+5
View File
@@ -37,6 +37,11 @@ func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
}
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
// Validate again at the persistence boundary. HTTP form validation is not the only
// caller, and a malformed row here would later make an authorized claim unencodable.
if !validUUID(draft.ID) || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) || draft.Quantity <= 0 {
return Draft{}, ErrInvalidDraft
}
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
defer cancel()
// SQLite permits one writer at a time. Serializing this store's short create
+66 -7
View File
@@ -9,14 +9,21 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
maxTitleLength = 120
maxSKUText = 80
MaxTitleCodePoints = 120
MaxSKUTextCodePoints = 80
MaxGoodsIDCharacters = 32
MaxMoneyASCIICharacters = 32
maxSKUText = MaxSKUTextCodePoints
)
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
var (
ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
ErrInvalidDraft = errors.New("invalid draft")
)
type Draft struct {
ID string
@@ -41,13 +48,13 @@ func Validate(form Form) (Draft, Errors) {
if !validUUID(draft.ID) {
errors["create_key"] = "创建请求已过期,请重新打开表单。"
}
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
if !validBoundedText(draft.Title, MaxTitleCodePoints) {
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
}
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
if !validBoundedText(draft.SKUColor, MaxSKUTextCodePoints) {
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
}
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
if !validBoundedText(draft.SKUSize, MaxSKUTextCodePoints) {
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
}
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
@@ -93,6 +100,9 @@ func CanonicalGoodsID(value string) (string, bool) {
return "", false
}
}
if !ValidGoodsID(goodsIDs[0]) {
return "", false
}
return goodsIDs[0], true
}
@@ -155,5 +165,54 @@ func normalizeMoney(value string) (string, bool) {
if whole == "0" && strings.Trim(fraction, "0") == "" {
return "", false
}
return whole + "." + (fraction + "00")[:2], true
canonical := whole + "." + (fraction + "00")[:2]
if len(canonical) > MaxMoneyASCIICharacters {
return "", false
}
return canonical, true
}
// ValidTaskWireFields is shared by creation, authorization and claim. Keeping one
// bounded domain prevents a database row from being valid in one stage but impossible
// to encode inside the fixed claim response budget in another stage.
func ValidTaskWireFields(title, goodsID, skuColor, skuSize, maxTotalPrice string) bool {
return validBoundedText(title, MaxTitleCodePoints) &&
ValidAuthorizationFields(goodsID, skuColor, skuSize, maxTotalPrice)
}
func ValidAuthorizationFields(goodsID, skuColor, skuSize, totalPriceCap string) bool {
return ValidGoodsID(goodsID) &&
validBoundedText(skuColor, MaxSKUTextCodePoints) &&
validBoundedText(skuSize, MaxSKUTextCodePoints) &&
ValidCanonicalMoney(totalPriceCap)
}
func ValidGoodsID(value string) bool {
if value == "" || len(value) > MaxGoodsIDCharacters {
return false
}
for index := 0; index < len(value); index++ {
if value[index] < '0' || value[index] > '9' {
return false
}
}
return true
}
func validBoundedText(value string, maximum int) bool {
// RuneCountInString replaces malformed byte sequences with RuneError. Validate first
// so corrupt SQLite text cannot consume the code-point budget as if it were legitimate.
if !utf8.ValidString(value) || value == "" || strings.TrimSpace(value) != value ||
utf8.RuneCountInString(value) > maximum {
return false
}
for _, character := range value {
// Python str.strip treats these four C0 separators as whitespace while Go
// TrimSpace does not. Reject them everywhere so both wire models have one
// explicit persisted-text domain instead of runtime-dependent trimming.
if character >= '\u001c' && character <= '\u001f' {
return false
}
}
return true
}
+88 -9
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"testing"
"time"
@@ -38,13 +39,16 @@ func TestValidateNormalizesManualDraft(t *testing.T) {
func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
for name, update := range map[string]func(*Form){
"empty title": func(form *Form) { form.Title = " " },
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
"zero quantity": func(form *Form) { form.Quantity = "0" },
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
"empty title": func(form *Form) { form.Title = " " },
"invalid utf8 title": func(form *Form) { form.Title = string([]byte{0xff}) },
"long title": func(form *Form) { form.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
"invalid utf8 size": func(form *Form) { form.SKUSize = string([]byte{0xff}) },
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
"zero quantity": func(form *Form) { form.Quantity = "0" },
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
} {
t.Run(name, func(t *testing.T) {
form := base
@@ -67,6 +71,7 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
"https://mobile.yangkeduo.com/other.html?goods_id=1",
"https://mobile.yangkeduo.com/goods.html?goods_id=" + strings.Repeat("1", MaxGoodsIDCharacters+1),
} {
if _, ok := CanonicalGoodsID(value); ok {
t.Fatalf("CanonicalGoodsID accepted %q", value)
@@ -75,19 +80,62 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
}
func TestNormalizeMoneyBoundaries(t *testing.T) {
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} {
maximum := strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00"
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00", maximum: maximum} {
got, ok := normalizeMoney(value)
if !ok || got != want {
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
}
}
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1"} {
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1", strings.Repeat("9", MaxMoneyASCIICharacters-2) + ".00"} {
if got, ok := normalizeMoney(value); ok {
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
}
}
}
func TestValidateAcceptsWorstLegalUnicodeFieldBounds(t *testing.T) {
goodsID := strings.Repeat("1", MaxGoodsIDCharacters)
draft, validation := Validate(Form{
CreateKey: testKey, Title: strings.Repeat("😀", MaxTitleCodePoints),
ProductURL: CanonicalURL(goodsID), SKUColor: strings.Repeat("色", MaxSKUTextCodePoints),
SKUSize: strings.Repeat("码", MaxSKUTextCodePoints), Quantity: "1",
MaxTotalPrice: strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00",
})
if !validation.Valid() || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) {
t.Fatalf("worst legal draft = %#v, validation = %#v", draft, validation)
}
}
func TestPersistedTextHasRuntimeIndependentC0AndNBSPDomain(t *testing.T) {
for name, invalid := range map[string]string{
"c0 prefix": "\u001cvalue",
"c0 suffix": "value\u001f",
"c0 interior": "value\u001dinside",
"nbsp prefix": "\u00a0value",
"nbsp suffix": "value\u00a0",
} {
t.Run(name, func(t *testing.T) {
if validBoundedText(invalid, MaxTitleCodePoints) {
t.Fatalf("validBoundedText(%q) accepted runtime-dependent text", invalid)
}
})
}
if !validBoundedText("left\u00a0right", MaxTitleCodePoints) {
t.Fatal("interior NBSP must remain a valid Unicode code point")
}
// Manual form input is normalized with Go TrimSpace before persistence.
draft, validation := Validate(Form{
CreateKey: testKey, Title: "\u00a0title\u00a0",
ProductURL: CanonicalURL("1"), SKUColor: "\u00a0black\u00a0",
SKUSize: "\u00a0M\u00a0", Quantity: "1", MaxTotalPrice: "1",
})
if !validation.Valid() || draft.Title != "title" || draft.SKUColor != "black" || draft.SKUSize != "M" {
t.Fatalf("NBSP form normalization = %#v, errors = %#v", draft, validation)
}
}
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
key, err := NewCreateKey()
if err != nil {
@@ -157,6 +205,37 @@ func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
}
}
func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) {
mutations := map[string]func(*Draft){
"untrimmed title": func(draft *Draft) { draft.Title = " title" },
"c0 interior title": func(draft *Draft) { draft.Title = "title\u001dhidden" },
"invalid utf8 title": func(draft *Draft) { draft.Title = string([]byte{0xff}) },
"long title": func(draft *Draft) { draft.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
"long color": func(draft *Draft) { draft.SKUColor = strings.Repeat("色", MaxSKUTextCodePoints+1) },
"long size": func(draft *Draft) { draft.SKUSize = strings.Repeat("码", MaxSKUTextCodePoints+1) },
"long goods id": func(draft *Draft) { draft.GoodsID = strings.Repeat("1", MaxGoodsIDCharacters+1) },
"long money": func(draft *Draft) { draft.MaxTotalPrice = strings.Repeat("1", MaxMoneyASCIICharacters-2) + ".00" },
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
draft := testDraft(testKey, "title")
mutate(&draft)
if _, err := store.CreateDraft(context.Background(), draft); !errors.Is(err, ErrInvalidDraft) {
t.Fatalf("CreateDraft error = %v, want ErrInvalidDraft", err)
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM tasks").Scan(&count); err != nil || count != 0 {
t.Fatalf("tasks after invalid create = %d, err %v", count, err)
}
})
}
}
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
+272
View File
@@ -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;
+45 -17
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
import sys
from collections.abc import Sequence
from .core.errors import StateError
from .logging_policy import configure_application_logger
from .runtime import RuntimePaths
from .runtime import LocalStateRuntime, RuntimePaths
def select_application_argv(argv: Sequence[str] | None) -> list[str]:
@@ -30,8 +31,7 @@ def main(argv: Sequence[str] | None = None) -> int:
return 1
try:
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
from PySide6.QtWidgets import QApplication
except ImportError:
logger.error("缺少 PySide6,无法启动桌面界面。")
print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr)
@@ -39,19 +39,47 @@ def main(argv: Sequence[str] | None = None) -> int:
application = QApplication.instance() or QApplication(select_application_argv(argv))
application.setApplicationName("采购工具")
runtime: LocalStateRuntime | None = None
coordinator = None
try:
runtime = LocalStateRuntime.open(paths)
try:
summary = runtime.store.load_profile_summary("default")
except StateError as error:
if error.reason != "profile_not_found":
raise
summary = None
window = QMainWindow()
window.setWindowTitle("采购工具")
window.setAccessibleName("采购工具")
window.setMinimumSize(420, 240)
window.resize(560, 320)
from .polling.coordinator import PollingCoordinator
from .ui.main_window import PurchaseToolWindow
message = QLabel("应用骨架已初始化。\n采购执行功能尚未启用。")
message.setAlignment(Qt.AlignmentFlag.AlignCenter)
message.setWordWrap(True)
message.setAccessibleName("当前状态")
window.setCentralWidget(message)
logger.info("应用已启动;采购执行功能尚未启用。")
window.show()
return application.exec()
settings = None if summary is None else summary.settings
has_token = False if summary is None else summary.has_stored_device_token
coordinator = PollingCoordinator(
profile_id="default",
store=runtime.store,
gateway_factory=None,
consumer=None,
profile_settings=settings,
poll_interval_seconds=15 if settings is None else settings.poll_interval_seconds,
failure_threshold=3 if settings is None else settings.failure_threshold,
)
window = PurchaseToolWindow(
store=runtime.store,
coordinator=coordinator,
profile_settings=settings,
has_stored_device_token=has_token,
)
logger.info("应用已启动;单趟执行能力尚未接入,真实领取保持禁用。")
window.show()
return application.exec()
except (OSError, RuntimeError, StateError):
logger.error("无法打开采购工具本地安全状态。")
print("无法启动采购工具:本地安全状态不可用。", file=sys.stderr)
return 3
finally:
worker_stopped = True
if coordinator is not None:
worker_stopped = coordinator.shutdown()
if runtime is not None and worker_stopped:
runtime.close()
@@ -0,0 +1,44 @@
"""与 UI、HTTP 和拼多多页面实现无关的客户端核心契约。"""
from .errors import (
AmbiguousRemoteError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
StateError,
ValidationError,
)
from .models import (
AssetReceipt,
AuthorizationSnapshot,
ClaimRequest,
ClaimedTask,
DeviceCredentials,
EvidenceUpload,
PurchaseTask,
RenewRequest,
RenewResult,
SecretToken,
)
from .ports import EvidenceSink, TaskSource
__all__ = [
"AmbiguousRemoteError",
"AssetReceipt",
"AuthorizationSnapshot",
"ClaimRequest",
"ClaimedTask",
"CredentialRemoteError",
"DeviceCredentials",
"EvidenceSink",
"EvidenceUpload",
"ManualRemoteError",
"ProtocolRemoteError",
"PurchaseTask",
"RenewRequest",
"RenewResult",
"SecretToken",
"StateError",
"TaskSource",
"ValidationError",
]
+47
View File
@@ -0,0 +1,47 @@
"""可安全呈现的客户端错误分类。"""
from __future__ import annotations
class ClientError(RuntimeError):
"""错误文本只使用固定 reason code,不携带凭据、响应或本机路径。"""
def __init__(self, reason: str) -> None:
self.reason = reason
super().__init__(reason)
class ValidationError(ClientError):
"""本地输入或 wire schema 不满足固定契约。"""
class StateError(ClientError):
"""本地状态无法安全推进;调用方必须停止而不是绕过。"""
class ProtectionError(ClientError):
"""秘密保护失败。"""
class SingleInstanceError(ClientError):
"""同一配置已经由另一个采购工具进程持有。"""
class RemoteError(ClientError):
"""服务端调用的稳定错误分类。"""
class AmbiguousRemoteError(RemoteError):
"""请求结果不明;只允许以原幂等键、原载荷显式恢复。"""
class CredentialRemoteError(RemoteError):
"""设备凭据无效或已撤销。"""
class ProtocolRemoteError(RemoteError):
"""请求/响应与固定协议不兼容,不得自动重试。"""
class ManualRemoteError(RemoteError):
"""服务端要求人工处理的确定性冲突。"""
+328
View File
@@ -0,0 +1,328 @@
"""任务领取、续租和单张证据上传的不可变值对象。"""
from __future__ import annotations
from dataclasses import dataclass, field
import hashlib
from pathlib import Path
from .errors import ValidationError
from .validation import (
MAX_SKU_TEXT_CODE_POINTS,
MAX_TITLE_CODE_POINTS,
canonical_product_url,
require_exact_fields,
require_goods_id,
require_lower_hex_64,
require_money,
require_persisted_text,
require_positive_int,
require_rfc3339_z,
rfc3339_z_nanoseconds,
require_string,
require_uuid4,
)
EVIDENCE_KIND = "SKU_PANEL_GATE_1"
PRIVACY_TIER = "INTERNAL_RAW"
@dataclass(frozen=True, repr=False)
class SecretToken:
"""64 位小写 token;repr 永不暴露明文。"""
value: str
def __post_init__(self) -> None:
require_lower_hex_64(self.value, "invalid_token")
def __repr__(self) -> str:
return "SecretToken([已隐藏])"
def __str__(self) -> str:
return "[已隐藏]"
@dataclass(frozen=True, repr=False)
class DeviceCredentials:
device_id: str
token: SecretToken
def __post_init__(self) -> None:
require_uuid4(self.device_id, "invalid_device_id")
if not isinstance(self.token, SecretToken):
raise ValidationError("invalid_device_token")
def __repr__(self) -> str:
return f"DeviceCredentials(device_id={self.device_id!r}, token=[已隐藏])"
@dataclass(frozen=True)
class ClaimRequest:
session_id: str
claim_request_id: str
def __post_init__(self) -> None:
require_uuid4(self.session_id, "invalid_session_id")
require_uuid4(self.claim_request_id, "invalid_claim_request_id")
def to_wire(self) -> dict[str, object]:
return {"session_id": self.session_id, "claim_request_id": self.claim_request_id}
@dataclass(frozen=True)
class PurchaseTask:
id: str
version: int
title: str
product_url: str
goods_id: str
sku_color: str
sku_size: str
quantity: int
max_total_price: str
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_task_id")
require_positive_int(self.version, "invalid_task_version")
require_persisted_text(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS)
require_goods_id(self.goods_id)
if self.product_url != canonical_product_url(self.goods_id):
raise ValidationError("invalid_product_url")
require_persisted_text(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS)
require_persisted_text(self.sku_size, "invalid_sku_size", maximum=MAX_SKU_TEXT_CODE_POINTS)
require_positive_int(self.quantity, "invalid_quantity")
require_money(self.max_total_price, "invalid_max_total_price")
@classmethod
def from_wire(cls, value: object) -> "PurchaseTask":
data = require_exact_fields(
value,
("id", "version", "title", "product_url", "goods_id", "sku_color", "sku_size", "quantity", "max_total_price"),
)
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True)
class AuthorizationSnapshot:
id: str
task_version: int
expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_authorization_id")
require_positive_int(self.task_version, "invalid_authorization_task_version")
require_rfc3339_z(self.expires_at, "invalid_authorization_expiry")
@classmethod
def from_wire(cls, value: object) -> "AuthorizationSnapshot":
data = require_exact_fields(value, ("id", "task_version", "expires_at"))
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True)
class AttemptSnapshot:
id: str
claim_token: SecretToken
claim_generation: int
lease_expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_attempt_id")
if not isinstance(self.claim_token, SecretToken):
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
require_positive_int(self.claim_generation, "invalid_claim_generation")
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
@classmethod
def from_wire(cls, value: object) -> "AttemptSnapshot":
data = require_exact_fields(value, ("id", "claim_token", "claim_generation", "lease_expires_at"))
return cls(
id=data["id"], # type: ignore[arg-type]
claim_token=SecretToken(data["claim_token"]), # type: ignore[arg-type]
claim_generation=data["claim_generation"], # type: ignore[arg-type]
lease_expires_at=data["lease_expires_at"], # type: ignore[arg-type]
)
@dataclass(frozen=True)
class ClaimedTask:
task: PurchaseTask
authorization: AuthorizationSnapshot
attempt: AttemptSnapshot = field(repr=False)
def __post_init__(self) -> None:
if self.task.version != self.authorization.task_version + 1:
raise ValidationError("task_authorization_version_mismatch")
if rfc3339_z_nanoseconds(self.attempt.lease_expires_at) > rfc3339_z_nanoseconds(self.authorization.expires_at):
raise ValidationError("claim_lease_exceeds_authorization")
@classmethod
def from_wire(cls, value: object) -> "ClaimedTask":
data = require_exact_fields(value, ("task", "authorization", "attempt"))
return cls(
task=PurchaseTask.from_wire(data["task"]),
authorization=AuthorizationSnapshot.from_wire(data["authorization"]),
attempt=AttemptSnapshot.from_wire(data["attempt"]),
)
@dataclass(frozen=True, repr=False)
class RenewRequest:
task_id: str
renew_request_id: str
session_id: str
attempt_id: str
claim_generation: int
claim_token: SecretToken
expected_lease_expires_at: str
authorization_expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.renew_request_id, "invalid_renew_request_id")
require_uuid4(self.session_id, "invalid_session_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_positive_int(self.claim_generation, "invalid_claim_generation")
if not isinstance(self.claim_token, SecretToken):
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
require_rfc3339_z(self.expected_lease_expires_at, "invalid_expected_lease_expiry")
require_rfc3339_z(self.authorization_expires_at, "invalid_authorization_expiry")
def __repr__(self) -> str:
return (
f"RenewRequest(task_id={self.task_id!r}, renew_request_id={self.renew_request_id!r}, "
"claim_token=[已隐藏])"
)
def to_wire(self) -> dict[str, object]:
return {
"renew_request_id": self.renew_request_id,
"session_id": self.session_id,
"attempt_id": self.attempt_id,
"claim_generation": self.claim_generation,
"claim_token": self.claim_token.value,
"expected_lease_expires_at": self.expected_lease_expires_at,
}
@dataclass(frozen=True)
class RenewResult:
task_id: str
attempt_id: str
claim_generation: int
lease_expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_positive_int(self.claim_generation, "invalid_claim_generation")
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
@classmethod
def from_wire(cls, value: object) -> "RenewResult":
data = require_exact_fields(value, ("task_id", "attempt_id", "claim_generation", "lease_expires_at"))
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True, repr=False)
class EvidenceUpload:
task_id: str
upload_key: str
attempt_id: str
sha256: str
captured_at: str
content: bytes = field(repr=False)
kind: str = EVIDENCE_KIND
privacy_tier: str = PRIVACY_TIER
width_px: int = field(init=False)
height_px: int = field(init=False)
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.upload_key, "invalid_upload_key")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_lower_hex_64(self.sha256, "invalid_evidence_sha256")
require_rfc3339_z(self.captured_at, "invalid_captured_at")
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
raise ValidationError("invalid_evidence_metadata")
if not isinstance(self.content, bytes) or not self.content or len(self.content) > 10 * 1024 * 1024:
raise ValidationError("invalid_evidence_size")
if len(self.content) < 24 or not self.content.startswith(b"\x89PNG\r\n\x1a\n") or self.content[12:16] != b"IHDR":
raise ValidationError("invalid_evidence_png")
width = int.from_bytes(self.content[16:20], "big")
height = int.from_bytes(self.content[20:24], "big")
if width <= 0 or height <= 0 or width > 8192 or height > 8192 or width * height > 16_777_216:
raise ValidationError("invalid_evidence_dimensions")
object.__setattr__(self, "width_px", width)
object.__setattr__(self, "height_px", height)
if hashlib.sha256(self.content).hexdigest() != self.sha256:
raise ValidationError("evidence_hash_mismatch")
def __repr__(self) -> str:
return (
f"EvidenceUpload(task_id={self.task_id!r}, upload_key={self.upload_key!r}, "
f"attempt_id={self.attempt_id!r}, byte_size={len(self.content)})"
)
@dataclass(frozen=True)
class AssetReceipt:
asset_id: str
task_id: str
attempt_id: str
kind: str
privacy_tier: str
sha256: str
byte_size: int
content_type: str
width_px: int
height_px: int
captured_at: str
def __post_init__(self) -> None:
require_uuid4(self.asset_id, "invalid_asset_id")
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
raise ValidationError("invalid_asset_metadata")
require_lower_hex_64(self.sha256, "invalid_asset_sha256")
require_positive_int(self.byte_size, "invalid_asset_byte_size")
if self.byte_size > 10 * 1024 * 1024 or self.content_type != "image/png":
raise ValidationError("invalid_asset_content")
width = require_positive_int(self.width_px, "invalid_asset_width")
height = require_positive_int(self.height_px, "invalid_asset_height")
if width > 8192 or height > 8192 or width * height > 16_777_216:
raise ValidationError("invalid_asset_dimensions")
require_rfc3339_z(self.captured_at, "invalid_captured_at")
@classmethod
def from_wire(cls, value: object) -> "AssetReceipt":
data = require_exact_fields(
value,
("asset_id", "task_id", "attempt_id", "kind", "privacy_tier", "sha256", "byte_size", "content_type", "width_px", "height_px", "captured_at"),
)
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True, repr=False)
class ScreenshotAsset:
"""调用方显式选择的唯一 PNG;路径不会进入 repr 或 HTTP。"""
path: Path = field(repr=False)
task_id: str
attempt_id: str
captured_at: str
kind: str = EVIDENCE_KIND
privacy_tier: str = PRIVACY_TIER
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_rfc3339_z(self.captured_at, "invalid_captured_at")
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
raise ValidationError("invalid_evidence_metadata")
def __repr__(self) -> str:
return f"ScreenshotAsset(task_id={self.task_id!r}, attempt_id={self.attempt_id!r}, path=[已隐藏])"
+17
View File
@@ -0,0 +1,17 @@
"""由 HTTP 适配器实现的窄端口。"""
from __future__ import annotations
from typing import Protocol
from .models import AssetReceipt, ClaimRequest, ClaimedTask, DeviceCredentials, EvidenceUpload, RenewRequest, RenewResult
class TaskSource(Protocol):
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
class EvidenceSink(Protocol):
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
@@ -0,0 +1,197 @@
"""客户端与服务端共享 wire 的严格值校验。"""
from __future__ import annotations
import calendar
from datetime import datetime, timezone
import json
import re
from typing import Any, Iterable, Mapping
from urllib.parse import quote
from .errors import ValidationError
UUID4_RE = re.compile(
r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
)
LOWER_HEX_64_RE = re.compile(r"[0-9a-f]{64}")
RFC3339_Z_RE = re.compile(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z"
)
MONEY_RE = re.compile(r"(?:0|[1-9][0-9]*)\.[0-9]{2}")
GOODS_ID_RE = re.compile(r"[0-9]+")
MAX_TITLE_CODE_POINTS = 120
MAX_SKU_TEXT_CODE_POINTS = 80
MAX_GOODS_ID_ASCII_CHARACTERS = 32
MAX_MONEY_ASCII_CHARACTERS = 32
# Go strings.TrimSpace uses Unicode White_Space plus the six ASCII space
# characters below, but unlike Python str.strip it does not include U+001C--
# U+001F. Keep the wire contract independent of either runtime's defaults.
GO_UNICODE_WHITE_SPACE = "\t\n\v\f\r \u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"
def require_string(value: object, reason: str, *, maximum: int = 4096) -> str:
if not isinstance(value, str) or not value or len(value) > maximum:
raise ValidationError(reason)
if any(0xD800 <= ord(character) <= 0xDFFF for character in value):
raise ValidationError(reason)
return value
def require_persisted_text(value: object, reason: str, *, maximum: int) -> str:
"""Validate text stored by Go after TrimSpace, without Python trim drift."""
text = require_string(value, reason, maximum=maximum)
if text.strip(GO_UNICODE_WHITE_SPACE) != text:
raise ValidationError(reason)
# Python str.strip treats these C0 separators as whitespace while Go does
# not. Reject them anywhere on both ends instead of assigning them two
# runtime-dependent meanings.
if any(0x1C <= ord(character) <= 0x1F for character in text):
raise ValidationError(reason)
return text
def require_uuid4(value: object, reason: str = "invalid_uuid") -> str:
text = require_string(value, reason, maximum=36)
if UUID4_RE.fullmatch(text) is None:
raise ValidationError(reason)
return text
def require_lower_hex_64(value: object, reason: str = "invalid_hex") -> str:
text = require_string(value, reason, maximum=64)
if LOWER_HEX_64_RE.fullmatch(text) is None:
raise ValidationError(reason)
return text
def require_rfc3339_z(value: object, reason: str = "invalid_timestamp") -> str:
text = require_string(value, reason, maximum=40)
if RFC3339_Z_RE.fullmatch(text) is None:
raise ValidationError(reason)
parsed: datetime | None = None
try:
parsed = datetime.fromisoformat(text[:-1] + "+00:00")
except ValueError:
pass
if parsed is None:
raise ValidationError(reason)
if parsed.utcoffset() is None or parsed.utcoffset().total_seconds() != 0:
raise ValidationError(reason)
return text
def rfc3339_z_nanoseconds(value: object, reason: str = "invalid_timestamp") -> int:
"""无浮点、无微秒截断地把 UTC RFC3339Nano 转成纳秒时间轴。"""
text = require_rfc3339_z(value, reason)
base: datetime | None = None
try:
base = datetime.strptime(text[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
except ValueError:
pass
if base is None:
raise ValidationError(reason)
fraction = "" if len(text) == 20 else text[20:-1]
nanoseconds = int(fraction.ljust(9, "0")) if fraction else 0
return calendar.timegm(base.utctimetuple()) * 1_000_000_000 + nanoseconds
def datetime_nanoseconds(value: datetime, reason: str = "invalid_timestamp") -> int:
if not isinstance(value, datetime) or value.utcoffset() is None:
raise ValidationError(reason)
utc = value.astimezone(timezone.utc)
return calendar.timegm(utc.utctimetuple()) * 1_000_000_000 + utc.microsecond * 1_000
def require_positive_int(value: object, reason: str = "invalid_integer") -> int:
# bool 是 int 的子类;wire 中必须显式拒绝 true/false。
if type(value) is not int or value <= 0 or value > 9_223_372_036_854_775_807:
raise ValidationError(reason)
return value
def require_money(value: object, reason: str = "invalid_money") -> str:
text = require_string(value, reason, maximum=MAX_MONEY_ASCII_CHARACTERS)
if MONEY_RE.fullmatch(text) is None or text == "0.00":
raise ValidationError(reason)
return text
def require_goods_id(value: object) -> str:
text = require_string(value, "invalid_goods_id", maximum=MAX_GOODS_ID_ASCII_CHARACTERS)
if GOODS_ID_RE.fullmatch(text) is None:
raise ValidationError("invalid_goods_id")
return text
def canonical_product_url(goods_id: str) -> str:
require_goods_id(goods_id)
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + quote(goods_id, safe="")
def require_exact_fields(
value: object,
required: Iterable[str],
reason: str = "invalid_schema",
) -> Mapping[str, Any]:
if not isinstance(value, dict):
raise ValidationError(reason)
expected = frozenset(required)
if frozenset(value) != expected:
raise ValidationError(reason)
return value
def strict_json_loads(raw: bytes, *, maximum: int) -> object:
if not isinstance(raw, bytes) or len(raw) == 0 or len(raw) > maximum:
raise ValidationError("invalid_json_size")
text: str | None = None
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
pass
if text is None:
raise ValidationError("invalid_json_utf8")
if text.startswith("\ufeff"):
raise ValidationError("invalid_json_bom")
def pairs_hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise ValidationError("duplicate_json_key")
result[key] = value
return result
def reject_number(_: str) -> object:
raise ValidationError("invalid_json_number")
def parse_integer(value: str) -> int:
digits = value[1:] if value.startswith("-") else value
if len(digits) > 19:
raise ValidationError("invalid_json_integer")
parsed = int(value)
if parsed < -9_223_372_036_854_775_808 or parsed > 9_223_372_036_854_775_807:
raise ValidationError("invalid_json_integer")
return parsed
parsed_json: object | None = None
failed = False
try:
parsed_json = json.loads(
text,
object_pairs_hook=pairs_hook,
parse_int=parse_integer,
parse_float=reject_number,
parse_constant=reject_number,
)
except ValidationError:
raise
except (json.JSONDecodeError, UnicodeError, ValueError, RecursionError):
failed = True
if failed:
raise ValidationError("invalid_json")
return parsed_json
@@ -0,0 +1,18 @@
"""Windows 本地恢复、凭据保护和单实例底座。"""
from .models import PollingSession, ProfileSettings, RecoverySnapshot
from .facade import DurableClientGateway
from .protection import DpapiProtector, SecretProtector
from .single_instance import NamedMutex
from .store import LocalStateStore
__all__ = [
"DpapiProtector",
"DurableClientGateway",
"LocalStateStore",
"NamedMutex",
"PollingSession",
"ProfileSettings",
"RecoverySnapshot",
"SecretProtector",
]
@@ -0,0 +1,82 @@
"""把“先持久化,再发一次 HTTP”固化成 T-304/T-306 的唯一集成入口。"""
from __future__ import annotations
from cmbuyer_client.core.errors import (
AmbiguousRemoteError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
)
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, ScreenshotAsset
from cmbuyer_client.core.ports import EvidenceSink, TaskSource
from .store import LocalStateStore
class DurableClientGateway:
"""不隐藏重试;每次方法调用最多发一次请求,结果不明保留原槽。"""
def __init__(self, store: LocalStateStore, task_source: TaskSource, evidence_sink: EvidenceSink) -> None:
self._store = store
self._task_source = task_source
self._evidence_sink = evidence_sink
def claim_next(self, profile_id: str) -> ClaimedTask | None:
request = self._store.prepare_claim(profile_id)
credentials = self._store.load_profile(profile_id).credentials
try:
claimed = self._task_source.claim_next(credentials, request)
except AmbiguousRemoteError:
raise
except CredentialRemoteError:
raise
except ProtocolRemoteError:
self._store.mark_claim_terminal(profile_id, request, "PROTOCOL")
raise
except ManualRemoteError:
self._store.mark_claim_terminal(profile_id, request, "MANUAL")
raise
if claimed is None:
self._store.commit_claim_empty(profile_id, request)
return None
self._store.commit_claim_success(profile_id, request, claimed)
return claimed
def renew(self, profile_id: str):
request = self._store.prepare_renew(profile_id)
credentials = self._store.load_profile(profile_id).credentials
try:
result = self._task_source.renew(credentials, request)
except AmbiguousRemoteError:
raise
except CredentialRemoteError:
raise
except ProtocolRemoteError:
self._store.mark_renew_terminal(profile_id, request, "PROTOCOL")
raise
except ManualRemoteError:
self._store.mark_renew_terminal(profile_id, request, "MANUAL")
raise
self._store.commit_renew_success(profile_id, request, result)
return result
def upload_evidence(self, profile_id: str, asset: ScreenshotAsset) -> AssetReceipt:
prepared = self._store.prepare_or_resume_evidence(profile_id, asset)
if isinstance(prepared, AssetReceipt):
return prepared
credentials = self._store.load_profile(profile_id).credentials
try:
receipt = self._evidence_sink.upload(credentials, prepared)
except AmbiguousRemoteError:
raise
except CredentialRemoteError:
raise
except ProtocolRemoteError:
self._store.mark_evidence_terminal(profile_id, prepared, "PROTOCOL")
raise
except ManualRemoteError:
self._store.mark_evidence_terminal(profile_id, prepared, "MANUAL")
raise
self._store.commit_evidence_success(profile_id, prepared, receipt)
return receipt
@@ -0,0 +1,98 @@
"""供 T-304 使用的稳定本地配置与恢复快照。"""
from __future__ import annotations
from dataclasses import dataclass, field
import re
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest
from cmbuyer_client.core.validation import require_string, require_uuid4
LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080"
PROFILE_ID_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}")
@dataclass(frozen=True)
class ProfileSettings:
profile_id: str
service_url: str
device_id: str
adb_path: str
adb_serial: str
transport: str
poll_interval_seconds: int = 15
failure_threshold: int = 3
http_timeout_seconds: int = 10
step_timeout_seconds: int = 45
def __post_init__(self) -> None:
if not isinstance(self.profile_id, str) or PROFILE_ID_RE.fullmatch(self.profile_id) is None:
raise ValueError("invalid_profile_id")
if self.service_url != LOOPBACK_SERVICE_URL:
raise ValueError("service_url_not_allowed")
require_uuid4(self.device_id, "invalid_device_id")
require_string(self.adb_path, "invalid_adb_path", maximum=1024)
require_string(self.adb_serial, "invalid_adb_serial", maximum=200)
if self.transport not in ("usb", "wifi"):
raise ValueError("invalid_transport")
_range(self.poll_interval_seconds, 5, 300, "invalid_poll_interval")
_range(self.failure_threshold, 1, 10, "invalid_failure_threshold")
_range(self.http_timeout_seconds, 1, 120, "invalid_http_timeout")
_range(self.step_timeout_seconds, 5, 300, "invalid_step_timeout")
@dataclass(frozen=True, repr=False)
class LoadedProfile:
settings: ProfileSettings
credentials: DeviceCredentials = field(repr=False)
def __repr__(self) -> str:
return f"LoadedProfile(settings={self.settings!r}, credentials=[已隐藏])"
@dataclass(frozen=True)
class ProfileSummary:
"""不解密、不返回任何 token 数据的配置页只读摘要。"""
settings: ProfileSettings
has_stored_device_token: bool
def __post_init__(self) -> None:
if type(self.has_stored_device_token) is not bool:
raise ValueError("invalid_token_presence")
@dataclass(frozen=True)
class PollingSession:
profile_id: str
session_id: str
accept_new: bool
def __post_init__(self) -> None:
require_uuid4(self.session_id, "invalid_session_id")
if type(self.accept_new) is not bool:
raise ValueError("invalid_accept_new")
@dataclass(frozen=True)
class PendingEvidence:
task_id: str
attempt_id: str
kind: str
upload_key: str
status: str
@dataclass(frozen=True)
class RecoverySnapshot:
session: PollingSession | None
pending_claim: ClaimRequest | None
active_claim: ClaimedTask | None = field(repr=False)
pending_renew: RenewRequest | None = field(repr=False)
pending_evidence: tuple[PendingEvidence, ...]
def _range(value: object, minimum: int, maximum: int, reason: str) -> None:
if type(value) is not int or not minimum <= value <= maximum:
raise ValueError(reason)
@@ -0,0 +1,112 @@
"""Windows 当前用户范围 DPAPI 封装;生产环境绝不降级为明文。"""
from __future__ import annotations
import ctypes
from ctypes import wintypes
import os
import re
from typing import Protocol
from cmbuyer_client.core.errors import ProtectionError
class SecretProtector(Protocol):
def protect(self, plaintext: bytes, *, purpose: str) -> bytes: ...
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes: ...
class _DataBlob(ctypes.Structure):
_fields_ = (("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_ubyte)))
def _blob(data: bytes) -> tuple[_DataBlob, object]:
buffer = (ctypes.c_ubyte * len(data)).from_buffer_copy(data) if data else (ctypes.c_ubyte * 1)()
return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))), buffer
class DpapiProtector:
"""使用 CryptProtectData/UI_FORBIDDEN;错误只暴露固定 reason code。"""
_UI_FORBIDDEN = 0x1
_ENTROPY_PREFIX = b"cmbuyer-localstate-v1:"
_PURPOSE_RE = re.compile(
r"(?:device-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36}|"
r"claim-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36})",
flags=re.ASCII,
)
def __init__(self) -> None:
if os.name != "nt":
raise ProtectionError("dpapi_requires_windows")
self._crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
self._crypt32.CryptProtectData.argtypes = (
ctypes.POINTER(_DataBlob),
wintypes.LPCWSTR,
ctypes.POINTER(_DataBlob),
wintypes.LPVOID,
wintypes.LPVOID,
wintypes.DWORD,
ctypes.POINTER(_DataBlob),
)
self._crypt32.CryptProtectData.restype = wintypes.BOOL
self._crypt32.CryptUnprotectData.argtypes = (
ctypes.POINTER(_DataBlob),
ctypes.POINTER(wintypes.LPWSTR),
ctypes.POINTER(_DataBlob),
wintypes.LPVOID,
wintypes.LPVOID,
wintypes.DWORD,
ctypes.POINTER(_DataBlob),
)
self._crypt32.CryptUnprotectData.restype = wintypes.BOOL
self._kernel32.LocalFree.argtypes = (wintypes.HLOCAL,)
self._kernel32.LocalFree.restype = wintypes.HLOCAL
def protect(self, plaintext: bytes, *, purpose: str) -> bytes:
if not isinstance(plaintext, bytes) or not plaintext:
raise ProtectionError("invalid_plaintext")
entropy = self._entropy(purpose)
source, source_buffer = _blob(plaintext)
entropy_blob, entropy_buffer = _blob(entropy)
output = _DataBlob()
if not self._crypt32.CryptProtectData(
ctypes.byref(source), None, ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output)
):
raise ProtectionError("dpapi_protect_failed")
# ctypes 指针不持有底层 Python buffer;局部引用必须活到系统调用返回。
del source_buffer, entropy_buffer
return self._take_output(output, "dpapi_protect_failed")
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
if not isinstance(ciphertext, bytes) or not ciphertext:
raise ProtectionError("invalid_ciphertext")
entropy = self._entropy(purpose)
source, source_buffer = _blob(ciphertext)
entropy_blob, entropy_buffer = _blob(entropy)
output = _DataBlob()
description = wintypes.LPWSTR()
if not self._crypt32.CryptUnprotectData(
ctypes.byref(source), ctypes.byref(description), ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output)
):
raise ProtectionError("dpapi_unprotect_failed")
del source_buffer, entropy_buffer
if description:
self._kernel32.LocalFree(ctypes.cast(description, wintypes.HLOCAL))
return self._take_output(output, "dpapi_unprotect_failed")
def _take_output(self, output: _DataBlob, reason: str) -> bytes:
if not output.pbData or output.cbData <= 0:
raise ProtectionError(reason)
try:
return ctypes.string_at(output.pbData, output.cbData)
finally:
self._kernel32.LocalFree(ctypes.cast(output.pbData, wintypes.HLOCAL))
@classmethod
def _entropy(cls, purpose: str) -> bytes:
if not isinstance(purpose, str) or cls._PURPOSE_RE.fullmatch(purpose) is None:
raise ProtectionError("invalid_protection_purpose")
return cls._ENTROPY_PREFIX + purpose.encode("ascii")
@@ -0,0 +1,51 @@
"""同一本地数据库的 Windows named mutex。"""
from __future__ import annotations
import ctypes
from ctypes import wintypes
import hashlib
import os
from pathlib import Path
from cmbuyer_client.core.errors import SingleInstanceError
class NamedMutex:
_ALREADY_EXISTS = 183
def __init__(self, database_path: Path) -> None:
if os.name != "nt":
raise SingleInstanceError("named_mutex_requires_windows")
canonical = str(database_path.expanduser().resolve()).casefold().encode("utf-8")
# Global namespace 覆盖同一 Windows 用户的多个交互 session;默认 DACL 不向其他用户泄露句柄。
name = "Global\\cmbuyer-" + hashlib.sha256(canonical).hexdigest()
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateMutexW.argtypes = (wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR)
kernel32.CreateMutexW.restype = wintypes.HANDLE
kernel32.ReleaseMutex.argtypes = (wintypes.HANDLE,)
kernel32.ReleaseMutex.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
kernel32.CloseHandle.restype = wintypes.BOOL
ctypes.set_last_error(0)
handle = kernel32.CreateMutexW(None, True, name)
if not handle:
raise SingleInstanceError("named_mutex_failed")
if ctypes.get_last_error() == self._ALREADY_EXISTS:
kernel32.CloseHandle(handle)
raise SingleInstanceError("instance_already_running")
self._kernel32 = kernel32
self._handle = handle
def close(self) -> None:
handle = getattr(self, "_handle", None)
if handle:
self._kernel32.ReleaseMutex(handle)
self._kernel32.CloseHandle(handle)
self._handle = None
def __enter__(self) -> "NamedMutex":
return self
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
self.close()
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -23,6 +23,8 @@ _KEY_VALUE_PATTERN = re.compile(
flags=re.IGNORECASE,
)
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
_BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[0-9a-f]{64}\b")
_BARE_TOKEN_PATTERN = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
def redact_text(message: str) -> str:
@@ -31,7 +33,9 @@ def redact_text(message: str) -> str:
def replace_key_value(match: re.Match[str]) -> str:
return f"{match.group('key')}{match.group('separator')}{REDACTED}"
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, message)
redacted = _BEARER_PATTERN.sub("Bearer " + REDACTED, message)
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, redacted)
redacted = _BARE_TOKEN_PATTERN.sub(REDACTED, redacted)
return _PHONE_PATTERN.sub(REDACTED, redacted)
@@ -47,6 +51,16 @@ class SensitiveDataFilter(logging.Filter):
return True
class RedactingFormatter(logging.Formatter):
"""再次处理完整格式化文本,覆盖异常 traceback 中的敏感值。"""
def format(self, record: logging.LogRecord) -> str:
return redact_text(super().format(record))
def formatException(self, exc_info: tuple[type[BaseException], BaseException, object]) -> str:
return redact_text(super().formatException(exc_info))
def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。"""
@@ -61,6 +75,6 @@ def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
handler.addFilter(SensitiveDataFilter())
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
handler.setFormatter(RedactingFormatter("%(asctime)s %(levelname)s %(message)s"))
logger.addHandler(handler)
return logger
@@ -0,0 +1,5 @@
"""采购工具轮询会话协调器。"""
from .coordinator import ClaimedTaskView, PollingCoordinator, PollingState, RecoveryStatus, StartReadiness
__all__ = ["ClaimedTaskView", "PollingCoordinator", "PollingState", "RecoveryStatus", "StartReadiness"]
@@ -0,0 +1,589 @@
"""在 Qt 事件循环中协调可恢复的领取会话。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
from PySide6.QtCore import QObject, QThread, QTimer, Signal, Slot
from cmbuyer_client.core.errors import (
AmbiguousRemoteError,
ClientError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
StateError,
)
from cmbuyer_client.core.models import ClaimedTask
from cmbuyer_client.localstate.models import PollingSession, ProfileSettings, RecoverySnapshot
from cmbuyer_client.logging_policy import redact_text
SAFE_AMBIGUOUS_REASONS = frozenset(
("http_result_unknown", "server_result_unknown", "truncated_response")
)
class PollingState(str, Enum):
STOPPED = "STOPPED"
STARTING = "STARTING"
BLOCKED = "BLOCKED"
RECOVERING = "RECOVERING"
WAITING = "WAITING"
CLAIMING = "CLAIMING"
ACTIVE = "ACTIVE"
RECOVERY_REQUIRED = "RECOVERY_REQUIRED"
class PollingStore(Protocol):
def recovery_snapshot(self, profile_id: str) -> RecoverySnapshot: ...
def start_or_resume_polling(self, profile_id: str) -> PollingSession: ...
def request_stop(self, profile_id: str) -> PollingSession: ...
class ClaimGateway(Protocol):
def claim_next(self, profile_id: str) -> ClaimedTask | None: ...
class ExecutionConsumer(Protocol):
def accept_claim(self, claimed: ClaimedTask, profile: ProfileSettings) -> None: ...
@dataclass(frozen=True)
class StartReadiness:
"""由后续已取证执行能力注入;T-304 自己不探测网络或设备。"""
ready: bool
reason: str
@dataclass(frozen=True)
class ClaimedTaskView:
"""允许发往 UI 的最小投影,刻意不包含 authorization/claim token。"""
task_id: str
title: str
status: str = "已领取"
@classmethod
def from_claim(cls, claimed: ClaimedTask) -> "ClaimedTaskView":
return cls(task_id=claimed.task.id, title=redact_text(claimed.task.title))
@dataclass(frozen=True)
class RecoveryStatus:
"""可进入 UI 的恢复摘要;不携带 task snapshot、claim token 或密文。"""
has_open_session: bool
session_accept_new: bool
has_pending_claim: bool
has_active_claim: bool
has_pending_active_work: bool
@classmethod
def from_snapshot(cls, snapshot: RecoverySnapshot) -> "RecoveryStatus":
return cls(
has_open_session=snapshot.session is not None,
session_accept_new=bool(snapshot.session and snapshot.session.accept_new),
has_pending_claim=snapshot.pending_claim is not None,
has_active_claim=snapshot.active_claim is not None,
has_pending_active_work=bool(snapshot.pending_renew or snapshot.pending_evidence),
)
@dataclass(frozen=True)
class _BootstrapResult:
recovery: RecoveryStatus
session: PollingSession | None
normalized_stop: PollingSession | None = None
class _PollingWorker(QObject):
bootstrap_finished = Signal(object)
claim_finished = Signal(object)
stop_finished = Signal(object)
failed = Signal(str, object)
def __init__(self, store: PollingStore) -> None:
super().__init__()
self._store = store
self._gateway: ClaimGateway | None = None
@Slot(object)
def configure_gateway(self, gateway: object) -> None:
if not hasattr(gateway, "claim_next"):
self.failed.emit("configure", RuntimeError("invalid_claim_gateway"))
return
self._gateway = gateway # type: ignore[assignment]
@Slot(str)
def bootstrap(self, profile_id: str) -> None:
try:
snapshot = self._store.recovery_snapshot(profile_id)
recovery = RecoveryStatus.from_snapshot(snapshot)
if snapshot.session is not None and snapshot.session.accept_new:
stopped = self._store.request_stop(profile_id)
self.bootstrap_finished.emit(_BootstrapResult(recovery, None, stopped))
return
if snapshot.active_claim is not None:
self.bootstrap_finished.emit(_BootstrapResult(recovery, None))
return
session = self._store.start_or_resume_polling(profile_id)
self.bootstrap_finished.emit(_BootstrapResult(recovery, session))
except Exception as error:
self.failed.emit("bootstrap", error)
@Slot(str)
def inspect_restart(self, profile_id: str) -> None:
try:
snapshot = self._store.recovery_snapshot(profile_id)
recovery = RecoveryStatus.from_snapshot(snapshot)
stopped = None
if snapshot.session is not None and snapshot.session.accept_new:
stopped = self._store.request_stop(profile_id)
self.bootstrap_finished.emit(_BootstrapResult(recovery, None, stopped))
except Exception as error:
self.failed.emit("inspect", error)
@Slot(str)
def claim(self, profile_id: str) -> None:
try:
# DurableClientGateway 在返回前已经提交 EMPTY 或 active claim;UI 不能
# 以 generation 过期为由丢弃这个业务结果。
if self._gateway is None:
raise RuntimeError("claim_gateway_not_configured")
self.claim_finished.emit(self._gateway.claim_next(profile_id))
except Exception as error:
self.failed.emit("claim", error)
@Slot(str)
def stop(self, profile_id: str) -> None:
try:
self.stop_finished.emit(self._store.request_stop(profile_id))
except Exception as error:
self.failed.emit("stop", error)
class PollingCoordinator(QObject):
"""把计时、阻塞 I/O 和可见状态收敛到一个会话边界。"""
state_changed = Signal(object, str, int)
claim_visible = Signal(object)
recovery_status_changed = Signal(object)
configuration_freeze_changed = Signal(bool)
settled = Signal()
_configure_gateway_requested = Signal(object)
_bootstrap_requested = Signal(str)
_inspect_requested = Signal(str)
_claim_requested = Signal(str)
_stop_requested_signal = Signal(str)
def __init__(
self,
*,
profile_id: str,
store: PollingStore | None,
gateway_factory: Callable[[ProfileSettings], ClaimGateway] | None,
consumer: ExecutionConsumer | None,
profile_settings: ProfileSettings | None = None,
readiness: StartReadiness | None = None,
poll_interval_seconds: int = 15,
failure_threshold: int = 3,
timer_interval_ms: int | None = None,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
if not 5 <= poll_interval_seconds <= 300:
raise ValueError("invalid_poll_interval")
if not 1 <= failure_threshold <= 10:
raise ValueError("invalid_failure_threshold")
self.profile_id = profile_id
self._store = store
self._gateway_factory = gateway_factory
self._gateway: ClaimGateway | None = None
self._consumer = consumer
self._profile_settings = profile_settings
self._frozen_profile: ProfileSettings | None = None
self._readiness = readiness
self.recovery_status: RecoveryStatus | None = None
self._timer_interval_override = timer_interval_ms
self._interval_ms = timer_interval_ms or (
profile_settings.poll_interval_seconds * 1000 if profile_settings is not None else poll_interval_seconds * 1000
)
if self._interval_ms <= 0:
raise ValueError("invalid_timer_interval")
self._failure_threshold = (
profile_settings.failure_threshold if profile_settings is not None else failure_threshold
)
self._consecutive_failures = 0
self._operation: str | None = None
self._stop_requested = False
self._epoch = 0
self._scheduled_epoch: int | None = None
self._post_stop_state = PollingState.STOPPED
self._post_stop_reason = "轮询已停止。"
self._thread: QThread | None = None
self._worker: _PollingWorker | None = None
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._on_timer_timeout)
if consumer is None:
self.state = PollingState.BLOCKED
self.reason = "单趟执行能力尚未接入,不能领取真实任务。"
elif store is None or gateway_factory is None:
self.state = PollingState.BLOCKED
self.reason = "轮询依赖未完整注入,不能领取真实任务。"
elif profile_settings is None:
self.state = PollingState.BLOCKED
self.reason = "尚未保存完整配置,不能开始轮询。"
elif readiness is None or not readiness.ready:
self.state = PollingState.BLOCKED
self.reason = "执行就绪条件未满足,不能开始轮询。" if readiness is None else readiness.reason
else:
self.state = PollingState.STOPPED
self.reason = "轮询已停止。"
if store is not None:
self._thread = QThread(self)
self._worker = _PollingWorker(store)
self._worker.moveToThread(self._thread)
self._configure_gateway_requested.connect(self._worker.configure_gateway)
self._inspect_requested.connect(self._worker.inspect_restart)
self._bootstrap_requested.connect(self._worker.bootstrap)
self._claim_requested.connect(self._worker.claim)
self._stop_requested_signal.connect(self._worker.stop)
self._worker.bootstrap_finished.connect(self._on_bootstrap_finished)
self._worker.claim_finished.connect(self._on_claim_finished)
self._worker.stop_finished.connect(self._on_stop_finished)
self._worker.failed.connect(self._on_worker_failed)
self._thread.start()
self._operation = "inspect"
self._set_state(PollingState.STARTING, "正在读取重启恢复状态并关闭遗留自动领取许可…")
self._inspect_requested.emit(self.profile_id)
@property
def can_start(self) -> bool:
return (
self._consumer is not None
and self._store is not None
and self._gateway_factory is not None
and self._profile_settings is not None
and self._readiness is not None
and self._readiness.ready
and self.state == PollingState.STOPPED
and self._operation is None
)
@property
def consecutive_failures(self) -> int:
return self._consecutive_failures
@property
def operation_in_flight(self) -> bool:
return self._operation is not None
def update_profile_settings(self, settings: ProfileSettings) -> None:
if self._operation is None and self.state in (PollingState.STOPPED, PollingState.BLOCKED):
self._profile_settings = settings
self._refresh_idle_gate()
def update_readiness(self, readiness: StartReadiness) -> None:
self._readiness = readiness
self._refresh_idle_gate()
def _refresh_idle_gate(self) -> None:
if self._operation is not None or self.state == PollingState.RECOVERY_REQUIRED:
return
if self._consumer is None:
self._set_state(PollingState.BLOCKED, "单趟执行能力尚未接入,不能领取真实任务。")
elif self._gateway_factory is None or self._store is None:
self._set_state(PollingState.BLOCKED, "轮询依赖未完整注入,不能领取真实任务。")
elif self._profile_settings is None:
self._set_state(PollingState.BLOCKED, "尚未保存完整配置,不能开始轮询。")
elif self._readiness is None or not self._readiness.ready:
reason = "执行就绪条件未满足,不能开始轮询。" if self._readiness is None else self._readiness.reason
self._set_state(PollingState.BLOCKED, reason)
else:
self._set_state(PollingState.STOPPED, "轮询已停止。")
def start(self) -> None:
# 这道门禁必须早于任何 store/gateway 调用;独立应用没有 consumer,
# 即使调用方绕过禁用按钮直接调用本方法也保持零 HTTP。
if self._consumer is None:
self._set_state(PollingState.BLOCKED, "单趟执行能力尚未接入,不能领取真实任务。")
return
if self._store is None or self._gateway_factory is None or self._worker is None:
self._set_state(PollingState.BLOCKED, "轮询依赖未完整注入,不能领取真实任务。")
return
if self._profile_settings is None:
self._set_state(PollingState.BLOCKED, "尚未保存完整配置,不能开始轮询。")
return
if self._readiness is None or not self._readiness.ready:
reason = "执行就绪条件未满足,不能开始轮询。" if self._readiness is None else self._readiness.reason
self._set_state(PollingState.BLOCKED, reason)
return
if not self.can_start:
return
frozen = self._profile_settings
try:
gateway = self._gateway_factory(frozen)
except Exception:
self._set_state(PollingState.BLOCKED, "领取网关无法按本次冻结配置建立,不能开始轮询。")
return
if gateway is None or not hasattr(gateway, "claim_next"):
self._set_state(PollingState.BLOCKED, "领取网关未完整注入,不能开始轮询。")
return
self._frozen_profile = frozen
self._interval_ms = self._timer_interval_override or frozen.poll_interval_seconds * 1000
self._failure_threshold = frozen.failure_threshold
self._gateway = gateway
self._configure_gateway_requested.emit(gateway)
self._epoch += 1
self._scheduled_epoch = None
self._timer.stop()
self._stop_requested = False
self._post_stop_state = PollingState.STOPPED
self._consecutive_failures = 0
self._operation = "bootstrap"
self._set_state(PollingState.STARTING, "正在读取本地恢复状态并建立轮询会话…")
self._bootstrap_requested.emit(self.profile_id)
def stop(self) -> None:
if self.state == PollingState.STOPPED and self._operation is None:
return
if self._store is None or self._worker is None:
return
# epoch/latch 双保险:stopEvent 先让 timer 队列中已经排队的 timeout
# 失效,再处理持久 stop;之后只有显式 Start 才会获得新 epoch。
self._epoch += 1
self._scheduled_epoch = None
self._stop_requested = True
self._timer.stop()
if self._operation in ("inspect", "bootstrap", "claim", "stop"):
if self._operation == "claim":
self._set_state(PollingState.CLAIMING, "正在等待本次有界领取返回;不会取消或重发请求。")
return
self._request_stop(PollingState.STOPPED, "轮询已停止;只阻止下一次领取。")
def shutdown(self, wait_ms: int = 5000) -> bool:
"""只结束空闲 worker;飞行中 I/O 必须由事件循环等待 settled。"""
self._timer.stop()
if self._operation is not None:
return False
if self._thread is not None and self._thread.isRunning():
self._thread.quit()
return self._thread.wait(wait_ms)
return True
@Slot(object)
def _on_bootstrap_finished(self, raw: object) -> None:
completed_operation = self._operation
self._operation = None
result = raw
if not isinstance(result, _BootstrapResult):
self._block("本地恢复结果无效,轮询已阻止。")
return
self.recovery_status = result.recovery
self.recovery_status_changed.emit(result.recovery)
self.configuration_freeze_changed.emit(
result.recovery.has_pending_claim or result.recovery.has_active_claim
)
if completed_operation == "inspect":
if result.recovery.has_active_claim:
self._set_state(PollingState.RECOVERY_REQUIRED, "遗留会话已停止;必须先安全恢复当前任务。")
elif result.recovery.has_pending_claim:
self._set_state(PollingState.STOPPED, "遗留领取请求已停止;显式开始后只使用原幂等键恢复。")
else:
self._refresh_idle_gate()
self.settled.emit()
return
if result.normalized_stop is not None:
if result.recovery.has_active_claim:
self._set_state(PollingState.RECOVERY_REQUIRED, "遗留会话已停止;必须先安全恢复当前任务。")
else:
self._set_state(PollingState.STOPPED, "遗留会话已停止;请再次显式开始轮询。")
self.settled.emit()
return
if result.recovery.has_active_claim:
self._set_state(
PollingState.RECOVERY_REQUIRED,
"检测到未关闭的采购任务,必须先完成安全恢复,不能领取新任务。",
)
self.settled.emit()
return
if self._stop_requested:
self._request_stop(PollingState.STOPPED, "轮询已停止;未发起领取请求。")
return
if result.recovery.has_pending_claim:
self._set_state(PollingState.RECOVERING, "正在使用原幂等键恢复结果不明的领取请求…")
else:
self._set_state(PollingState.WAITING, "轮询会话已启动,正在等待领取。")
self._schedule_claim(0)
def _schedule_claim(self, delay_ms: int) -> None:
self._scheduled_epoch = self._epoch
self._timer.start(delay_ms)
@Slot()
def _on_timer_timeout(self) -> None:
scheduled_epoch = self._scheduled_epoch
self._scheduled_epoch = None
if scheduled_epoch != self._epoch:
return
self._begin_claim(scheduled_epoch)
def _begin_claim(self, dispatch_epoch: int) -> None:
if dispatch_epoch != self._epoch:
return
if self._operation is not None or self._stop_requested:
return
if self.state not in (PollingState.WAITING, PollingState.RECOVERING):
return
self._operation = "claim"
self.configuration_freeze_changed.emit(True)
self._set_state(PollingState.CLAIMING, "正在领取已授权任务…")
self._claim_requested.emit(self.profile_id)
@Slot(object)
def _on_claim_finished(self, claimed: object) -> None:
self._operation = None
self._consecutive_failures = 0
if claimed is not None and not isinstance(claimed, ClaimedTask):
self._request_stop(PollingState.BLOCKED, "领取结果类型无效,轮询已阻止。")
return
if isinstance(claimed, ClaimedTask):
self.claim_visible.emit(ClaimedTaskView.from_claim(claimed))
if self._stop_requested:
self._request_stop(
PollingState.RECOVERY_REQUIRED,
"停止期间领取已落库;必须先安全恢复该任务,不能领取下一条。",
)
return
try:
consumer = self._consumer
frozen_profile = self._frozen_profile
if consumer is None or frozen_profile is None:
raise RuntimeError("execution_consumer_missing")
# consumer 只能使用本次显式 Start 冻结的不可变配置;不得在
# 已领取后回读可变 UI/store,否则 ADB 身份和超时会发生趟内漂移。
consumer.accept_claim(claimed, frozen_profile)
except Exception:
self._request_stop(
PollingState.RECOVERY_REQUIRED,
"执行 consumer 未接收已落库任务;必须安全恢复,不能重新领取。",
)
return
self._set_state(PollingState.ACTIVE, "任务已安全领取并交给单趟执行能力。")
return
self.configuration_freeze_changed.emit(False)
if self._stop_requested:
self._request_stop(PollingState.STOPPED, "轮询已停止;本次没有可领取任务。")
return
self._set_state(PollingState.WAITING, "暂无已授权任务,等待下一次轮询。")
self._schedule_claim(self._interval_ms)
def _request_stop(self, target: PollingState, reason: str) -> None:
if self._operation == "stop":
return
self._timer.stop()
self._post_stop_state = target
self._post_stop_reason = reason
self._operation = "stop"
self._stop_requested_signal.emit(self.profile_id)
@Slot(object)
def _on_stop_finished(self, session: object) -> None:
self._operation = None
if not isinstance(session, PollingSession) or session.accept_new:
self._block("停止状态未能持久化,轮询已阻止。")
return
self._set_state(self._post_stop_state, self._post_stop_reason)
self.settled.emit()
@Slot(str, object)
def _on_worker_failed(self, operation: str, error: object) -> None:
self._operation = None
if operation == "inspect":
if isinstance(error, StateError) and error.reason == "profile_not_found":
self.recovery_status = RecoveryStatus(False, False, False, False, False)
self.recovery_status_changed.emit(self.recovery_status)
self._refresh_idle_gate()
else:
self._block("本地恢复状态无法安全读取;已停止且不能领取任务。")
self.settled.emit()
return
if (
operation == "claim"
and isinstance(error, AmbiguousRemoteError)
and error.reason in SAFE_AMBIGUOUS_REASONS
):
self._consecutive_failures += 1
if self._stop_requested:
self._request_stop(PollingState.STOPPED, "轮询已停止;结果不明的原领取请求已保留。")
elif self._consecutive_failures >= self._failure_threshold:
self._request_stop(
PollingState.BLOCKED,
"连续领取失败达到阈值;原幂等请求已保留,需排查后重新开始。",
)
else:
self._set_state(
PollingState.RECOVERING,
"领取结果不明;等待使用相同幂等键恢复,不会创建新请求。",
)
self._schedule_claim(self._interval_ms)
return
if operation == "claim" and isinstance(error, AmbiguousRemoteError):
self.configuration_freeze_changed.emit(True)
self._request_stop(
PollingState.BLOCKED,
"领取响应无法证明可安全定时恢复;原槽已保留,需显式开始后同键恢复。",
)
return
if operation == "stop":
self._block("停止状态无法安全落库,轮询已阻止;未清除任何恢复事实。")
return
if isinstance(error, CredentialRemoteError):
reason = "设备凭据无效或已撤销;修复凭据后再手工开始。"
elif isinstance(error, ProtocolRemoteError):
reason = "服务响应与固定协议不兼容;已停止普通重试。"
elif isinstance(error, ManualRemoteError):
reason = "服务端要求人工处理;已停止普通重试。"
elif isinstance(error, ClientError):
reason = "本地安全状态无法推进;已停止普通重试。"
else:
reason = "轮询发生未分类错误;已失败闭合。"
if operation == "claim":
# DurableClientGateway 已把协议错误和 409 人工冲突标成 terminal,
# 二者没有 pending/active;凭据或本地错误则可能保留 pending,继续冻结。
self.configuration_freeze_changed.emit(
not isinstance(error, (ProtocolRemoteError, ManualRemoteError))
)
if operation in ("bootstrap", "configure"):
self._block(reason)
else:
self._request_stop(PollingState.BLOCKED, reason)
def _block(self, reason: str) -> None:
self._timer.stop()
self._scheduled_epoch = None
self._set_state(PollingState.BLOCKED, reason)
if self._operation is None:
self.settled.emit()
def _set_state(self, state: PollingState, reason: str) -> None:
self.state = state
self.reason = reason
self.state_changed.emit(state, reason, self._consecutive_failures)
@@ -0,0 +1,7 @@
"""只连接固定本机采购服务的 HTTP 适配器。"""
from .evidence_sink import HttpEvidenceSink
from .http_transport import HttpTransport, LOOPBACK_SERVICE_URL
from .task_source import HttpTaskSource
__all__ = ["HttpEvidenceSink", "HttpTaskSource", "HttpTransport", "LOOPBACK_SERVICE_URL"]
@@ -0,0 +1,84 @@
"""仅上传调用方显式提供的单个 PNG 的窄 EvidenceSink。"""
from __future__ import annotations
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError, ValidationError
from cmbuyer_client.core.models import AssetReceipt, DeviceCredentials, EvidenceUpload
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
from .http_transport import HttpTransport
from .wire import SMALL_RESPONSE_LIMIT, classify_bodyless_error, common_headers, parse_json_response
class HttpEvidenceSink:
def __init__(self, transport: HttpTransport) -> None:
self._transport = transport
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt:
boundary = "cmbuyer-" + evidence.upload_key.replace("-", "")
marker = ("--" + boundary).encode("ascii")
if marker in evidence.content:
raise ProtocolRemoteError("multipart_boundary_collision")
body = _multipart_body(boundary, evidence)
response = self._transport.request(
"POST",
f"/api/v1/tasks/{evidence.task_id}/evidence",
common_headers(
credentials.device_id,
credentials.token.value,
"multipart/form-data; boundary=" + boundary,
),
body,
response_limit=SMALL_RESPONSE_LIMIT,
)
if response.status not in (200, 201):
classify_bodyless_error(response)
try:
receipt = AssetReceipt.from_wire(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT))
except ValidationError as error:
raise AmbiguousRemoteError("invalid_evidence_success_response") from error
if (
receipt.task_id != evidence.task_id
or receipt.attempt_id != evidence.attempt_id
or receipt.kind != evidence.kind
or receipt.privacy_tier != evidence.privacy_tier
or receipt.sha256 != evidence.sha256
or receipt.byte_size != len(evidence.content)
or receipt.width_px != evidence.width_px
or receipt.height_px != evidence.height_px
or rfc3339_z_nanoseconds(receipt.captured_at) != rfc3339_z_nanoseconds(evidence.captured_at)
):
raise AmbiguousRemoteError("evidence_response_mismatch")
return receipt
def _multipart_body(boundary: str, evidence: EvidenceUpload) -> bytes:
chunks: list[bytes] = []
def add_field(name: str, value: str) -> None:
chunks.extend(
(
f"--{boundary}\r\n".encode("ascii"),
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("ascii"),
value.encode("utf-8"),
b"\r\n",
)
)
add_field("upload_key", evidence.upload_key)
add_field("attempt_id", evidence.attempt_id)
add_field("kind", evidence.kind)
add_field("privacy_tier", evidence.privacy_tier)
add_field("sha256", evidence.sha256)
add_field("captured_at", evidence.captured_at)
chunks.extend(
(
f"--{boundary}\r\n".encode("ascii"),
b'Content-Disposition: form-data; name="file"; filename="evidence.png"\r\n',
b"Content-Type: image/png\r\n\r\n",
evidence.content,
b"\r\n",
f"--{boundary}--\r\n".encode("ascii"),
)
)
return b"".join(chunks)
@@ -0,0 +1,116 @@
"""无代理、无重定向、无隐藏重试的 localhost HTTP transport。"""
from __future__ import annotations
from dataclasses import dataclass
import http.client
import re
from typing import Callable, Iterable
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError
LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080"
_HOST = "127.0.0.1"
_PORT = 8080
@dataclass(frozen=True)
class HttpResponse:
status: int
headers: tuple[tuple[str, str], ...]
body: bytes
def header_values(self, name: str) -> tuple[str, ...]:
wanted = name.lower()
return tuple(value for key, value in self.headers if key.lower() == wanted)
class HttpTransport:
"""每次调用只创建一个直连 TCP 请求;重试只能由持久化恢复层决定。"""
def __init__(
self,
service_url: str = LOOPBACK_SERVICE_URL,
*,
timeout_seconds: int = 10,
connection_factory: Callable[..., http.client.HTTPConnection] = http.client.HTTPConnection,
) -> None:
if service_url != LOOPBACK_SERVICE_URL:
raise ProtocolRemoteError("service_url_not_allowed")
if type(timeout_seconds) is not int or not 1 <= timeout_seconds <= 120:
raise ProtocolRemoteError("invalid_http_timeout")
self._timeout_seconds = timeout_seconds
self._connection_factory = connection_factory
def request(
self,
method: str,
path: str,
headers: Iterable[tuple[str, str]],
body: bytes,
*,
response_limit: int,
) -> HttpResponse:
if method != "POST" or not path.startswith("/api/v1/") or "?" in path or "#" in path:
raise ProtocolRemoteError("invalid_http_target")
if not isinstance(body, bytes) or type(response_limit) is not int or response_limit <= 0:
raise ProtocolRemoteError("invalid_http_request")
header_items = tuple(headers)
normalized: dict[str, str] = {}
for key, value in header_items:
lowered = key.lower()
if lowered in normalized or "\r" in key or "\n" in key or "\r" in value or "\n" in value:
raise ProtocolRemoteError("invalid_http_headers")
normalized[lowered] = value
connection: http.client.HTTPConnection | None = None
result: HttpResponse | None = None
failure: str | None = None
try:
connection = self._connection_factory(_HOST, _PORT, timeout=self._timeout_seconds)
connection.request(method, path, body=body, headers={key: value for key, value in header_items})
response = connection.getresponse()
response_headers = tuple(response.getheaders())
content_lengths = tuple(value for key, value in response_headers if key.lower() == "content-length")
transfer_encodings = tuple(value for key, value in response_headers if key.lower() == "transfer-encoding")
if len(content_lengths) > 1:
raise AmbiguousRemoteError("invalid_content_length")
if content_lengths and transfer_encodings:
raise AmbiguousRemoteError("ambiguous_response_framing")
if len(transfer_encodings) > 1 or (
transfer_encodings and transfer_encodings[0].lower() != "chunked"
):
raise AmbiguousRemoteError("invalid_transfer_encoding")
declared = content_lengths[0] if content_lengths else None
declared_length: int | None = None
if declared is not None:
if re.fullmatch(r"[0-9]+", declared, flags=re.ASCII) is None:
raise AmbiguousRemoteError("invalid_content_length")
if len(declared) > 10:
raise AmbiguousRemoteError("response_too_large")
declared_length = int(declared)
if declared_length > response_limit:
raise AmbiguousRemoteError("response_too_large")
response_body = response.read(response_limit + 1)
if len(response_body) > response_limit:
raise AmbiguousRemoteError("response_too_large")
if declared_length is not None and len(response_body) != declared_length:
raise AmbiguousRemoteError("truncated_response")
result = HttpResponse(response.status, response_headers, response_body)
except AmbiguousRemoteError as error:
failure = error.reason
except (OSError, TimeoutError, http.client.HTTPException):
failure = "http_result_unknown"
finally:
if connection is not None:
try:
connection.close()
except OSError:
if result is None:
failure = "http_result_unknown"
if failure is not None:
raise AmbiguousRemoteError(failure)
if result is None:
raise AmbiguousRemoteError("http_result_unknown")
return result
@@ -0,0 +1,75 @@
"""领取与续租的固定 localhost HTTP 适配器。"""
from __future__ import annotations
from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest, RenewResult
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
from .http_transport import HttpTransport
from .wire import (
JSON_RESPONSE_LIMIT,
SMALL_RESPONSE_LIMIT,
classify_json_error,
common_headers,
encode_json,
parse_json_response,
)
class HttpTaskSource:
def __init__(self, transport: HttpTransport) -> None:
self._transport = transport
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None:
body = encode_json(request.to_wire())
response = self._transport.request(
"POST",
"/api/v1/tasks/claim-next",
common_headers(credentials.device_id, credentials.token.value, "application/json"),
body,
response_limit=JSON_RESPONSE_LIMIT,
)
if response.status == 204:
if response.body or response.header_values("Content-Encoding"):
raise AmbiguousRemoteError("invalid_empty_claim_response")
return None
if response.status != 200:
classify_json_error(
response,
allowed_409=frozenset(("idempotency_conflict", "claim_requires_manual")),
)
try:
claimed = ClaimedTask.from_wire(parse_json_response(response, maximum=JSON_RESPONSE_LIMIT))
except ValidationError as error:
raise AmbiguousRemoteError("invalid_claim_success_response") from error
if rfc3339_z_nanoseconds(claimed.attempt.lease_expires_at) > rfc3339_z_nanoseconds(claimed.authorization.expires_at):
raise AmbiguousRemoteError("invalid_claim_lease")
return claimed
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult:
response = self._transport.request(
"POST",
f"/api/v1/tasks/{request.task_id}/lease/renew",
common_headers(credentials.device_id, credentials.token.value, "application/json"),
encode_json(request.to_wire()),
response_limit=SMALL_RESPONSE_LIMIT,
)
if response.status != 200:
classify_json_error(
response,
allowed_409=frozenset(("idempotency_conflict", "claim_not_current")),
)
try:
result = RenewResult.from_wire(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT))
except ValidationError as error:
raise AmbiguousRemoteError("invalid_renew_success_response") from error
if (
result.task_id != request.task_id
or result.attempt_id != request.attempt_id
or result.claim_generation != request.claim_generation
or rfc3339_z_nanoseconds(result.lease_expires_at) < rfc3339_z_nanoseconds(request.expected_lease_expires_at)
or rfc3339_z_nanoseconds(result.lease_expires_at) > rfc3339_z_nanoseconds(request.authorization_expires_at)
):
raise AmbiguousRemoteError("renew_response_mismatch")
return result
+106
View File
@@ -0,0 +1,106 @@
"""T-302/T-204 固定 HTTP wire 的编码、解码与错误分类。"""
from __future__ import annotations
import json
from typing import Any, Mapping
from cmbuyer_client.core.errors import (
AmbiguousRemoteError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
ValidationError,
)
from cmbuyer_client.core.validation import require_exact_fields, strict_json_loads
from .http_transport import HttpResponse
JSON_REQUEST_LIMIT = 4096
JSON_RESPONSE_LIMIT = 32 * 1024
SMALL_RESPONSE_LIMIT = 8 * 1024
JSON_CONTENT_TYPES = frozenset(("application/json", "application/json; charset=utf-8"))
def encode_json(value: Mapping[str, object]) -> bytes:
body = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
if len(body) > JSON_REQUEST_LIMIT:
raise ProtocolRemoteError("request_too_large")
return body
def common_headers(device_id: str, token: str, content_type: str) -> tuple[tuple[str, str], ...]:
return (
("Authorization", "Bearer " + token),
("X-CMBuyer-Device-ID", device_id),
("Accept", "application/json"),
("Content-Type", content_type),
)
def parse_json_response(response: HttpResponse, *, maximum: int) -> object:
encodings = response.header_values("Content-Encoding")
types = response.header_values("Content-Type")
if encodings or len(types) != 1 or types[0].lower() not in JSON_CONTENT_TYPES:
raise ValidationError("invalid_response_content_type")
return strict_json_loads(response.body, maximum=maximum)
def require_empty_response(response: HttpResponse) -> None:
if response.body or response.header_values("Content-Encoding"):
raise ProtocolRemoteError("unexpected_error_body")
def classify_json_error(response: HttpResponse, *, allowed_409: frozenset[str]) -> None:
"""抛出错误,不返回。调用方只在非成功状态使用。"""
if 200 <= response.status <= 299:
# 服务端可能已提交幂等事实;未知 2xx 绝不能终结本地槽或换 key。
raise AmbiguousRemoteError("unknown_success_status")
if response.status == 401:
require_empty_response(response)
raise CredentialRemoteError("device_credential_rejected")
if response.status == 503 or 500 <= response.status <= 599:
# 5xx 无法证明服务端是否在提交响应前完成事务。
raise AmbiguousRemoteError("server_result_unknown")
if response.status == 409:
try:
data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",))
code = data["error"]
except ValidationError as error:
raise ProtocolRemoteError("invalid_conflict_response") from error
if not isinstance(code, str) or code not in allowed_409:
raise ProtocolRemoteError("unknown_conflict")
raise ManualRemoteError(code)
expected = {400: "invalid_request", 413: "request_too_large", 415: "unsupported_media_type"}
if response.status in expected:
try:
data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",))
except ValidationError as error:
raise ProtocolRemoteError("invalid_error_response") from error
if data["error"] != expected[response.status]:
raise ProtocolRemoteError("unexpected_error_code")
raise ProtocolRemoteError(expected[response.status])
if 300 <= response.status <= 399:
raise ProtocolRemoteError("redirect_rejected")
raise ProtocolRemoteError("unexpected_http_status")
def classify_bodyless_error(response: HttpResponse) -> None:
if 200 <= response.status <= 299:
raise AmbiguousRemoteError("unknown_success_status")
if response.status == 401:
require_empty_response(response)
raise CredentialRemoteError("device_credential_rejected")
if response.status == 503 or 500 <= response.status <= 599:
raise AmbiguousRemoteError("server_result_unknown")
if response.status == 409:
require_empty_response(response)
raise ManualRemoteError("evidence_conflict")
if response.status in (400, 403, 413, 415):
require_empty_response(response)
raise ProtocolRemoteError("evidence_request_rejected")
if 300 <= response.status <= 399:
raise ProtocolRemoteError("redirect_rejected")
raise ProtocolRemoteError("unexpected_http_status")
+62 -2
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
from typing import Any, Callable
@dataclass(frozen=True)
@@ -17,21 +18,34 @@ class RuntimePaths:
root: Path
logs: Path
artifacts: Path
state: Path
database: Path
@classmethod
def from_root(cls, root: Path) -> "RuntimePaths":
resolved_root = root.expanduser()
# 路径在进程启动时一次性固化;之后 cwd 改变不能打开第二套数据库或绕过原 mutex。
resolved_root = root.expanduser().resolve(strict=False)
state = resolved_root / "state"
return cls(
root=resolved_root,
logs=resolved_root / "logs",
artifacts=resolved_root / "artifacts",
state=state,
database=state / "client-state.sqlite3",
)
@classmethod
def default(cls) -> "RuntimePaths":
local_app_data = os.environ.get("LOCALAPPDATA")
if local_app_data:
return cls.from_root(Path(local_app_data) / "cmbuyer")
local_root = Path(local_app_data).expanduser()
if not local_root.is_absolute():
raise RuntimeError("local_app_data_must_be_absolute")
return cls.from_root(local_root / "cmbuyer")
if os.name == "nt":
# Windows 上回退到 home 会悄悄创建第二套状态库并绕开同一 mutex,必须失败闭合。
raise RuntimeError("local_app_data_required")
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
@@ -40,3 +54,49 @@ class RuntimePaths:
self.logs.mkdir(parents=True, exist_ok=True)
self.artifacts.mkdir(parents=True, exist_ok=True)
self.state.mkdir(parents=True, exist_ok=True)
@dataclass
class LocalStateRuntime:
"""持有 named mutex 与本地状态库,保证 mutex 总是先取得。"""
paths: RuntimePaths
mutex: Any
store: Any
@classmethod
def open(
cls,
paths: RuntimePaths | None = None,
*,
mutex_factory: Callable[[Path], Any] | None = None,
protector_factory: Callable[[], Any] | None = None,
store_factory: Callable[[Path, Any], Any] | None = None,
) -> "LocalStateRuntime":
from .localstate.protection import DpapiProtector
from .localstate.single_instance import NamedMutex
from .localstate.store import LocalStateStore
selected = paths or RuntimePaths.default()
selected.ensure_exists()
make_mutex = mutex_factory or NamedMutex
make_protector = protector_factory or DpapiProtector
make_store = store_factory or LocalStateStore
mutex = make_mutex(selected.database)
try:
protector = make_protector()
store = make_store(selected.database, protector)
except Exception:
mutex.close()
raise
return cls(selected, mutex, store)
def close(self) -> None:
self.mutex.close()
def __enter__(self) -> "LocalStateRuntime":
return self
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
self.close()
+5
View File
@@ -0,0 +1,5 @@
"""采购工具原生 Qt Widgets 界面。"""
from .main_window import PurchaseToolWindow
__all__ = ["PurchaseToolWindow"]
+419
View File
@@ -0,0 +1,419 @@
"""采购执行 Tab:状态、当前任务、滚动日志和历史记录主从视图。"""
from __future__ import annotations
from PySide6.QtCore import QModelIndex, QSize, Qt, Signal, Slot
from PySide6.QtGui import QAction, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QGroupBox,
QHBoxLayout,
QLabel,
QMenu,
QPlainTextEdit,
QPushButton,
QSizePolicy,
QSplitter,
QStackedWidget,
QTableView,
QToolButton,
QVBoxLayout,
QWidget,
)
from cmbuyer_client.polling.coordinator import ClaimedTaskView, PollingCoordinator, PollingState
from cmbuyer_client.logging_policy import redact_text
from .records import PurchaseRecord, PurchaseRecordModel, PurchaseRecordProvider
class _LogView(QPlainTextEdit):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("rollingLog")
self.setReadOnly(True)
self.setPlaceholderText("轮询启动后将在这里显示脱敏日志。")
self.setAccessibleName("滚动日志")
def append_event(self, text: str) -> None:
bar = self.verticalScrollBar()
follow = bar.value() >= bar.maximum() - 2
self.appendPlainText(redact_text(text))
if follow:
bar.setValue(bar.maximum())
class _RecordTableView(QTableView):
"""把双击与 Enter 收敛为唯一 activation 信号,避免平台重复发命令。"""
record_activated = Signal(object)
def mouseDoubleClickEvent(self, event) -> None:
index = self.indexAt(event.position().toPoint())
if index.isValid():
self.setCurrentIndex(index.siblingAtColumn(0))
self.record_activated.emit(index)
event.accept()
return
super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event) -> None:
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and self.currentIndex().isValid():
self.record_activated.emit(self.currentIndex())
event.accept()
return
super().keyPressEvent(event)
class ExecutionPage(QWidget):
LIVE_PAGE = 0
DETAIL_PAGE = 1
COMPACT_DETAIL_WIDTH = 760
def __init__(
self,
coordinator: PollingCoordinator,
record_provider: PurchaseRecordProvider | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("executionPage")
self._coordinator = coordinator
self._selected_record_id: str | None = None
self._saved_scroll = 0
outer = QVBoxLayout(self)
outer.setContentsMargins(12, 12, 12, 12)
status_row = QHBoxLayout()
self.service_status = self._status("采购服务", "待首次真实领取验证")
self.device_status = self._status("ADB", "待后续执行能力验证")
self.app_status = self._status("拼多多版本", "待后续执行能力验证")
self.session_status = self._status("会话", "已停止")
for widget in (self.service_status, self.device_status, self.app_status, self.session_status):
status_row.addWidget(widget)
status_row.addStretch(1)
self.poll_action = QAction("开始轮询", self)
self.poll_action.setObjectName("pollAction")
self.poll_action.triggered.connect(self._toggle_polling)
self.addAction(self.poll_action)
self.poll_button = QPushButton()
self.poll_button.setObjectName("pollButton")
self.poll_button.clicked.connect(self.poll_action.trigger)
status_row.addWidget(self.poll_button)
outer.addLayout(status_row)
self.banner = QLabel()
self.banner.setObjectName("sessionBanner")
self.banner.setWordWrap(True)
self.banner.setAccessibleName("轮询会话状态")
self.banner.setFrameShape(QFrame.Shape.StyledPanel)
outer.addWidget(self.banner)
self.body_splitter = QSplitter(Qt.Orientation.Horizontal)
self.body_splitter.setObjectName("executionSplitter")
self.body_splitter.setChildrenCollapsible(False)
self.left_stack = QStackedWidget()
self.left_stack.setObjectName("leftWorkspace")
self.left_stack.addWidget(self._build_live_page())
self.left_stack.addWidget(self._build_detail_page())
self.body_splitter.addWidget(self.left_stack)
self.body_splitter.addWidget(self._build_records_page())
self.body_splitter.setStretchFactor(0, 2)
self.body_splitter.setStretchFactor(1, 1)
self.body_splitter.setSizes([760, 380])
outer.addWidget(self.body_splitter, 1)
self.view_record_action = QAction("查看所选记录", self)
self.view_record_action.setObjectName("viewSelectedRecord")
self.view_record_action.setEnabled(False)
self.view_record_action.triggered.connect(self.open_selected_record)
self.addAction(self.view_record_action)
self.view_record_button.setDefaultAction(self.view_record_action)
self.return_action = QAction("返回当前任务", self)
self.return_action.setObjectName("returnToCurrentTask")
self.return_action.setEnabled(False)
self.return_action.triggered.connect(self.return_to_live)
self.addAction(self.return_action)
self.return_button.setDefaultAction(self.return_action)
self.escape_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
self.escape_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
self.escape_shortcut.activated.connect(self._escape)
self.record_view.clicked.connect(self._on_record_selected)
self.record_view.record_activated.connect(self._open_index)
self.record_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.record_view.customContextMenuRequested.connect(self._show_record_menu)
self.record_view.selectionModel().currentChanged.connect(self._on_current_changed)
coordinator.state_changed.connect(self._on_polling_state)
coordinator.claim_visible.connect(self._show_claimed_task)
self._on_polling_state(coordinator.state, coordinator.reason, coordinator.consecutive_failures)
if record_provider is not None:
# T-304 没有公共历史仓库;只接受调用方准备好的 View DTO 快照,
# 独立应用不注入 provider,模型保持真实空态。
self.set_records(record_provider.snapshot())
@staticmethod
def _status(name: str, value: str) -> QLabel:
label = QLabel(f"{name}\n{value}")
label.setFrameShape(QFrame.Shape.StyledPanel)
label.setMinimumWidth(118)
label.setAccessibleName(name)
return label
def _build_live_page(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
task_group = QGroupBox("当前任务")
task_layout = QHBoxLayout(task_group)
self.current_task_text = QLabel("当前没有任务。\n启动后只领取已授权任务。")
self.current_task_text.setObjectName("currentTaskText")
self.current_task_text.setWordWrap(True)
self.current_task_text.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
self.current_task_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.current_image = QLabel("暂无可信商品图片")
self.current_image.setObjectName("currentTaskImage")
self.current_image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.current_image.setFrameShape(QFrame.Shape.StyledPanel)
self.current_image.setMinimumSize(QSize(180, 120))
self.current_image.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
task_layout.addWidget(self.current_task_text, 2)
task_layout.addWidget(self.current_image, 1)
layout.addWidget(task_group, 1)
log_group = QGroupBox("滚动日志")
log_layout = QVBoxLayout(log_group)
self.log_view = _LogView()
log_layout.addWidget(self.log_view)
layout.addWidget(log_group, 2)
return page
def _build_detail_page(self) -> QWidget:
page = QWidget()
page.setObjectName("recordDetailPage")
layout = QVBoxLayout(page)
header = QHBoxLayout()
self.detail_title = QLabel("采购记录详情")
self.detail_title.setObjectName("recordDetailTitle")
self.detail_title.setStyleSheet("font-size: 18px; font-weight: 600;")
header.addWidget(self.detail_title)
header.addStretch(1)
self.return_button = QToolButton()
self.return_button.setObjectName("returnCurrentTaskButton")
header.addWidget(self.return_button)
layout.addLayout(header)
top = QSplitter(Qt.Orientation.Horizontal)
self.detail_original = QPlainTextEdit()
self.detail_original.setObjectName("recordOriginalText")
self.detail_original.setReadOnly(True)
self.detail_original.setPlaceholderText("没有可显示的原始文字。")
self.detail_image = QLabel("没有可显示的可信图片")
self.detail_image.setObjectName("recordImage")
self.detail_image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.detail_image.setFrameShape(QFrame.Shape.StyledPanel)
top.addWidget(self.detail_original)
top.addWidget(self.detail_image)
top.setStretchFactor(0, 2)
top.setStretchFactor(1, 1)
layout.addWidget(top, 2)
result_group = QGroupBox("采购结果")
result_layout = QVBoxLayout(result_group)
self.detail_result = QPlainTextEdit()
self.detail_result.setObjectName("recordResult")
self.detail_result.setReadOnly(True)
self.detail_result.setPlaceholderText("暂无采购结果。")
result_layout.addWidget(self.detail_result)
layout.addWidget(result_group, 1)
return page
def _build_records_page(self) -> QWidget:
page = QGroupBox("采购记录")
page.setObjectName("recordsPanel")
self.records_panel = page
layout = QVBoxLayout(page)
header = QHBoxLayout()
self.records_summary = QLabel("暂无记录")
header.addWidget(self.records_summary)
header.addStretch(1)
self.view_record_button = QToolButton()
self.view_record_button.setObjectName("viewSelectedRecordButton")
header.addWidget(self.view_record_button)
layout.addLayout(header)
self.record_model = PurchaseRecordModel(parent=self)
self.record_view = _RecordTableView()
self.record_view.setObjectName("purchaseRecordTable")
self.record_view.setModel(self.record_model)
self.record_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.record_view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.record_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.record_view.setAlternatingRowColors(True)
self.record_view.setSortingEnabled(False)
self.record_view.horizontalHeader().setStretchLastSection(False)
self.record_view.horizontalHeader().setSectionResizeMode(0, self.record_view.horizontalHeader().ResizeMode.Stretch)
self.record_view.horizontalHeader().setSectionResizeMode(1, self.record_view.horizontalHeader().ResizeMode.ResizeToContents)
self.record_view.verticalHeader().setVisible(False)
layout.addWidget(self.record_view)
return page
def set_records(self, records: list[PurchaseRecord]) -> None:
selected_id = self._selected_record_id
self.record_model.set_records(records)
self.records_summary.setText(f"共 {len(records)} 条" if records else "暂无记录")
if selected_id is not None:
row = self.record_model.row_for_id(selected_id)
if row >= 0:
self.record_view.setCurrentIndex(self.record_model.index(row, 0))
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
self._render_record(self.record_model.record_at(row))
return
self._selected_record_id = None
self.view_record_action.setEnabled(False)
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
self.return_to_live()
@Slot(object, str, int)
def _on_polling_state(self, state: object, reason: str, failures: int) -> None:
polling_state = state if isinstance(state, PollingState) else PollingState.BLOCKED
self.session_status.setText(f"会话\n{self._state_text(polling_state)}")
suffix = f"(连续失败 {failures} 次)" if failures else ""
self.banner.setText(reason + suffix)
running = polling_state in (
PollingState.STARTING,
PollingState.RECOVERING,
PollingState.WAITING,
PollingState.CLAIMING,
PollingState.ACTIVE,
)
self.poll_action.setText("停止轮询" if running else "开始轮询")
self.poll_action.setEnabled(running or self._coordinator.can_start)
self.poll_button.setText(self.poll_action.text())
self.poll_button.setEnabled(self.poll_action.isEnabled())
self.poll_button.setToolTip("" if self.poll_action.isEnabled() else reason)
@staticmethod
def _state_text(state: PollingState) -> str:
return {
PollingState.STOPPED: "已停止",
PollingState.STARTING: "启动中",
PollingState.BLOCKED: "已阻止",
PollingState.RECOVERING: "安全恢复",
PollingState.WAITING: "等待领取",
PollingState.CLAIMING: "正在领取",
PollingState.ACTIVE: "任务执行中",
PollingState.RECOVERY_REQUIRED: "待安全恢复",
}[state]
@Slot()
def _toggle_polling(self) -> None:
if self._coordinator.state in (
PollingState.STARTING,
PollingState.RECOVERING,
PollingState.WAITING,
PollingState.CLAIMING,
PollingState.ACTIVE,
):
self._coordinator.stop()
else:
self._coordinator.start()
@Slot(QModelIndex)
def _on_record_selected(self, index: QModelIndex) -> None:
record = self.record_model.record_at(index.row())
if record is None:
return
self._selected_record_id = record.record_id
self.view_record_action.setEnabled(True)
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
self._saved_scroll = self.record_view.verticalScrollBar().value()
self._render_record(record)
@Slot(object)
def _show_claimed_task(self, raw: object) -> None:
if not isinstance(raw, ClaimedTaskView):
return
self.current_task_text.setText(
f"标题:{raw.title}\n任务 ID:{raw.task_id}\n状态:{raw.status}"
)
self.log_view.append_event(f"已安全领取任务 {raw.task_id};等待单趟执行能力处理。")
@Slot(QModelIndex, QModelIndex)
def _on_current_changed(self, current: QModelIndex, previous: QModelIndex) -> None:
del previous
if current.isValid():
self._on_record_selected(current)
@Slot(QModelIndex)
def _open_index(self, index: QModelIndex) -> None:
if index.isValid():
self.record_view.setCurrentIndex(index.siblingAtColumn(0))
self._on_record_selected(index)
self.view_record_action.trigger()
@Slot()
def open_selected_record(self) -> None:
if self._selected_record_id is None:
return
row = self.record_model.row_for_id(self._selected_record_id)
record = self.record_model.record_at(row)
if record is None:
return
self._saved_scroll = self.record_view.verticalScrollBar().value()
self._render_record(record)
self.left_stack.setCurrentIndex(self.DETAIL_PAGE)
self.return_action.setEnabled(True)
self._apply_compact_detail()
self.return_button.setFocus()
def _render_record(self, record: PurchaseRecord | None) -> None:
if record is None:
self.detail_title.setText("记录不存在")
self.detail_original.clear()
self.detail_result.clear()
self.detail_image.setText("没有可显示的可信图片")
return
self.detail_title.setText(record.title)
self.detail_original.setPlainText(record.original_text)
self.detail_result.setPlainText(record.result_text)
self.detail_image.setText(record.image_description or "没有可显示的可信图片")
@Slot()
def return_to_live(self) -> None:
if self.left_stack.currentIndex() != self.DETAIL_PAGE:
return
self.left_stack.setCurrentIndex(self.LIVE_PAGE)
self.return_action.setEnabled(False)
self.records_panel.setVisible(True)
row = self.record_model.row_for_id(self._selected_record_id or "")
if row >= 0:
self.record_view.setCurrentIndex(self.record_model.index(row, 0))
self.record_view.verticalScrollBar().setValue(self._saved_scroll)
self.record_view.setFocus()
@Slot()
def _escape(self) -> None:
# Qt popup/menu 优先消费 Esc;只有详情态的页面级 shortcut 会执行返回。
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
self.return_action.trigger()
@Slot(object)
def _show_record_menu(self, point: object) -> None:
index = self.record_view.indexAt(point)
if index.isValid():
self.record_view.setCurrentIndex(index.siblingAtColumn(0))
self._on_record_selected(index)
menu = QMenu(self.record_view)
menu.addAction(self.view_record_action)
menu.exec(self.record_view.viewport().mapToGlobal(point))
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self._apply_compact_detail()
def _apply_compact_detail(self) -> None:
compact_detail = self.width() < self.COMPACT_DETAIL_WIDTH and self.left_stack.currentIndex() == self.DETAIL_PAGE
self.records_panel.setVisible(not compact_detail)
@@ -0,0 +1,95 @@
"""采购工具固定双 Tab 原生窗口。"""
from __future__ import annotations
from PySide6.QtCore import QTimer, Qt, Slot
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import QMainWindow, QTabWidget
from cmbuyer_client.localstate.models import ProfileSettings
from cmbuyer_client.polling.coordinator import PollingCoordinator, PollingState, RecoveryStatus
from .execution import ExecutionPage
from .records import PurchaseRecordProvider
from .settings import ProfileStore, SettingsPage
class PurchaseToolWindow(QMainWindow):
EXECUTION_PAGE_ID = "purchase-execution"
SETTINGS_PAGE_ID = "settings"
def __init__(
self,
*,
store: ProfileStore,
coordinator: PollingCoordinator,
profile_settings: ProfileSettings | None,
has_stored_device_token: bool,
record_provider: PurchaseRecordProvider | None = None,
parent=None,
) -> None:
super().__init__(parent)
self.setWindowTitle("采购工具")
self.setAccessibleName("采购工具")
self.setMinimumSize(720, 520)
self.resize(1180, 760)
self._coordinator = coordinator
self._close_pending = False
recovery = coordinator.recovery_status
frozen = recovery is None or recovery.has_pending_claim or recovery.has_active_claim
self.tabs = QTabWidget()
self.tabs.setObjectName("mainTabs")
self.tabs.setTabsClosable(False)
self.tabs.setMovable(False)
self.execution_page = ExecutionPage(coordinator, record_provider)
self.execution_page.setProperty("pageId", self.EXECUTION_PAGE_ID)
self.settings_page = SettingsPage(
store,
settings=profile_settings,
has_stored_device_token=has_stored_device_token,
identity_frozen=frozen,
)
self.settings_page.setProperty("pageId", self.SETTINGS_PAGE_ID)
self.tabs.addTab(self.execution_page, "采购执行")
self.tabs.addTab(self.settings_page, "配置")
self.tabs.setCurrentWidget(self.execution_page)
self.setCentralWidget(self.tabs)
self.settings_page.settings_saved.connect(
lambda settings, has_token: self._coordinator.update_profile_settings(settings)
)
coordinator.recovery_status_changed.connect(self._on_recovery_status)
coordinator.configuration_freeze_changed.connect(self.settings_page.set_identity_frozen)
coordinator.settled.connect(self._finish_pending_close)
@Slot(object)
def _on_recovery_status(self, raw: object) -> None:
if not isinstance(raw, RecoveryStatus):
self.settings_page.set_identity_frozen(True)
return
self.settings_page.set_identity_frozen(raw.has_pending_claim or raw.has_active_claim)
def closeEvent(self, event: QCloseEvent) -> None:
running = self._coordinator.state in (
PollingState.STARTING,
PollingState.RECOVERING,
PollingState.WAITING,
PollingState.CLAIMING,
PollingState.ACTIVE,
) or self._coordinator.operation_in_flight
if running:
# 不 terminate 飞行中的 QThread。先提升 stop latch,等待 HTTP 自身
# 超时和 DurableClientGateway 落库,再由 settled 重试关闭。
self._close_pending = True
self._coordinator.stop()
event.ignore()
return
event.accept()
@Slot()
def _finish_pending_close(self) -> None:
if not self._close_pending:
return
self._close_pending = False
QTimer.singleShot(0, self.close)
+95
View File
@@ -0,0 +1,95 @@
"""采购记录的只读 Qt Model/View 数据源。"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from cmbuyer_client.logging_policy import redact_text
from cmbuyer_client.core.errors import ValidationError
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
@dataclass(frozen=True)
class PurchaseRecord:
record_id: str
title: str
status: str
created_at: str
original_text: str = ""
result_text: str = ""
image_description: str = ""
created_at_nanoseconds: int = field(init=False, repr=False)
def __post_init__(self) -> None:
# 记录 provider 只能注入可显示摘要;最终 UI 边界仍统一脱敏,避免
# consumer bug 把 Bearer/裸 token 放入 model、详情或可见日志。
for field in ("title", "status", "original_text", "result_text", "image_description"):
object.__setattr__(self, field, redact_text(getattr(self, field)))
if not isinstance(self.created_at, str):
raise ValueError("noncanonical_record_timestamp")
fraction = self.created_at[20:-1] if len(self.created_at) > 20 and self.created_at.endswith("Z") else ""
if fraction and fraction.endswith("0"):
raise ValueError("noncanonical_record_timestamp")
try:
timestamp = rfc3339_z_nanoseconds(self.created_at)
except ValidationError:
raise ValueError("noncanonical_record_timestamp") from None
object.__setattr__(self, "created_at_nanoseconds", timestamp)
class PurchaseRecordProvider(Protocol):
"""只返回已准备好的无秘密 View DTO;不得在 GUI 线程查询 SQLite/HTTP。"""
def snapshot(self) -> list[PurchaseRecord]: ...
class PurchaseRecordModel(QAbstractTableModel):
RECORD_ID_ROLE = int(Qt.ItemDataRole.UserRole) + 1
def __init__(self, records: list[PurchaseRecord] | None = None, parent=None) -> None:
super().__init__(parent)
self._records: list[PurchaseRecord] = []
self.set_records(records or [])
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0 if parent.isValid() else len(self._records)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0 if parent.isValid() else 2
def data(self, index: QModelIndex, role: int = int(Qt.ItemDataRole.DisplayRole)):
if not index.isValid() or not 0 <= index.row() < len(self._records):
return None
record = self._records[index.row()]
if role == int(Qt.ItemDataRole.DisplayRole):
return record.title if index.column() == 0 else record.status
if role == self.RECORD_ID_ROLE:
return record.record_id
if role == int(Qt.ItemDataRole.ToolTipRole):
return f"{record.title}\n{record.created_at}"
if role == int(Qt.ItemDataRole.TextAlignmentRole) and index.column() == 1:
return int(Qt.AlignmentFlag.AlignCenter)
return None
def headerData(self, section: int, orientation: Qt.Orientation, role: int = int(Qt.ItemDataRole.DisplayRole)):
if role != int(Qt.ItemDataRole.DisplayRole) or orientation != Qt.Orientation.Horizontal:
return None
return ("标题", "状态")[section] if 0 <= section < 2 else None
def set_records(self, records: list[PurchaseRecord]) -> None:
self.beginResetModel()
self._records = sorted(
records,
key=lambda item: (item.created_at_nanoseconds, item.record_id),
reverse=True,
)
self.endResetModel()
def record_at(self, row: int) -> PurchaseRecord | None:
return self._records[row] if 0 <= row < len(self._records) else None
def row_for_id(self, record_id: str) -> int:
return next((row for row, item in enumerate(self._records) if item.record_id == record_id), -1)
+249
View File
@@ -0,0 +1,249 @@
"""只做本地校验和显式保存的配置页。"""
from __future__ import annotations
from pathlib import Path
from typing import Protocol
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QComboBox,
QFormLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSpinBox,
QVBoxLayout,
QWidget,
)
from cmbuyer_client.core.models import SecretToken
from cmbuyer_client.core.errors import ValidationError
from cmbuyer_client.core.validation import require_uuid4
from cmbuyer_client.localstate.models import LOOPBACK_SERVICE_URL, ProfileSettings
class ProfileStore(Protocol):
def save_profile(self, settings: ProfileSettings, token: SecretToken | None) -> None: ...
class SettingsPage(QScrollArea):
settings_saved = Signal(object, bool)
VALIDATION_HINT = "服务身份将在首次真实领取时验证;设备与 App 状态由后续已取证执行能力验证。"
def __init__(
self,
store: ProfileStore,
*,
profile_id: str = "default",
settings: ProfileSettings | None = None,
has_stored_device_token: bool = False,
identity_frozen: bool = False,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("settingsPage")
self.setWidgetResizable(True)
self._store = store
self._profile_id = profile_id
self._has_stored_device_token = has_stored_device_token
self._loaded_settings = settings
self._identity_frozen = identity_frozen
content = QWidget()
outer = QVBoxLayout(content)
title = QLabel("配置")
title.setObjectName("settingsTitle")
title.setStyleSheet("font-size: 20px; font-weight: 600;")
outer.addWidget(title)
form = QFormLayout()
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
outer.addLayout(form)
self.service_url = QLineEdit(LOOPBACK_SERVICE_URL)
self.service_url.setReadOnly(True)
self.service_url.setObjectName("serviceUrl")
form.addRow("采购服务 URL", self.service_url)
self.device_id = QLineEdit()
self.device_id.setObjectName("deviceId")
self.device_id.setPlaceholderText("小写 UUIDv4")
form.addRow("设备 UUID", self.device_id)
self.device_token = QLineEdit()
self.device_token.setObjectName("deviceToken")
self.device_token.setEchoMode(QLineEdit.EchoMode.Password)
# 控件只负责给输入设置合理上限;长度与字符集必须在 save() 中显式
# 验证。若这里限制为 64,粘贴 65 位 token 会被 Qt 静默截成合法
# 64 位并覆盖原凭据。
self.device_token.setMaxLength(256)
self.device_token.setPlaceholderText("首次必填;已有凭据时留空表示保留")
form.addRow("设备 token", self.device_token)
self.token_status = QLabel()
self.token_status.setObjectName("tokenStatus")
form.addRow("凭据状态", self.token_status)
self.adb_path = QLineEdit()
self.adb_path.setObjectName("adbPath")
form.addRow("ADB 路径", self.adb_path)
self.adb_serial = QLineEdit()
self.adb_serial.setObjectName("adbSerial")
form.addRow("设备 serial", self.adb_serial)
self.transport = QComboBox()
self.transport.setObjectName("transport")
self.transport.addItem("USB", "usb")
self.transport.addItem("WiFi", "wifi")
form.addRow("连接方式", self.transport)
self.poll_interval = self._spin(5, 300, 15, "pollInterval", " 秒")
form.addRow("轮询间隔", self.poll_interval)
self.failure_threshold = self._spin(1, 10, 3, "failureThreshold", " 次")
form.addRow("连续失败停止阈值", self.failure_threshold)
self.http_timeout = self._spin(1, 120, 10, "httpTimeout", " 秒")
form.addRow("HTTP 超时", self.http_timeout)
self.step_timeout = self._spin(5, 300, 45, "stepTimeout", " 秒")
form.addRow("真机步骤超时", self.step_timeout)
self.validation_hint = QLabel(self.VALIDATION_HINT)
self.validation_hint.setObjectName("deferredValidationHint")
self.validation_hint.setWordWrap(True)
outer.addWidget(self.validation_hint)
self.feedback = QLabel()
self.feedback.setObjectName("settingsFeedback")
self.feedback.setWordWrap(True)
self.feedback.setAccessibleName("配置保存状态")
outer.addWidget(self.feedback)
self.save_action = QAction("保存配置", self)
self.save_action.setShortcut(QKeySequence.StandardKey.Save)
self.save_action.triggered.connect(self.save)
self.addAction(self.save_action)
self.save_button = QPushButton("保存配置")
self.save_button.setObjectName("saveSettings")
self.save_button.clicked.connect(self.save_action.trigger)
outer.addWidget(self.save_button, 0, Qt.AlignmentFlag.AlignRight)
outer.addStretch(1)
self.setWidget(content)
if settings is not None:
self._load(settings)
self._update_token_status()
self.set_identity_frozen(identity_frozen)
@staticmethod
def _spin(minimum: int, maximum: int, value: int, name: str, suffix: str) -> QSpinBox:
field = QSpinBox()
field.setObjectName(name)
field.setRange(minimum, maximum)
field.setValue(value)
field.setSuffix(suffix)
return field
def _load(self, settings: ProfileSettings) -> None:
self.device_id.setText(settings.device_id)
self.adb_path.setText(settings.adb_path)
self.adb_serial.setText(settings.adb_serial)
self.transport.setCurrentIndex(max(0, self.transport.findData(settings.transport)))
self.poll_interval.setValue(settings.poll_interval_seconds)
self.failure_threshold.setValue(settings.failure_threshold)
self.http_timeout.setValue(settings.http_timeout_seconds)
self.step_timeout.setValue(settings.step_timeout_seconds)
def set_identity_frozen(self, frozen: bool) -> None:
self._identity_frozen = frozen
for field in (
self.device_id,
self.adb_path,
self.adb_serial,
self.transport,
self.poll_interval,
self.failure_threshold,
self.http_timeout,
self.step_timeout,
):
field.setEnabled(not frozen)
if frozen:
self.feedback.setText("存在待恢复或执行中的领取;服务与设备身份参数已冻结。")
elif self.feedback.text().startswith("存在待恢复或执行中的领取"):
self.feedback.clear()
@Slot()
def save(self) -> None:
self.feedback.clear()
token_text = self.device_token.text().strip()
device_id = self.device_id.text().strip()
try:
require_uuid4(device_id, "invalid_device_id")
except (TypeError, ValueError, ValidationError):
self._validation_error(self.device_id, "设备 UUID 必须是小写 UUIDv4。")
return
if not self._has_stored_device_token and not token_text:
self._validation_error(self.device_token, "首次保存必须填写设备 token。")
return
if token_text and (len(token_text) != 64 or any(character not in "0123456789abcdef" for character in token_text)):
self._validation_error(self.device_token, "设备 token 必须为 64 位小写十六进制。")
return
try:
if self._identity_frozen:
if self._loaded_settings is None:
self._validation_error(self.device_id, "冻结配置缺少原始设置,不能保存。")
return
settings = self._loaded_settings
else:
adb_text = self.adb_path.text().strip()
if not adb_text:
self._validation_error(self.adb_path, "请填写 ADB 路径。")
return
adb_file = Path(adb_text).expanduser()
if not adb_file.is_file():
self._validation_error(self.adb_path, "ADB 路径必须指向本机已存在的文件。")
return
adb_file = adb_file.resolve(strict=True)
serial = self.adb_serial.text().strip()
if not serial:
self._validation_error(self.adb_serial, "请填写设备 serial。")
return
settings = ProfileSettings(
profile_id=self._profile_id,
service_url=LOOPBACK_SERVICE_URL,
device_id=device_id,
adb_path=str(adb_file),
adb_serial=serial,
transport=str(self.transport.currentData()),
poll_interval_seconds=self.poll_interval.value(),
failure_threshold=self.failure_threshold.value(),
http_timeout_seconds=self.http_timeout.value(),
step_timeout_seconds=self.step_timeout.value(),
)
token = SecretToken(token_text) if token_text else None
except (TypeError, ValueError):
self._validation_error(self.device_id, "配置格式不正确,请检查设备 UUID 与各项参数。")
return
try:
# None 明确表示保留 T-303 中已有的 DPAPI 密文,绝不是清除凭据。
self._store.save_profile(settings, token)
except Exception:
self.feedback.setText("配置保存失败,本地安全存储未更新。")
(self.device_token if token_text else self.device_id).setFocus()
return
self._has_stored_device_token = True
self._loaded_settings = settings
self.device_token.clear()
self._update_token_status()
self.feedback.setText("配置已保存。本地校验不代表服务、设备或 App 已就绪。")
self.settings_saved.emit(settings, True)
def _validation_error(self, field: QWidget, message: str) -> None:
self.feedback.setText(message)
field.setFocus()
def _update_token_status(self) -> None:
self.token_status.setText("已保存" if self._has_stored_device_token else "未保存")
+1
View File
@@ -0,0 +1 @@
"""core tests。"""
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
import json
import unittest
from cmbuyer_client.core.errors import ValidationError
from cmbuyer_client.core.models import ClaimedTask, SecretToken
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds, strict_json_loads
TASK_ID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
AUTH_ID = "73c9f507-7473-4fa6-8d71-8786c34c6301"
ATTEMPT_ID = "53c9f507-7473-4fa6-8d71-8786c34c6301"
TOKEN = "0123456789abcdef" * 4
def claim_wire() -> dict[str, object]:
return {
"task": {
"id": TASK_ID,
"version": 3,
"title": "纯棉短袖",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
"goods_id": "937122477375",
"sku_color": "黑色CHA(纯棉)",
"sku_size": "M(建议100-115)",
"quantity": 2,
"max_total_price": "30.00",
},
"authorization": {"id": AUTH_ID, "task_version": 2, "expires_at": "2026-08-04T10:00:00Z"},
"attempt": {
"id": ATTEMPT_ID,
"claim_token": TOKEN,
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:05:00Z",
},
}
class CoreModelsTests(unittest.TestCase):
def test_claim_wire_round_trip_and_secret_repr(self) -> None:
claimed = ClaimedTask.from_wire(claim_wire())
self.assertEqual(claimed.task.quantity, 2)
self.assertNotIn(TOKEN, repr(claimed))
self.assertNotIn(TOKEN, repr(SecretToken(TOKEN)))
def test_rejects_bool_float_wrong_url_and_version_drift(self) -> None:
mutations = []
for mutate in (
lambda value: value["task"].__setitem__("quantity", True),
lambda value: value["task"].__setitem__("max_total_price", "30.0"),
lambda value: value["task"].__setitem__("max_total_price", "0.00"),
lambda value: value["task"].__setitem__("product_url", "https://example.invalid/"),
lambda value: value["task"].__setitem__("version", 2),
):
value = claim_wire()
mutate(value)
mutations.append(value)
for value in mutations:
with self.subTest(value=value), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value)
def test_strict_json_rejects_nested_duplicates_float_nan_bom_and_utf8(self) -> None:
bad_values = (
b'{"task":{"id":1,"id":2}}',
b'{"value":1.0}',
b'{"value":NaN}',
b'\xef\xbb\xbf{}',
b'\xff',
('{"value":' + "9" * 5000 + '}').encode(),
)
for raw in bad_values:
with self.subTest(raw=raw), self.assertRaises(ValidationError):
strict_json_loads(raw, maximum=1024)
self.assertEqual(strict_json_loads(json.dumps({"value": 1}).encode(), maximum=1024), {"value": 1})
def test_rfc3339_nano_comparison_preserves_all_fraction_digits(self) -> None:
equal = (
"2026-08-04T09:01:00.1Z",
"2026-08-04T09:01:00.100000Z",
"2026-08-04T09:01:00.100000000Z",
)
self.assertEqual(len({rfc3339_z_nanoseconds(value) for value in equal}), 1)
ordered = (
"2026-08-04T09:01:00Z",
"2026-08-04T09:01:00.000001Z",
"2026-08-04T09:01:00.0000011Z",
"2026-08-04T09:01:00.000001101Z",
"2026-08-04T09:01:01Z",
)
self.assertEqual([rfc3339_z_nanoseconds(value) for value in ordered], sorted(rfc3339_z_nanoseconds(value) for value in ordered))
def test_money_accepts_positive_subunit_but_rejects_zero_and_noncanonical_forms(self) -> None:
value = claim_wire()
value["task"]["max_total_price"] = "0.01"
self.assertEqual(ClaimedTask.from_wire(value).task.max_total_price, "0.01")
for invalid in ("0.00", "00.01", "1.0", "1.000", "1", 1.0, "1.12", "1.٠٠", "12.00"):
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
changed = claim_wire()
changed["task"]["max_total_price"] = invalid
ClaimedTask.from_wire(changed)
wide_quantity = claim_wire()
wide_quantity["task"]["quantity"] = 2_147_483_648
self.assertEqual(ClaimedTask.from_wire(wide_quantity).task.quantity, 2_147_483_648)
for invalid_goods in ("123", "1٢3"):
changed = claim_wire()
changed["task"]["goods_id"] = invalid_goods
changed["task"]["product_url"] = "https://mobile.yangkeduo.com/goods.html?goods_id=" + invalid_goods
with self.subTest(invalid_goods=invalid_goods), self.assertRaises(ValidationError):
ClaimedTask.from_wire(changed)
too_large = claim_wire()
too_large["task"]["quantity"] = 9_223_372_036_854_775_808
with self.assertRaises(ValidationError):
ClaimedTask.from_wire(too_large)
def test_claim_fields_share_explicit_server_bounds(self) -> None:
legal = claim_wire()
legal_goods = "1" * 32
legal["task"].update(
version=9_223_372_036_854_775_807,
title="😀" * 120,
goods_id=legal_goods,
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + legal_goods,
sku_color="色" * 80,
sku_size="码" * 80,
max_total_price="1" * 29 + ".00",
)
legal["authorization"]["task_version"] = 9_223_372_036_854_775_806
claimed = ClaimedTask.from_wire(legal)
self.assertEqual(len(claimed.task.title), 120)
# Python's default ensure_ascii=True expands astral characters to surrogate
# escape pairs, so this is a conservative parser-budget proof as well.
self.assertLess(len(json.dumps(legal, separators=(",", ":")).encode()), 32 * 1024)
mutations = (
("title", "😀" * 121),
("title", " title"),
("sku_color", "色" * 81),
("sku_color", "black "),
("sku_size", "码" * 81),
("sku_size", " M"),
("max_total_price", "1" * 30 + ".00"),
)
for field, invalid in mutations:
changed = claim_wire()
changed["task"][field] = invalid
with self.subTest(field=field, length=len(invalid)), self.assertRaises(ValidationError):
ClaimedTask.from_wire(changed)
overlong_goods = "1" * 33
changed = claim_wire()
changed["task"].update(
goods_id=overlong_goods,
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + overlong_goods,
)
with self.assertRaises(ValidationError):
ClaimedTask.from_wire(changed)
def test_wire_strings_reject_lone_surrogates_but_accept_valid_pair(self) -> None:
for escaped in (r'"\ud800"', r'"\udc00"'):
value = claim_wire()
value["task"]["title"] = json.loads(escaped)
with self.subTest(escaped=escaped), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value)
value = claim_wire()
value["task"]["title"] = json.loads(r'"\ud83d\ude00"')
self.assertEqual(ClaimedTask.from_wire(value).task.title, "😀")
def test_title_rejects_ascii_and_unicode_whitespace_only(self) -> None:
for title in ("", " \t\r\n", "\u3000", " \u3000\t", "\u00a0title", "title\u00a0"):
value = claim_wire()
value["task"]["title"] = title
with self.subTest(title=repr(title)), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value)
def test_persisted_text_has_runtime_independent_c0_and_nbsp_domain(self) -> None:
for field in ("title", "sku_color", "sku_size"):
for invalid in ("\u001cvalue", "value\u001f", "value\u001dinside", "\u00a0value", "value\u00a0"):
value = claim_wire()
value["task"][field] = invalid
with self.subTest(field=field, invalid=repr(invalid)), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value)
value = claim_wire()
value["task"][field] = "left\u00a0right"
self.assertEqual(getattr(ClaimedTask.from_wire(value).task, field), "left\u00a0right")
+1
View File
@@ -0,0 +1 @@
"""localstate tests。"""
@@ -0,0 +1,36 @@
from __future__ import annotations
import ast
from pathlib import Path
import unittest
SRC = Path(__file__).resolve().parents[2] / "src" / "cmbuyer_client"
SCOPED = tuple((SRC / name) for name in ("core", "remote", "localstate"))
class StaticBoundaryTests(unittest.TestCase):
def test_scoped_modules_do_not_import_device_pdd_or_unapproved_capabilities(self) -> None:
forbidden_modules = ("cmbuyer_client.device", "cmbuyer_client.pdd")
forbidden_text = (
"ResultSink",
"/events",
"/fail",
"/submission-fence",
"/result",
"click_permitted",
)
for directory in SCOPED:
for path in directory.glob("*.py"):
text = path.read_text(encoding="utf-8")
tree = ast.parse(text)
imports = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imports.append(node.module)
for module in forbidden_modules:
self.assertFalse(any(name.startswith(module) for name in imports), (path, module))
for value in forbidden_text:
self.assertNotIn(value, text, (path, value))
+321
View File
@@ -0,0 +1,321 @@
from __future__ import annotations
import base64
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest import mock
from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, StateError
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken
from cmbuyer_client.localstate.facade import DurableClientGateway
from cmbuyer_client.localstate.models import ProfileSettings
from cmbuyer_client.localstate.store import LocalStateStore
from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink
from cmbuyer_client.remote.http_transport import HttpResponse
from cmbuyer_client.remote.task_source import HttpTaskSource
from tests.core.test_models import ATTEMPT_ID, TASK_ID, claim_wire
from tests.localstate.test_store import DEVICE_TOKEN, FakeProtector, PROFILE
from tests.remote.test_task_source import DEVICE_ID, FakeTransport, response
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
class InspectingSource:
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
self.store = store
self.profile_id = profile_id
self.calls = 0
self.mode = "success"
self.request_ids: list[str] = []
def claim_next(self, credentials, request):
self.calls += 1
self.request_ids.append(request.claim_request_id)
# HTTP 适配器被调用时,幂等请求必须已经 durable。
self.assert_pending(request.claim_request_id)
if self.mode == "ambiguous":
raise AmbiguousRemoteError("http_result_unknown")
if self.mode == "manual":
raise ManualRemoteError("claim_requires_manual")
return ClaimedTask.from_wire(claim_wire())
def renew(self, credentials, request):
raise AssertionError("not used")
def assert_pending(self, request_id: str) -> None:
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
if snapshot.pending_claim is None or snapshot.pending_claim.claim_request_id != request_id:
raise AssertionError("HTTP happened before durable prepare")
class InspectingSink:
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
self.store = store
self.profile_id = profile_id
self.calls = 0
def upload(self, credentials, upload):
self.calls += 1
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
if not snapshot.pending_evidence or snapshot.pending_evidence[0].upload_key != upload.upload_key:
raise AssertionError("HTTP happened before durable evidence slot")
return AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301",
upload.task_id,
upload.attempt_id,
upload.kind,
upload.privacy_tier,
upload.sha256,
len(upload.content),
"image/png",
1,
1,
upload.captured_at,
)
class InspectingRenewSource:
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
self.store = store
self.profile_id = profile_id
self.calls = 0
def claim_next(self, credentials, request):
raise AssertionError("not used")
def renew(self, credentials, request):
self.calls += 1
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
if snapshot.pending_renew is None or snapshot.pending_renew.renew_request_id != request.renew_request_id:
raise AssertionError("HTTP happened before durable renew")
return RenewResult(request.task_id, request.attempt_id, request.claim_generation, request.expected_lease_expires_at)
class DurableClientGatewayTests(unittest.TestCase):
def setUp(self) -> None:
self.directory = tempfile.TemporaryDirectory()
self.database = Path(self.directory.name) / "client-state.sqlite3"
self.store = LocalStateStore(
self.database,
FakeProtector(),
now=lambda: datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc),
)
profile = ProfileSettings(
PROFILE,
"http://127.0.0.1:8080",
DEVICE_ID,
"D:/Portable/adb/adb.exe",
"192.168.0.173:5555",
"wifi",
)
self.store.save_profile(profile, SecretToken(DEVICE_TOKEN))
self.store.start_or_resume_polling(PROFILE)
self.source = InspectingSource(self.store, PROFILE)
self.sink = InspectingSink(self.store, PROFILE)
self.gateway = DurableClientGateway(self.store, self.source, self.sink)
def tearDown(self) -> None:
self.directory.cleanup()
def test_claim_unknown_replays_same_durable_key_then_commits(self) -> None:
self.source.mode = "ambiguous"
with self.assertRaises(AmbiguousRemoteError):
self.gateway.claim_next(PROFILE)
self.source.mode = "success"
claimed = self.gateway.claim_next(PROFILE)
self.assertEqual(claimed.task.id, TASK_ID)
self.assertEqual(self.source.request_ids[0], self.source.request_ids[1])
self.assertIsNotNone(self.store.active_claim(PROFILE))
def test_unknown_claim_2xx_keeps_pending_key_for_real_adapter_replay(self) -> None:
transport = FakeTransport(response(201, claim_wire()))
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
with self.assertRaises(AmbiguousRemoteError):
gateway.claim_next(PROFILE)
pending = self.store.recovery_snapshot(PROFILE).pending_claim
self.assertIsNotNone(pending)
transport.response = response(200, claim_wire())
claimed = gateway.claim_next(PROFILE)
self.assertEqual(claimed.task.id, TASK_ID)
sent = [call[3] for call in transport.calls]
self.assertEqual(sent[0], sent[1])
def test_claim_401_allows_token_repair_and_same_key_replay(self) -> None:
transport = FakeTransport(HttpResponse(401, (), b""))
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
with self.assertRaises(CredentialRemoteError):
gateway.claim_next(PROFILE)
request_id = self.store.recovery_snapshot(PROFILE).pending_claim.claim_request_id
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
transport.response = response(200, claim_wire())
gateway.claim_next(PROFILE)
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
self.assertEqual(json.loads(transport.calls[1][3])["claim_request_id"], request_id)
def test_profile_read_sql_failure_after_prepare_is_fixed_error_and_zero_http(self) -> None:
original_connect = self.store._connect
calls = 0
def fail_second_connection():
nonlocal calls
calls += 1
connection = original_connect()
if calls == 2:
connection.set_authorizer(
lambda action, table, *_: sqlite3.SQLITE_DENY
if action == sqlite3.SQLITE_READ and table == "profiles"
else sqlite3.SQLITE_OK
)
return connection
with mock.patch.object(self.store, "_connect", side_effect=fail_second_connection):
with self.assertRaisesRegex(StateError, "localstate_read_failed") as captured:
self.gateway.claim_next(PROFILE)
self.assertEqual(self.source.calls, 0)
self.assertNotIn(str(self.database), repr(captured.exception))
def test_renew_is_durable_before_http(self) -> None:
self.gateway.claim_next(PROFILE)
source = InspectingRenewSource(self.store, PROFILE)
gateway = DurableClientGateway(self.store, source, self.sink)
result = gateway.renew(PROFILE)
self.assertEqual(result.attempt_id, ATTEMPT_ID)
self.assertEqual(source.calls, 1)
def test_unknown_renew_2xx_keeps_pending_payload_for_replay(self) -> None:
self.gateway.claim_next(PROFILE)
payload = {
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:06:00Z",
}
transport = FakeTransport(response(201, payload))
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
with self.assertRaises(AmbiguousRemoteError):
gateway.renew(PROFILE)
pending = self.store.recovery_snapshot(PROFILE).pending_renew
self.assertIsNotNone(pending)
transport.response = response(200, payload)
gateway.renew(PROFILE)
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
def test_renew_401_allows_bearer_repair_without_changing_claim_payload(self) -> None:
self.gateway.claim_next(PROFILE)
payload = {
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:06:00Z",
}
transport = FakeTransport(HttpResponse(401, (), b""))
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
with self.assertRaises(CredentialRemoteError):
gateway.renew(PROFILE)
self.assertIsNotNone(self.store.recovery_snapshot(PROFILE).pending_renew)
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
transport.response = response(200, payload)
gateway.renew(PROFILE)
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
def test_unknown_evidence_2xx_keeps_pending_multipart_for_replay(self) -> None:
self.gateway.claim_next(PROFILE)
path = Path(self.directory.name) / "unknown.png"
path.write_bytes(PNG)
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
transport = FakeTransport(HttpResponse(202, (), b""))
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
with self.assertRaises(AmbiguousRemoteError):
gateway.upload_evidence(PROFILE, asset)
pending = self.store.recovery_snapshot(PROFILE).pending_evidence
self.assertEqual(len(pending), 1)
digest = hashlib.sha256(PNG).hexdigest()
receipt = {
"asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": digest,
"byte_size": len(PNG),
"content_type": "image/png",
"width_px": 1,
"height_px": 1,
"captured_at": "2026-08-04T09:01:00Z",
}
transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode())
gateway.upload_evidence(PROFILE, asset)
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
def test_evidence_401_allows_bearer_repair_with_same_file_and_multipart(self) -> None:
self.gateway.claim_next(PROFILE)
path = Path(self.directory.name) / "credential.png"
path.write_bytes(PNG)
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
transport = FakeTransport(HttpResponse(401, (), b""))
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
with self.assertRaises(CredentialRemoteError):
gateway.upload_evidence(PROFILE, asset)
self.assertEqual(len(self.store.recovery_snapshot(PROFILE).pending_evidence), 1)
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
digest = hashlib.sha256(PNG).hexdigest()
receipt = {
"asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": digest,
"byte_size": len(PNG),
"content_type": "image/png",
"width_px": 1,
"height_px": 1,
"captured_at": "2026-08-04T09:01:00Z",
}
transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode())
gateway.upload_evidence(PROFILE, asset)
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
def test_equivalent_captured_at_replays_exact_original_multipart_bytes(self) -> None:
self.gateway.claim_next(PROFILE)
path = Path(self.directory.name) / "exact-replay.png"
path.write_bytes(PNG)
transport = FakeTransport(HttpResponse(202, (), b""))
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
first = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.1Z")
equivalent = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.100000Z")
with self.assertRaises(AmbiguousRemoteError):
gateway.upload_evidence(PROFILE, first)
with self.assertRaises(AmbiguousRemoteError):
gateway.upload_evidence(PROFILE, equivalent)
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
def test_manual_claim_is_durable_and_never_gets_new_key(self) -> None:
self.source.mode = "manual"
with self.assertRaises(ManualRemoteError):
self.gateway.claim_next(PROFILE)
with self.assertRaises(Exception):
self.gateway.claim_next(PROFILE)
self.assertEqual(self.source.calls, 1)
def test_evidence_slot_exists_before_http_and_success_never_reuploads(self) -> None:
self.gateway.claim_next(PROFILE)
path = Path(self.directory.name) / "one.png"
path.write_bytes(PNG)
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.120000Z")
first = self.gateway.upload_evidence(PROFILE, asset)
path.write_bytes(PNG + b"changed")
second = self.gateway.upload_evidence(PROFILE, asset)
self.assertEqual(first, second)
self.assertEqual(self.sink.calls, 1)
@@ -0,0 +1,24 @@
from __future__ import annotations
import os
import unittest
from cmbuyer_client.core.errors import ProtectionError
from cmbuyer_client.localstate.protection import DpapiProtector
@unittest.skipUnless(os.name == "nt", "DPAPI 仅在 Windows 验证")
class DpapiProtectorTests(unittest.TestCase):
def test_current_user_round_trip_purpose_isolation_and_corruption(self) -> None:
protector = DpapiProtector()
plaintext = b"a" * 64
device_purpose = "device-token:default:33c9f507-7473-4fa6-8d71-8786c34c6301"
claim_purpose = "claim-token:default:53c9f507-7473-4fa6-8d71-8786c34c6301"
ciphertext = protector.protect(plaintext, purpose=device_purpose)
self.assertNotIn(plaintext, ciphertext)
self.assertEqual(protector.unprotect(ciphertext, purpose=device_purpose), plaintext)
with self.assertRaises(ProtectionError):
protector.unprotect(ciphertext, purpose=claim_purpose)
damaged = ciphertext[:-1] + bytes((ciphertext[-1] ^ 1,))
with self.assertRaises(ProtectionError):
protector.unprotect(damaged, purpose=device_purpose)
@@ -0,0 +1,38 @@
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from cmbuyer_client.core.errors import SingleInstanceError
from cmbuyer_client.localstate.single_instance import NamedMutex
@unittest.skipUnless(os.name == "nt", "named mutex 仅在 Windows 验证")
class NamedMutexTests(unittest.TestCase):
def test_second_process_for_same_database_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
database = Path(directory) / "state.sqlite3"
first = NamedMutex(database)
try:
with self.assertRaises(SingleInstanceError):
NamedMutex(database)
code = (
"from pathlib import Path; "
"from cmbuyer_client.localstate.single_instance import NamedMutex; "
"from cmbuyer_client.core.errors import SingleInstanceError; "
f"p=Path({str(database)!r}); "
"\ntry:\n NamedMutex(p)\nexcept SingleInstanceError:\n raise SystemExit(17)\nraise SystemExit(0)"
)
environment = dict(os.environ)
environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[2] / "src")
result = subprocess.run([sys.executable, "-c", code], env=environment, check=False)
self.assertEqual(result.returncode, 17)
finally:
first.close()
with NamedMutex(database):
pass
+895
View File
@@ -0,0 +1,895 @@
from __future__ import annotations
import base64
from dataclasses import replace
from datetime import datetime, timezone
import hashlib
import hmac
import json
import os
from pathlib import Path
import sqlite3
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest import mock
from cmbuyer_client.core.errors import ProtectionError, StateError
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken
from cmbuyer_client.localstate.models import ProfileSettings
from cmbuyer_client.localstate.store import LocalStateStore, _read_stable_png
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN, claim_wire
from tests.remote.test_task_source import DEVICE_ID
DEVICE_TOKEN = "b" * 64
PROFILE = "default"
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
class FakeProtector:
def protect(self, plaintext: bytes, *, purpose: str) -> bytes:
key = hashlib.sha256(("test:" + purpose).encode()).digest()
encrypted = bytes(value ^ key[index % len(key)] for index, value in enumerate(plaintext))
return hmac.digest(key, plaintext, "sha256") + encrypted
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
if len(ciphertext) < 33:
raise ProtectionError("fake_unprotect_failed")
key = hashlib.sha256(("test:" + purpose).encode()).digest()
plaintext = bytes(value ^ key[index % len(key)] for index, value in enumerate(ciphertext[32:]))
if not hmac.compare_digest(ciphertext[:32], hmac.digest(key, plaintext, "sha256")):
raise ProtectionError("fake_unprotect_failed")
return plaintext
class NoUnprotectProtector(FakeProtector):
def __init__(self) -> None:
self.unprotect_calls = 0
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
self.unprotect_calls += 1
raise AssertionError("metadata_read_must_not_unprotect")
def settings(device_id: str = DEVICE_ID) -> ProfileSettings:
return ProfileSettings(
PROFILE,
"http://127.0.0.1:8080",
device_id,
"D:/Portable/adb/adb.exe",
"192.168.0.173:5555",
"wifi",
)
class LocalStateStoreTests(unittest.TestCase):
def setUp(self) -> None:
self.directory = tempfile.TemporaryDirectory()
self.database = Path(self.directory.name) / "state" / "client-state.sqlite3"
self.clock = [datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc)]
self.store = self.new_store()
self.store.save_profile(settings(), SecretToken(DEVICE_TOKEN))
def tearDown(self) -> None:
self.directory.cleanup()
def new_store(self) -> LocalStateStore:
return LocalStateStore(self.database, FakeProtector(), now=lambda: self.clock[0])
def test_claim_unknown_restart_stop_and_atomic_success(self) -> None:
session = self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
self.assertEqual(self.store.prepare_claim(PROFILE), request)
# 模拟 HTTP 已成功但进程在落库前退出;重启只能恢复原 key。
restarted = self.new_store()
self.assertEqual(restarted.recovery_snapshot(PROFILE).pending_claim, request)
self.assertEqual(restarted.prepare_claim(PROFILE), request)
# stop 不能丢弃飞行中的 claim,返回结果仍必须落 active。
restarted.request_stop(PROFILE)
claimed = ClaimedTask.from_wire(claim_wire())
restarted.commit_claim_success(PROFILE, request, claimed)
snapshot = self.new_store().recovery_snapshot(PROFILE)
self.assertFalse(snapshot.session.accept_new)
self.assertIsNone(snapshot.pending_claim)
self.assertEqual(snapshot.active_claim.task.id, TASK_ID)
self.assertEqual(snapshot.active_claim.attempt.claim_token.value, TOKEN)
with self.assertRaises(StateError):
restarted.prepare_claim(PROFILE)
def test_relative_database_path_is_frozen_across_cwd_changes(self) -> None:
original_cwd = Path.cwd()
first = Path(self.directory.name) / "first-cwd"
second = Path(self.directory.name) / "second-cwd"
first.mkdir()
second.mkdir()
try:
os.chdir(first)
relative_store = LocalStateStore(Path("relative/state.sqlite3"), FakeProtector(), now=lambda: self.clock[0])
relative_store.save_profile(settings(), SecretToken(DEVICE_TOKEN))
frozen_path = relative_store.database_path
os.chdir(second)
self.assertEqual(relative_store.load_profile(PROFILE).settings, settings())
self.assertEqual(relative_store.database_path, frozen_path)
self.assertTrue(frozen_path.is_absolute())
self.assertFalse((second / "relative" / "state.sqlite3").exists())
finally:
os.chdir(original_cwd)
def test_profile_summary_reads_settings_and_token_presence_without_unprotect(self) -> None:
protector = NoUnprotectProtector()
summary_store = LocalStateStore(
self.database,
protector,
now=lambda: self.clock[0],
)
summary = summary_store.load_profile_summary(PROFILE)
self.assertEqual(summary.settings, settings())
self.assertTrue(summary.has_stored_device_token)
self.assertNotIn(DEVICE_TOKEN, repr(summary))
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_empty_cipher_fails_closed_without_unprotect(self) -> None:
connection = sqlite3.connect(self.database)
try:
connection.execute(
"UPDATE profiles SET device_token_cipher=? WHERE profile_id=?",
(sqlite3.Binary(b""), PROFILE),
)
connection.commit()
finally:
connection.close()
protector = NoUnprotectProtector()
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
with self.assertRaisesRegex(StateError, "invalid_device_token_cipher"):
summary_store.load_profile_summary(PROFILE)
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_wrong_cipher_storage_class_fails_closed_without_unprotect(self) -> None:
current = settings()
row = (
current.service_url,
current.device_id,
current.adb_path,
current.adb_serial,
current.transport,
current.poll_interval_seconds,
current.failure_threshold,
current.http_timeout_seconds,
current.step_timeout_seconds,
"text",
64,
)
protector = NoUnprotectProtector()
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
fake_connection = mock.Mock()
fake_connection.execute.return_value.fetchone.return_value = row
with mock.patch.object(summary_store, "_read_transaction") as read_transaction:
read_transaction.return_value.__enter__.return_value = fake_connection
with self.assertRaisesRegex(StateError, "invalid_device_token_cipher"):
summary_store.load_profile_summary(PROFILE)
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_invalid_stored_settings_are_normalized_without_unprotect(self) -> None:
original = settings()
protector = NoUnprotectProtector()
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
for column, invalid, valid in (
("service_url", "http://127.0.0.1:9999", original.service_url),
("transport", "bluetooth", original.transport),
("poll_interval_seconds", 4, original.poll_interval_seconds),
):
with self.subTest(column=column):
connection = sqlite3.connect(self.database)
try:
connection.execute("PRAGMA ignore_check_constraints=ON")
connection.execute(
f"UPDATE profiles SET {column}=? WHERE profile_id=?",
(invalid, PROFILE),
)
connection.commit()
finally:
connection.close()
try:
with self.assertRaisesRegex(StateError, "stored_profile_invalid") as captured:
summary_store.load_profile_summary(PROFILE)
self.assertNotIn(DEVICE_TOKEN, str(captured.exception))
finally:
connection = sqlite3.connect(self.database)
try:
connection.execute("PRAGMA ignore_check_constraints=ON")
connection.execute(
f"UPDATE profiles SET {column}=? WHERE profile_id=?",
(valid, PROFILE),
)
connection.commit()
finally:
connection.close()
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_missing_profile_fails_without_creating_defaults(self) -> None:
with self.assertRaisesRegex(StateError, "profile_not_found"):
self.store.load_profile_summary("missing")
connection = sqlite3.connect(self.database)
try:
self.assertEqual(connection.execute("SELECT count(*) FROM profiles").fetchone()[0], 1)
finally:
connection.close()
def test_empty_allows_new_key_but_terminal_does_not(self) -> None:
self.store.start_or_resume_polling(PROFILE)
first = self.store.prepare_claim(PROFILE)
self.store.commit_claim_empty(PROFILE, first)
second = self.store.prepare_claim(PROFILE)
self.assertNotEqual(first.claim_request_id, second.claim_request_id)
self.store.mark_claim_terminal(PROFILE, second, "MANUAL")
with self.assertRaises(StateError):
self.store.prepare_claim(PROFILE)
def test_idle_profile_identity_change_creates_new_session_and_key(self) -> None:
first_session = self.store.start_or_resume_polling(PROFILE)
first = self.store.prepare_claim(PROFILE)
self.store.commit_claim_empty(PROFILE, first)
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
self.store.save_profile(settings(other_id), SecretToken("c" * 64))
self.assertIsNone(self.store.recovery_snapshot(PROFILE).session)
second_session = self.store.start_or_resume_polling(PROFILE)
second = self.store.prepare_claim(PROFILE)
self.assertNotEqual(first_session.session_id, second_session.session_id)
self.assertNotEqual(first.claim_request_id, second.claim_request_id)
self.assertEqual(second.session_id, second_session.session_id)
def test_device_change_requires_new_token_because_cipher_is_identity_bound(self) -> None:
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
with self.assertRaisesRegex(StateError, "device_token_required_for_device_change"):
self.store.save_profile(settings(other_id), None)
self.store.save_profile(settings(other_id), SecretToken("c" * 64))
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "c" * 64)
def test_tampered_profile_session_mismatch_is_not_auto_repaired(self) -> None:
self.store.start_or_resume_polling(PROFILE)
connection = sqlite3.connect(self.database)
try:
connection.execute(
"UPDATE profiles SET device_id=? WHERE profile_id=?",
("f3c9f507-7473-4fa6-8d71-8786c34c6301", PROFILE),
)
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "polling_identity_mismatch"):
self.store.start_or_resume_polling(PROFILE)
def test_pending_and_active_freeze_service_device_and_token_identity(self) -> None:
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
self.store.save_profile(settings(), SecretToken("c" * 64))
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "c" * 64)
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
with self.assertRaises(StateError):
self.store.save_profile(settings(other_id), None)
base = settings()
changed_profiles = (
replace(base, adb_path="D:/other/adb.exe"),
replace(base, adb_serial="usb-other"),
replace(base, transport="usb"),
replace(base, poll_interval_seconds=16),
replace(base, failure_threshold=4),
replace(base, http_timeout_seconds=11),
replace(base, step_timeout_seconds=46),
)
for changed in changed_profiles:
with self.subTest(changed=changed), self.assertRaises(StateError):
self.store.save_profile(changed, None)
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
self.store.save_profile(settings(), SecretToken("d" * 64))
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "d" * 64)
def test_renew_reuses_exact_payload_across_restart_and_cas_updates_only_lease(self) -> None:
self._claim_active()
request = self.store.prepare_renew(PROFILE)
self.clock[0] = datetime(2026, 8, 4, 9, 7, tzinfo=timezone.utc)
recovered = self.new_store().prepare_renew(PROFILE)
self.assertEqual(recovered.renew_request_id, request.renew_request_id)
self.assertEqual(recovered.to_wire(), request.to_wire())
result = RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z")
self.store.commit_renew_success(PROFILE, request, result)
active = self.new_store().active_claim(PROFILE)
self.assertEqual(active.attempt.claim_generation, 1)
self.assertEqual(active.attempt.claim_token.value, TOKEN)
self.assertEqual(active.attempt.lease_expires_at, "2026-08-04T09:06:00Z")
with self.assertRaises(StateError):
self.store.prepare_renew(PROFILE)
def test_evidence_slot_is_persisted_before_send_and_rejects_file_change(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "explicit.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
recovered = self.new_store().prepare_or_resume_evidence(PROFILE, asset)
self.assertEqual(recovered.upload_key, upload.upload_key)
self.assertEqual(recovered.content, upload.content)
image.write_bytes(PNG + b"changed")
with self.assertRaises(StateError):
self.store.prepare_or_resume_evidence(PROFILE, asset)
image.write_bytes(PNG)
receipt = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301",
TASK_ID,
ATTEMPT_ID,
"SKU_PANEL_GATE_1",
"INTERNAL_RAW",
upload.sha256,
len(PNG),
"image/png",
1,
1,
"2026-08-04T09:01:00Z",
)
self.store.commit_evidence_success(PROFILE, upload, receipt)
image.write_bytes(PNG + b"different-after-success")
self.assertEqual(self.new_store().prepare_or_resume_evidence(PROFILE, asset), receipt)
image.unlink()
self.assertEqual(self.new_store().prepare_or_resume_evidence(PROFILE, asset), receipt)
def test_evidence_terminal_outcome_retains_slot_and_blocks_resend(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "manual.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
self.store.mark_evidence_terminal(PROFILE, upload, "MANUAL")
snapshot = self.store.recovery_snapshot(PROFILE)
self.assertEqual(snapshot.pending_evidence[0].status, "MANUAL")
with self.assertRaises(StateError):
self.store.prepare_or_resume_evidence(PROFILE, asset)
def test_receipt_dimensions_must_match_local_png_and_pending_slot_survives(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "dimension.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
wrong = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 2, 2, upload.captured_at,
)
with self.assertRaisesRegex(StateError, "evidence_response_mismatch"):
self.store.commit_evidence_success(PROFILE, upload, wrong)
pending = self.new_store().recovery_snapshot(PROFILE).pending_evidence
self.assertEqual(len(pending), 1)
self.assertEqual(pending[0].upload_key, upload.upload_key)
def test_evidence_slot_is_profile_owned_and_missing_half_fails_closed(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "owned.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
self.store.prepare_or_resume_evidence(PROFILE, asset)
other = replace(settings(), profile_id="other")
self.store.save_profile(other, SecretToken("c" * 64))
with self.assertRaisesRegex(StateError, "evidence_slot_not_owned"):
self.store.prepare_or_resume_evidence("other", asset)
connection = sqlite3.connect(self.database)
connection.execute("DROP TRIGGER evidence_slots_no_delete")
connection.execute("DELETE FROM evidence_slots WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
with self.assertRaisesRegex(StateError, "evidence_marker_mismatch"):
self.store.prepare_or_resume_evidence(PROFILE, asset)
def test_evidence_reader_rejects_reparse_and_path_identity_change(self) -> None:
image = Path(self.directory.name) / "stable.png"
image.write_bytes(PNG)
real = os.lstat(image)
reparse = SimpleNamespace(
st_mode=real.st_mode,
st_file_attributes=0x400,
st_dev=real.st_dev,
st_ino=real.st_ino,
st_size=real.st_size,
st_mtime_ns=real.st_mtime_ns,
)
with mock.patch("cmbuyer_client.localstate.store.os.lstat", return_value=reparse):
with self.assertRaisesRegex(StateError, "evidence_file_not_regular"):
_read_stable_png(image)
replaced = SimpleNamespace(
st_mode=real.st_mode,
st_file_attributes=0,
st_dev=real.st_dev,
st_ino=real.st_ino + 1,
st_size=real.st_size,
st_mtime_ns=real.st_mtime_ns,
)
with mock.patch("cmbuyer_client.localstate.store.os.lstat", side_effect=(real, replaced)):
with self.assertRaisesRegex(StateError, "evidence_changed_during_read"):
_read_stable_png(image)
def test_corrupt_database_and_ciphertext_fail_closed(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER active_claims_identity_immutable")
connection.execute("UPDATE active_claims SET claim_token_cipher=?", (sqlite3.Binary(b"corrupt"),))
connection.commit()
finally:
connection.close()
with self.assertRaises(ProtectionError):
self.new_store().active_claim(PROFILE)
corrupt = Path(self.directory.name) / "corrupt.sqlite3"
corrupt.write_bytes(b"not-a-sqlite-database")
with self.assertRaises(StateError):
LocalStateStore(corrupt, FakeProtector())
def test_claim_token_cipher_cannot_be_swapped_between_attempt_histories(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
request = self.store.prepare_claim(PROFILE)
wire = claim_wire()
wire["task"]["id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
wire["authorization"]["id"] = "93c9f507-7473-4fa6-8d71-8786c34c6301"
wire["attempt"]["id"] = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
wire["attempt"]["claim_token"] = "f" * 64
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(wire))
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER active_claims_identity_immutable")
rows = connection.execute("SELECT attempt_id,claim_token_cipher FROM active_claims ORDER BY created_at,attempt_id").fetchall()
connection.execute("UPDATE active_claims SET claim_token_cipher=? WHERE attempt_id=?", (rows[1][1], rows[0][0]))
connection.execute("UPDATE active_claims SET claim_token_cipher=? WHERE attempt_id=?", (rows[0][1], rows[1][0]))
connection.commit()
finally:
connection.close()
with self.assertRaises(ProtectionError):
self.new_store().active_claim(PROFILE)
def test_device_token_cipher_cannot_be_swapped_between_profiles(self) -> None:
other = replace(settings(), profile_id="other", device_id="f3c9f507-7473-4fa6-8d71-8786c34c6301")
self.store.save_profile(other, SecretToken("c" * 64))
connection = sqlite3.connect(self.database)
try:
rows = connection.execute(
"SELECT profile_id,device_token_cipher FROM profiles WHERE profile_id IN (?,?) ORDER BY profile_id",
(PROFILE, "other"),
).fetchall()
connection.execute("UPDATE profiles SET device_token_cipher=? WHERE profile_id=?", (rows[1][1], rows[0][0]))
connection.execute("UPDATE profiles SET device_token_cipher=? WHERE profile_id=?", (rows[0][1], rows[1][0]))
connection.commit()
finally:
connection.close()
with self.assertRaises(ProtectionError):
self.new_store().load_profile(PROFILE)
def test_state_graph_rejects_missing_active_pending_overlap_and_snapshot_drift(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute(
"INSERT INTO claim_requests VALUES(?,?,?,'PENDING',?,?)",
(
"63c9f507-7473-4fa6-8d71-8786c34c6301",
PROFILE,
self.store.recovery_snapshot(PROFILE).session.session_id,
"2026-08-04T09:00:00Z",
"2026-08-04T09:00:00Z",
),
)
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "claim_state_conflict"):
self.store.prepare_claim(PROFILE)
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER claim_requests_no_delete")
connection.execute("DELETE FROM claim_requests WHERE status='PENDING'")
connection.execute("DROP TRIGGER active_claims_identity_immutable")
connection.execute("UPDATE active_claims SET task_id=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "active_claim_snapshot_mismatch"):
self.store.active_claim(PROFILE)
def test_succeeded_claim_tombstone_detects_missing_history(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER active_claims_no_delete")
connection.execute("DELETE FROM active_claims")
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "active_claim_request_mismatch"):
self.store.prepare_claim(PROFILE)
def test_closed_history_is_retained_but_does_not_block_next_claim(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z'")
connection.commit()
finally:
connection.close()
self.assertIsNone(self.store.active_claim(PROFILE))
changed = replace(settings(), poll_interval_seconds=16)
self.store.save_profile(changed, None)
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
self.assertIsNotNone(request.claim_request_id)
def test_pending_renew_and_evidence_revalidate_active_graph(self) -> None:
self._claim_active()
renew = self.store.prepare_renew(PROFILE)
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER renew_requests_identity_immutable")
connection.execute("UPDATE renew_requests SET task_id=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "renew_active_mismatch"):
self.store.prepare_renew(PROFILE)
def test_success_receipt_must_match_immutable_slot(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "receipt.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
receipt = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301",
TASK_ID,
ATTEMPT_ID,
upload.kind,
upload.privacy_tier,
upload.sha256,
len(upload.content),
"image/png",
1,
1,
upload.captured_at,
)
self.store.commit_evidence_success(PROFILE, upload, receipt)
connection = sqlite3.connect(self.database)
try:
wrong = dict(receipt.__dict__)
wrong["asset_id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("UPDATE evidence_slots SET receipt_json=?", (json.dumps(wrong),))
connection.rollback()
connection.execute("DROP TRIGGER evidence_slots_receipt_immutable")
connection.execute("UPDATE evidence_slots SET receipt_json=?", (json.dumps(wrong),))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "evidence_receipt_mismatch"):
self.store.prepare_or_resume_evidence(PROFILE, asset)
def test_marker_and_slot_append_only_triggers_prevent_erasing_history(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "append-only.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
self.store.prepare_or_resume_evidence(PROFILE, asset)
connection = sqlite3.connect(self.database)
try:
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("DELETE FROM evidence_slot_markers")
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("UPDATE evidence_slot_markers SET upload_key=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("DELETE FROM evidence_slots")
finally:
connection.close()
def test_capped_equal_renew_is_success_but_authorization_overrun_is_rejected(self) -> None:
self._claim_active()
request = self.store.prepare_renew(PROFILE)
equal = RenewResult(TASK_ID, ATTEMPT_ID, 1, request.expected_lease_expires_at)
self.store.commit_renew_success(PROFILE, request, equal)
self.assertEqual(self.store.active_claim(PROFILE).attempt.lease_expires_at, request.expected_lease_expires_at)
next_request = self.store.prepare_renew(PROFILE)
beyond = RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T10:00:00.000000001Z")
with self.assertRaisesRegex(StateError, "renew_response_mismatch"):
self.store.commit_renew_success(PROFILE, next_request, beyond)
def test_sqlite_database_wal_and_shm_never_contain_plaintext_tokens(self) -> None:
self._claim_active()
# 强制 checkpoint 后同时扫描主文件和可能存在的 WAL/SHM。
connection = sqlite3.connect(self.database)
connection.execute("PRAGMA wal_checkpoint(FULL)")
connection.close()
for path in (self.database, Path(str(self.database) + "-wal"), Path(str(self.database) + "-shm")):
if path.exists():
raw = path.read_bytes()
self.assertNotIn(DEVICE_TOKEN.encode(), raw)
self.assertNotIn(bytes.fromhex(DEVICE_TOKEN), raw)
self.assertNotIn(TOKEN.encode(), raw)
self.assertNotIn(bytes.fromhex(TOKEN), raw)
def test_concurrent_stop_and_claim_commit_never_loses_result(self) -> None:
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
barrier = threading.Barrier(2)
failures: list[BaseException] = []
def stop() -> None:
try:
barrier.wait()
self.store.request_stop(PROFILE)
except BaseException as error:
failures.append(error)
thread = threading.Thread(target=stop)
thread.start()
barrier.wait()
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
thread.join()
self.assertEqual(failures, [])
snapshot = self.store.recovery_snapshot(PROFILE)
self.assertFalse(snapshot.session.accept_new)
self.assertIsNotNone(snapshot.active_claim)
def test_recovery_snapshot_uses_one_sqlite_read_snapshot(self) -> None:
self.store.start_or_resume_polling(PROFILE)
writer = self.new_store()
failures: list[BaseException] = []
class PausingStore(LocalStateStore):
armed = False
def _connect(inner_self):
connection = super(PausingStore, inner_self)._connect()
if inner_self.armed:
def trace(statement: str) -> None:
if inner_self.armed and "FROM claim_requests" in statement:
inner_self.armed = False
thread = threading.Thread(target=do_stop)
thread.start()
thread.join()
connection.set_trace_callback(trace)
return connection
def do_stop() -> None:
try:
writer.request_stop(PROFILE)
except BaseException as error:
failures.append(error)
reader = PausingStore(self.database, FakeProtector(), now=lambda: self.clock[0])
reader.armed = True
snapshot = reader.recovery_snapshot(PROFILE)
self.assertEqual(failures, [])
self.assertTrue(snapshot.session.accept_new)
self.assertFalse(writer.recovery_snapshot(PROFILE).session.accept_new)
def test_business_snapshot_is_immutable_hashed_and_renew_never_rewrites_it(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
raw, digest, initial_lease, current_lease = connection.execute(
"""SELECT snapshot_json,snapshot_digest,initial_lease_expires_at,lease_expires_at
FROM active_claims WHERE closed_at IS NULL"""
).fetchone()
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("UPDATE active_claims SET snapshot_json='{}' WHERE closed_at IS NULL")
connection.rollback()
connection.execute("DROP TRIGGER active_claims_identity_immutable")
for field, changed in (
("sku_color", "白色"),
("sku_size", "XL"),
("quantity", 99),
("max_total_price", "999.00"),
):
payload = json.loads(raw)
payload["task"][field] = changed
tampered = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
connection.execute("UPDATE active_claims SET snapshot_json=? WHERE closed_at IS NULL", (tampered,))
connection.commit()
with self.subTest(field=field), self.assertRaisesRegex(StateError, "active_claim_snapshot_mismatch"):
self.store.active_claim(PROFILE)
connection.execute(
"UPDATE active_claims SET snapshot_json=?,snapshot_digest=? WHERE closed_at IS NULL", (raw, digest)
)
connection.commit()
connection.execute(
"""UPDATE active_claims SET initial_lease_expires_at='2026-08-04T09:59:00Z',
lease_expires_at='2026-08-04T09:59:00Z' WHERE closed_at IS NULL"""
)
connection.commit()
with self.assertRaisesRegex(StateError, "invalid_stored_claim"):
self.store.active_claim(PROFILE)
connection.execute(
"""UPDATE active_claims SET initial_lease_expires_at=?,lease_expires_at=?
WHERE closed_at IS NULL""",
(initial_lease, current_lease),
)
connection.commit()
finally:
connection.close()
renew = self.store.prepare_renew(PROFILE)
self.store.commit_renew_success(PROFILE, renew, RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z"))
connection = sqlite3.connect(self.database)
try:
self.assertEqual(
connection.execute("SELECT snapshot_json,snapshot_digest FROM active_claims WHERE closed_at IS NULL").fetchone(),
(raw, digest),
)
finally:
connection.close()
def test_open_session_detects_tampering_of_every_non_token_profile_setting(self) -> None:
self._claim_active()
changed_values = {
"service_url": "http://127.0.0.1:9999",
"device_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
"adb_path": "D:/other/adb.exe",
"adb_serial": "usb-other",
"transport": "usb",
"poll_interval_seconds": 16,
"failure_threshold": 4,
"http_timeout_seconds": 11,
"step_timeout_seconds": 46,
}
connection = sqlite3.connect(self.database)
try:
for field, changed in changed_values.items():
original = connection.execute(f"SELECT {field} FROM profiles WHERE profile_id=?", (PROFILE,)).fetchone()[0]
connection.execute(f"UPDATE profiles SET {field}=? WHERE profile_id=?", (changed, PROFILE))
connection.commit()
with self.subTest(field=field), self.assertRaisesRegex(StateError, "polling_identity_mismatch"):
self.store.active_claim(PROFILE)
connection.execute(f"UPDATE profiles SET {field}=? WHERE profile_id=?", (original, PROFILE))
connection.commit()
finally:
connection.close()
def test_closed_attempt_evidence_is_history_not_current_recovery_work(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "old-manual.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
self.store.mark_evidence_terminal(PROFILE, upload, "MANUAL")
connection = sqlite3.connect(self.database)
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
next_request = self.store.prepare_claim(PROFILE)
self.assertIsNotNone(next_request.claim_request_id)
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_evidence, ())
def test_succeeded_evidence_history_does_not_block_next_claim(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "old-success.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
receipt = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 1, 1, upload.captured_at,
)
self.store.commit_evidence_success(PROFILE, upload, receipt)
connection = sqlite3.connect(self.database)
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
self.assertIsNotNone(self.store.prepare_claim(PROFILE))
def test_evidence_slot_without_corresponding_claim_history_fails_closed(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "orphan.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
receipt = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 1, 1, upload.captured_at,
)
self.store.commit_evidence_success(PROFILE, upload, receipt)
orphan = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER evidence_slot_markers_immutable")
connection.execute("DROP TRIGGER evidence_slots_identity_immutable")
connection.execute("UPDATE evidence_slot_markers SET attempt_id=?", (orphan,))
connection.execute("UPDATE evidence_slots SET attempt_id=?", (orphan,))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "evidence_claim_history_mismatch"):
self.store.recovery_snapshot(PROFILE)
def test_terminal_renew_is_scoped_to_old_closed_attempt(self) -> None:
self._claim_active()
old_renew = self.store.prepare_renew(PROFILE)
self.store.mark_renew_terminal(PROFILE, old_renew, "MANUAL")
connection = sqlite3.connect(self.database)
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
request = self.store.prepare_claim(PROFILE)
wire = claim_wire()
wire["task"]["id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
wire["authorization"]["id"] = "93c9f507-7473-4fa6-8d71-8786c34c6301"
wire["attempt"]["id"] = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(wire))
next_renew = self.store.prepare_renew(PROFILE)
self.assertNotEqual(next_renew.renew_request_id, old_renew.renew_request_id)
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_renew, next_renew)
def test_successful_renew_response_is_write_once_and_digest_checked(self) -> None:
self._claim_active()
request = self.store.prepare_renew(PROFILE)
self.store.commit_renew_success(PROFILE, request, RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z"))
connection = sqlite3.connect(self.database)
try:
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("UPDATE renew_requests SET response_json='{}' WHERE renew_request_id=?", (request.renew_request_id,))
connection.rollback()
connection.execute("DROP TRIGGER renew_requests_response_immutable")
connection.execute("UPDATE renew_requests SET response_json='{}' WHERE renew_request_id=?", (request.renew_request_id,))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "renew_response_mismatch"):
self.store.active_claim(PROFILE)
def test_invalid_or_reversed_session_and_claim_timestamps_fail_closed(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T08:59:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "invalid_claim_timeline"):
self.store.recovery_snapshot(PROFILE)
def test_invalid_session_closed_at_fails_closed(self) -> None:
self.store.start_or_resume_polling(PROFILE)
connection = sqlite3.connect(self.database)
try:
connection.execute("UPDATE polling_sessions SET closed_at='not-a-time' WHERE profile_id=?", (PROFILE,))
connection.commit()
finally:
connection.close()
with self.assertRaises(StateError):
self.store.recovery_snapshot(PROFILE)
def test_store_rejects_forged_claim_whose_lease_exceeds_authorization(self) -> None:
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
claimed = ClaimedTask.from_wire(claim_wire())
object.__setattr__(claimed.attempt, "lease_expires_at", "2026-08-04T10:00:00.000000001Z")
with self.assertRaisesRegex(StateError, "claim_lease_exceeds_authorization"):
self.store.commit_claim_success(PROFILE, request, claimed)
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_claim, request)
def _claim_active(self) -> None:
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
+1
View File
@@ -0,0 +1 @@
"""轮询协调器测试。"""
+373
View File
@@ -0,0 +1,373 @@
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.calls = 0
def claim_next(self, profile_id: str):
self.calls += 1
if self.gate is not None:
self.gate.wait(2)
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()
wait_until(lambda: coordinator.state == PollingState.CLAIMING)
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)
+1
View File
@@ -0,0 +1 @@
"""remote tests。"""
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import hashlib
import json
import base64
import re
import unittest
from cmbuyer_client.core.models import DeviceCredentials, EvidenceUpload, SecretToken
from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError
from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink
from cmbuyer_client.remote.http_transport import HttpResponse
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN
from tests.remote.test_task_source import DEVICE_ID, FakeTransport
UPLOAD_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301"
ASSET_ID = "63c9f507-7473-4fa6-8d71-8786c34c6301"
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
class EvidenceSinkTests(unittest.TestCase):
def test_upload_has_fixed_fields_and_never_contains_local_path(self) -> None:
digest = hashlib.sha256(PNG).hexdigest()
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
payload = {
"asset_id": ASSET_ID,
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": digest,
"byte_size": len(PNG),
"content_type": "image/png",
"width_px": 1,
"height_px": 1,
"captured_at": "2026-08-04T09:01:00Z",
}
raw = json.dumps(payload, separators=(",", ":")).encode()
transport = FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))
receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
self.assertEqual(receipt.asset_id, ASSET_ID)
self.assertEqual(len(transport.calls), 1)
body = transport.calls[0][3]
self.assertIn(b'filename="evidence.png"', body)
self.assertNotIn(b"C:\\", body)
self.assertNotIn(b"manifest", body)
self.assertNotIn(b".xml", body)
names = re.findall(br'Content-Disposition: form-data; name="([^"]+)"', body)
self.assertEqual(
names,
[b"upload_key", b"attempt_id", b"kind", b"privacy_tier", b"sha256", b"captured_at", b"file"],
)
self.assertEqual(body.count(b'filename="evidence.png"'), 1)
self.assertNotIn(b"claim_token", body)
self.assertNotIn(b"session_id", body)
boundary = dict(transport.calls[0][2])["Content-Type"].split("boundary=", 1)[1]
self.assertEqual(boundary, "cmbuyer-" + UPLOAD_ID.replace("-", ""))
def test_captured_at_equivalent_trailing_zeros_are_accepted(self) -> None:
digest = hashlib.sha256(PNG).hexdigest()
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00.120000Z", PNG)
payload = {
"asset_id": ASSET_ID,
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": digest,
"byte_size": len(PNG),
"content_type": "image/png",
"width_px": 1,
"height_px": 1,
"captured_at": "2026-08-04T09:01:00.12Z",
}
raw = json.dumps(payload, separators=(",", ":")).encode()
for status in (200, 201):
with self.subTest(status=status):
transport = FakeTransport(HttpResponse(status, (("Content-Type", "application/json"),), raw))
receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
self.assertEqual(receipt.captured_at, "2026-08-04T09:01:00.12Z")
def test_wrong_content_hash_fails_before_http_object_can_be_built(self) -> None:
transport = FakeTransport(HttpResponse(500, (), b""))
with self.assertRaises(ValidationError):
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, "0" * 64, "2026-08-04T09:01:00Z", PNG)
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
self.assertEqual(transport.calls, [])
def test_unknown_2xx_is_ambiguous(self) -> None:
digest = hashlib.sha256(PNG).hexdigest()
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
for status in (202, 204, 206):
with self.subTest(status=status):
transport = FakeTransport(HttpResponse(status, (), b""))
with self.assertRaises(AmbiguousRemoteError):
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
self.assertEqual(len(transport.calls), 1)
def test_boundary_collision_and_receipt_mismatch_fail_closed(self) -> None:
marker = ("--cmbuyer-" + UPLOAD_ID.replace("-", "")).encode()
collision_content = PNG + marker
collision = EvidenceUpload(
TASK_ID,
UPLOAD_ID,
ATTEMPT_ID,
hashlib.sha256(collision_content).hexdigest(),
"2026-08-04T09:01:00Z",
collision_content,
)
transport = FakeTransport(HttpResponse(500, (), b""))
from cmbuyer_client.core.errors import ProtocolRemoteError
with self.assertRaises(ProtocolRemoteError):
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), collision)
self.assertEqual(transport.calls, [])
digest = hashlib.sha256(PNG).hexdigest()
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
mismatch = {
"asset_id": ASSET_ID,
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": "f" * 64,
"byte_size": len(PNG),
"content_type": "image/png",
"width_px": 1,
"height_px": 1,
"captured_at": "2026-08-04T09:01:00Z",
}
raw = json.dumps(mismatch, separators=(",", ":")).encode()
with self.assertRaises(AmbiguousRemoteError):
HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload(
DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload
)
dimension_mismatch = dict(mismatch)
dimension_mismatch["sha256"] = digest
dimension_mismatch["width_px"] = 2
raw = json.dumps(dimension_mismatch, separators=(",", ":")).encode()
with self.assertRaises(AmbiguousRemoteError):
HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload(
DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload
)
def test_evidence_error_status_matrix(self) -> None:
digest = hashlib.sha256(PNG).hexdigest()
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN))
from cmbuyer_client.core.errors import CredentialRemoteError, ManualRemoteError, ProtocolRemoteError
cases = (
(401, CredentialRemoteError),
(400, ProtocolRemoteError),
(403, ProtocolRemoteError),
(409, ManualRemoteError),
(413, ProtocolRemoteError),
(415, ProtocolRemoteError),
(500, AmbiguousRemoteError),
(503, AmbiguousRemoteError),
)
for status, expected in cases:
with self.subTest(status=status), self.assertRaises(expected):
HttpEvidenceSink(FakeTransport(HttpResponse(status, (), b""))).upload(credentials, upload)
+184
View File
@@ -0,0 +1,184 @@
from __future__ import annotations
import os
import http.client
import unittest
from unittest import mock
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError
from cmbuyer_client.remote.http_transport import HttpTransport
class FakeResponse:
status = 200
def __init__(self, body: bytes = b"{}", headers: list[tuple[str, str]] | None = None) -> None:
self.body = body
self.headers = headers or [("Content-Type", "application/json")]
def getheader(self, name: str) -> str | None:
return str(len(self.body)) if name == "Content-Length" else None
def getheaders(self) -> list[tuple[str, str]]:
return self.headers
def read(self, maximum: int) -> bytes:
return self.body[:maximum]
class FakeConnection:
def __init__(self, host: str, port: int, timeout: int) -> None:
self.created = (host, port, timeout)
self.calls = 0
self.closed = False
self.sent_headers: dict[str, str] = {}
self.response = FakeResponse()
def request(self, method: str, path: str, body: bytes, headers: dict[str, str]) -> None:
self.calls += 1
self.sent_headers = headers
def getresponse(self) -> FakeResponse:
return self.response
def close(self) -> None:
self.closed = True
class HttpTransportTests(unittest.TestCase):
def test_exact_loopback_and_proxy_environment_is_irrelevant(self) -> None:
made: list[FakeConnection] = []
def factory(*args: object, **kwargs: object) -> FakeConnection:
connection = FakeConnection(*args, **kwargs)
made.append(connection)
return connection
with mock.patch.dict(os.environ, {"HTTP_PROXY": "http://example.invalid:9999"}):
result = HttpTransport(connection_factory=factory).request(
"POST", "/api/v1/tasks/claim-next", (("Content-Type", "application/json"),), b"{}", response_limit=10
)
self.assertEqual(result.status, 200)
self.assertEqual(made[0].created, ("127.0.0.1", 8080, 10))
self.assertEqual(made[0].calls, 1)
self.assertTrue(made[0].closed)
for url in ("http://localhost:8080", "http://127.0.0.1:8081", "http://127.0.0.1:8080/", "https://127.0.0.1:8080"):
with self.subTest(url=url), self.assertRaises(ProtocolRemoteError):
HttpTransport(url)
def test_network_failure_is_ambiguous_without_retry(self) -> None:
class Broken(FakeConnection):
def getresponse(self) -> FakeResponse:
raise OSError("offline")
made: list[Broken] = []
def factory(*args: object, **kwargs: object) -> Broken:
connection = Broken(*args, **kwargs)
made.append(connection)
return connection
with self.assertRaises(AmbiguousRemoteError):
HttpTransport(connection_factory=factory).request(
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=10
)
self.assertEqual(made[0].calls, 1)
def test_generator_headers_and_content_length_framing(self) -> None:
made: list[FakeConnection] = []
def factory(*args: object, **kwargs: object) -> FakeConnection:
connection = FakeConnection(*args, **kwargs)
made.append(connection)
return connection
headers = ((name, value) for name, value in (("Content-Type", "application/json"), ("Accept", "application/json")))
HttpTransport(connection_factory=factory).request(
"POST", "/api/v1/tasks/claim-next", headers, b"{}", response_limit=8
)
self.assertEqual(made[0].sent_headers["Content-Type"], "application/json")
self.assertEqual(made[0].sent_headers["Accept"], "application/json")
legal = FakeConnection("127.0.0.1", 8080, 10)
legal.response = FakeResponse(b"{}", [("Content-Length", "2")])
accepted = HttpTransport(connection_factory=lambda *args, **kwargs: legal).request(
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
)
self.assertEqual(accepted.body, b"{}")
chunked = FakeConnection("127.0.0.1", 8080, 10)
chunked.response = FakeResponse(b"{}", [("Transfer-Encoding", "Chunked")])
accepted_chunked = HttpTransport(connection_factory=lambda *args, **kwargs: chunked).request(
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
)
self.assertEqual(accepted_chunked.body, b"{}")
cases = (
([('Transfer-Encoding', 'chunked'), ('Content-Length', '2')], b'{}'),
([('Transfer-Encoding', 'gzip')], b'{}'),
([('Transfer-Encoding', ' chunked ')], b'{}'),
([('Transfer-Encoding', 'chunked,gzip')], b'{}'),
([('Transfer-Encoding', 'chunked'), ('Transfer-Encoding', 'chunked')], b'{}'),
([("Content-Length", "2"), ("Content-Length", "2")], b"{}"),
([("Content-Length", "+2")], b"{}"),
([("Content-Length", "-0")], b""),
([("Content-Length", "2x")], b"{}"),
([("Content-Length", "3")], b"{}"),
([("Content-Length", "1")], b"{}"),
([("Content-Length", "999")], b"{}"),
([], b"0123456789"),
)
for response_headers, body in cases:
with self.subTest(headers=response_headers, body=body):
connection = FakeConnection("127.0.0.1", 8080, 10)
connection.response = FakeResponse(body, response_headers)
with self.assertRaises(AmbiguousRemoteError):
HttpTransport(connection_factory=lambda *args, value=connection, **kwargs: value).request(
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
)
def test_timeout_incomplete_read_and_close_do_not_expose_partial_body(self) -> None:
token = ("a" * 64).encode()
class Incomplete(FakeResponse):
def read(self, maximum: int) -> bytes:
raise http.client.IncompleteRead(token, 1)
class Connection(FakeConnection):
def getresponse(self) -> FakeResponse:
return Incomplete()
def close(self) -> None:
self.closed = True
raise OSError("close failed")
with self.assertRaises(AmbiguousRemoteError) as captured:
HttpTransport(connection_factory=Connection).request(
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=128
)
self.assertNotIn(token.decode(), _exception_graph(captured.exception))
class Timeout(FakeConnection):
def getresponse(self) -> FakeResponse:
raise TimeoutError("timed out")
with self.assertRaises(AmbiguousRemoteError):
HttpTransport(connection_factory=Timeout).request(
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
)
def _exception_graph(error: BaseException) -> str:
seen: set[int] = set()
values: list[str] = []
pending: list[object] = [error]
while pending:
value = pending.pop()
if id(value) in seen:
continue
seen.add(id(value))
values.append(repr(value))
if isinstance(value, BaseException):
pending.extend(item for item in (value.__cause__, value.__context__) if item is not None)
pending.extend(value.__dict__.values())
return "\n".join(values)
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
import json
import unittest
from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, ProtocolRemoteError
from cmbuyer_client.core.models import ClaimRequest, DeviceCredentials, RenewRequest, SecretToken
from cmbuyer_client.remote.http_transport import HttpResponse
from cmbuyer_client.remote.task_source import HttpTaskSource
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN, claim_wire
from tests.remote.test_http_transport import _exception_graph
DEVICE_ID = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
SESSION_ID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
REQUEST_ID = "33c9f507-7473-4fa6-8d71-8786c34c6301"
RENEW_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301"
class FakeTransport:
def __init__(self, response: HttpResponse) -> None:
self.response = response
self.calls: list[tuple[object, ...]] = []
def request(self, *args: object, **kwargs: object) -> HttpResponse:
self.calls.append(args + (kwargs,))
return self.response
def response(status: int, value: object | None = None) -> HttpResponse:
body = b"" if value is None else json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
headers = () if value is None else (("Content-Type", "application/json; charset=utf-8"),)
return HttpResponse(status, headers, body)
class TaskSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN))
def test_claim_success_and_empty_each_send_once_with_exact_headers(self) -> None:
transport = FakeTransport(response(200, claim_wire()))
claimed = HttpTaskSource(transport).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
self.assertEqual(claimed.task.id, TASK_ID)
self.assertEqual(len(transport.calls), 1)
args = transport.calls[0]
self.assertEqual(args[1], "/api/v1/tasks/claim-next")
headers = dict(args[2])
self.assertEqual(headers["Authorization"], "Bearer " + TOKEN)
self.assertEqual(headers["X-CMBuyer-Device-ID"], DEVICE_ID)
empty = FakeTransport(response(204))
self.assertIsNone(HttpTaskSource(empty).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)))
self.assertEqual(len(empty.calls), 1)
def test_invalid_2xx_is_unknown_and_redirect_is_not_followed(self) -> None:
malformed = FakeTransport(HttpResponse(200, (("Content-Type", "application/json"),), b'{"task":'))
with self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(malformed).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
self.assertEqual(len(malformed.calls), 1)
secret_body = b'{"claim_token":"' + TOKEN.encode()
leaking = FakeTransport(HttpResponse(200, (("Content-Type", "application/json"),), secret_body))
with self.assertRaises(AmbiguousRemoteError) as captured:
HttpTaskSource(leaking).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
self.assertNotIn(TOKEN, _exception_graph(captured.exception))
for status in (201, 202, 206):
with self.subTest(status=status), self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(FakeTransport(response(status, claim_wire()))).claim_next(
self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)
)
redirect = FakeTransport(HttpResponse(302, (("Location", "http://example.invalid"),), b""))
with self.assertRaises(ProtocolRemoteError):
HttpTaskSource(redirect).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
self.assertEqual(len(redirect.calls), 1)
def test_claim_rejects_service_field_bound_drift_as_ambiguous(self) -> None:
mutations = (
("title", "😀" * 121),
("sku_color", "色" * 81),
("sku_size", "码" * 81),
("max_total_price", "1" * 30 + ".00"),
)
for field, invalid in mutations:
value = claim_wire()
value["task"][field] = invalid
transport = FakeTransport(response(200, value))
with self.subTest(field=field), self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(transport).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
self.assertEqual(len(transport.calls), 1)
goods_id = "1" * 33
value = claim_wire()
value["task"].update(
goods_id=goods_id,
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + goods_id,
)
with self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(FakeTransport(response(200, value))).claim_next(
self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)
)
def test_fixed_conflict_and_renew_cas(self) -> None:
conflict = FakeTransport(response(409, {"error": "claim_requires_manual"}))
with self.assertRaises(ManualRemoteError):
HttpTaskSource(conflict).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
request = RenewRequest(TASK_ID, RENEW_ID, SESSION_ID, ATTEMPT_ID, 1, SecretToken(TOKEN), "2026-08-04T09:05:00Z", "2026-08-04T10:00:00Z")
renewed = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T09:06:00Z"})
result = HttpTaskSource(FakeTransport(renewed)).renew(self.credentials, request)
self.assertEqual(result.claim_generation, 1)
capped = RenewRequest(TASK_ID, RENEW_ID, SESSION_ID, ATTEMPT_ID, 1, SecretToken(TOKEN), "2026-08-04T10:00:00.000000000Z", "2026-08-04T10:00:00Z")
capped_result = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T10:00:00Z"})
self.assertEqual(HttpTaskSource(FakeTransport(capped_result)).renew(self.credentials, capped).lease_expires_at, "2026-08-04T10:00:00Z")
beyond_cap = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T10:00:00.000000001Z"})
with self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(FakeTransport(beyond_cap)).renew(self.credentials, request)
stale = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T09:04:00Z"})
with self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(FakeTransport(stale)).renew(self.credentials, request)
for status in (201, 204):
with self.subTest(status=status), self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(FakeTransport(response(status, None if status == 204 else {
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:06:00Z",
}))).renew(self.credentials, request)
def test_claim_and_renew_error_status_matrix(self) -> None:
claim_request = ClaimRequest(SESSION_ID, REQUEST_ID)
claim_cases = (
(HttpResponse(401, (), b""), CredentialRemoteError),
(response(400, {"error": "invalid_request"}), ProtocolRemoteError),
(HttpResponse(403, (), b""), ProtocolRemoteError),
(response(413, {"error": "request_too_large"}), ProtocolRemoteError),
(response(415, {"error": "unsupported_media_type"}), ProtocolRemoteError),
(HttpResponse(500, (), b""), AmbiguousRemoteError),
(HttpResponse(503, (), b""), AmbiguousRemoteError),
(HttpResponse(418, (), b""), ProtocolRemoteError),
)
for wire_response, expected in claim_cases:
with self.subTest(status=wire_response.status), self.assertRaises(expected):
HttpTaskSource(FakeTransport(wire_response)).claim_next(self.credentials, claim_request)
renew_request = RenewRequest(
TASK_ID,
RENEW_ID,
SESSION_ID,
ATTEMPT_ID,
1,
SecretToken(TOKEN),
"2026-08-04T09:05:00Z",
"2026-08-04T10:00:00Z",
)
for code in ("idempotency_conflict", "claim_not_current"):
with self.subTest(code=code), self.assertRaises(ManualRemoteError):
HttpTaskSource(FakeTransport(response(409, {"error": code}))).renew(self.credentials, renew_request)
+33
View File
@@ -22,3 +22,36 @@ class ApplicationArgumentsTests(unittest.TestCase):
def test_none_uses_process_arguments(self) -> None:
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
self.assertEqual(["process-name", "--process-option"], select_application_argv(None))
def test_standalone_entry_does_not_construct_http_or_device_capabilities(self) -> None:
source = (CLIENT_ROOT / "src" / "cmbuyer_client" / "app.py").read_text(encoding="utf-8")
for forbidden in (
"HttpTaskSource",
"HttpTransport",
"DurableClientGateway",
"import uiautomator2",
".device",
".pdd",
):
with self.subTest(forbidden=forbidden):
self.assertNotIn(forbidden, source)
self.assertIn("gateway_factory=None", source)
self.assertIn("consumer=None", source)
def test_t304_ui_has_no_hidden_connection_probe_or_irreversible_capability_import(self) -> None:
paths = [
*(CLIENT_ROOT / "src" / "cmbuyer_client" / "ui").glob("*.py"),
*(CLIENT_ROOT / "src" / "cmbuyer_client" / "polling").glob("*.py"),
]
source = "\n".join(path.read_text(encoding="utf-8") for path in paths)
for forbidden in (
"uiautomator2",
"cmbuyer_client.pdd",
"cmbuyer_client.device",
"submit_order",
"payment",
"http.client",
"subprocess",
):
with self.subTest(forbidden=forbidden):
self.assertNotIn(forbidden, source)
+21
View File
@@ -47,3 +47,24 @@ class LoggingPolicyTests(unittest.TestCase):
self.assertNotIn("not-for-log", content)
self.assertNotIn("13900139000", content)
self.assertIn("[已隐藏]", content)
def test_bearer_bare_token_and_traceback_are_redacted(self) -> None:
token = "a" * 64
with tempfile.TemporaryDirectory() as directory:
paths = RuntimePaths.from_root(Path(directory))
logger = configure_application_logger(paths)
try:
try:
raise RuntimeError("credential=" + token)
except RuntimeError:
logger.exception("Authorization: Bearer %s bare=%s", token, token)
for handler in logger.handlers:
handler.flush()
content = (paths.logs / "client.log").read_text(encoding="utf-8")
finally:
for handler in tuple(logger.handlers):
logger.removeHandler(handler)
handler.close()
self.assertNotIn(token, content)
self.assertIn("[已隐藏]", content)
+58 -1
View File
@@ -2,16 +2,18 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
import tempfile
import unittest
from unittest import mock
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.runtime import RuntimePaths
from cmbuyer_client.runtime import LocalStateRuntime, RuntimePaths
class RuntimePathsTests(unittest.TestCase):
@@ -23,3 +25,58 @@ class RuntimePathsTests(unittest.TestCase):
self.assertTrue(paths.logs.is_dir())
self.assertTrue(paths.artifacts.is_dir())
self.assertTrue(paths.state.is_dir())
self.assertEqual(paths.database, paths.state / "client-state.sqlite3")
def test_localstate_runtime_acquires_mutex_before_protector_and_store(self) -> None:
events: list[str] = []
class Mutex:
def __init__(self, path: Path) -> None:
events.append("mutex")
def close(self) -> None:
events.append("close")
with tempfile.TemporaryDirectory() as directory:
runtime = LocalStateRuntime.open(
RuntimePaths.from_root(Path(directory)),
mutex_factory=Mutex,
protector_factory=lambda: events.append("protector") or object(),
store_factory=lambda path, protector: events.append("store") or object(),
)
runtime.close()
self.assertEqual(events, ["mutex", "protector", "store", "close"])
def test_localstate_runtime_releases_mutex_if_open_fails(self) -> None:
events: list[str] = []
class Mutex:
def __init__(self, path: Path) -> None:
events.append("mutex")
def close(self) -> None:
events.append("close")
def fail() -> object:
raise RuntimeError("failed")
with tempfile.TemporaryDirectory() as directory, self.assertRaises(RuntimeError):
LocalStateRuntime.open(
RuntimePaths.from_root(Path(directory)),
mutex_factory=Mutex,
protector_factory=fail,
)
self.assertEqual(events, ["mutex", "close"])
def test_windows_without_localappdata_fails_instead_of_creating_second_database(self) -> None:
with mock.patch("cmbuyer_client.runtime.os.name", "nt"), mock.patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(RuntimeError, "local_app_data_required"):
RuntimePaths.default()
def test_runtime_root_is_frozen_absolute_and_relative_localappdata_is_rejected(self) -> None:
paths = RuntimePaths.from_root(Path("relative-runtime"))
self.assertTrue(paths.root.is_absolute())
with mock.patch.dict(os.environ, {"LOCALAPPDATA": "relative-local-app-data"}, clear=True):
with self.assertRaisesRegex(RuntimeError, "local_app_data_must_be_absolute"):
RuntimePaths.default()
+1
View File
@@ -0,0 +1 @@
"""原生 Qt Widgets UI 测试。"""
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
import os
import tempfile
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from cmbuyer_client.polling.coordinator import PollingState, RecoveryStatus
from cmbuyer_client.ui.execution import ExecutionPage
from cmbuyer_client.ui.main_window import PurchaseToolWindow
from cmbuyer_client.ui.records import PurchaseRecord
class FakeCoordinator(QObject):
state_changed = Signal(object, str, int)
claim_visible = Signal(object)
settled = Signal()
recovery_status_changed = Signal(object)
configuration_freeze_changed = Signal(bool)
def __init__(self) -> None:
super().__init__()
self.state = PollingState.BLOCKED
self.reason = "单趟执行能力尚未接入,不能领取真实任务。"
self.consecutive_failures = 0
self.can_start = False
self.operation_in_flight = False
self.recovery_status = RecoveryStatus(False, False, False, False, False)
self.starts = 0
self.stops = 0
def start(self) -> None:
self.starts += 1
def stop(self) -> None:
self.stops += 1
def update_profile_settings(self, settings) -> None:
pass
class FakeStore:
def __init__(self) -> None:
self.calls = 0
def save_profile(self, settings, token) -> None:
self.calls += 1
def records() -> list[PurchaseRecord]:
return [
PurchaseRecord("old", "旧记录", "失败", "2026-08-04T09:00:00Z", "旧文字", "旧结果"),
PurchaseRecord("new", "新记录", "待付款", "2026-08-04T10:00:00Z", "新文字", "新结果"),
]
class ExecutionPageTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def setUp(self) -> None:
self.coordinator = FakeCoordinator()
self.page = ExecutionPage(self.coordinator)
self.page.resize(1100, 700)
self.page.show()
QTest.qWait(20)
def tearDown(self) -> None:
self.page.close()
def test_standalone_start_is_disabled_with_nearby_reason_and_empty_real_model(self) -> None:
self.assertFalse(self.page.poll_button.isEnabled())
self.assertIn("尚未接入", self.page.banner.text())
self.assertEqual(self.page.record_model.rowCount(), 0)
self.assertEqual(self.page.record_model.columnCount(), 2)
self.assertEqual(self.coordinator.starts, 0)
def test_records_are_newest_first_and_open_via_action_then_escape_restores_focus(self) -> None:
self.page.set_records(records())
self.assertEqual(self.page.record_model.data(self.page.record_model.index(0, 0)), "新记录")
index = self.page.record_model.index(0, 0)
self.page.record_view.setCurrentIndex(index)
self.page.view_record_action.trigger()
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
self.assertEqual(self.page.detail_title.text(), "新记录")
QTest.keyClick(self.page.return_button, Qt.Key.Key_Escape)
QTest.qWait(20)
self.assertEqual(self.page.left_stack.currentIndex(), self.page.LIVE_PAGE)
self.assertEqual(self.page.record_view.currentIndex().row(), 0)
self.assertTrue(self.page.record_view.hasFocus())
def test_detail_selection_updates_in_place_without_modal(self) -> None:
self.page.set_records(records())
self.page.record_view.setCurrentIndex(self.page.record_model.index(0, 0))
self.page.open_selected_record()
second = self.page.record_model.index(1, 0)
self.page.record_view.setCurrentIndex(second)
self.page._on_record_selected(second)
self.assertEqual(self.page.detail_title.text(), "旧记录")
self.assertEqual(self.page.detail_result.toPlainText(), "旧结果")
def test_double_click_enter_and_visible_action_share_inline_detail_command(self) -> None:
self.page.set_records(records())
index = self.page.record_model.index(0, 0)
self.page.record_view.setCurrentIndex(index)
rect = self.page.record_view.visualRect(index)
QTest.mouseDClick(self.page.record_view.viewport(), Qt.MouseButton.LeftButton, pos=rect.center())
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
self.assertIsNone(QApplication.activeModalWidget())
self.page.return_to_live()
self.page.record_view.setFocus()
QTest.keyClick(self.page.record_view, Qt.Key.Key_Return)
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
self.page.return_to_live()
self.page.view_record_action.trigger()
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
def test_responsive_resize_keeps_model_selection_and_inline_detail(self) -> None:
self.page.set_records(records())
model = self.page.record_model
self.page.record_view.setCurrentIndex(model.index(0, 0))
self.page.open_selected_record()
for width in (700, 900, 1200):
self.page.resize(width, 700)
QTest.qWait(10)
self.assertIs(self.page.record_view.model(), model)
self.assertEqual(self.page.record_view.currentIndex().row(), 0)
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
def test_record_model_detail_and_log_redact_bare_token_sentinel(self) -> None:
token = "e" * 64
record = PurchaseRecord("secret", "标题 " + token, "失败", "2026-08-04T10:00:00Z", token, token)
self.page.set_records([record])
self.page.record_view.setCurrentIndex(self.page.record_model.index(0, 0))
self.page.open_selected_record()
self.page.log_view.append_event("Bearer " + token)
visible = "\n".join(
(
str(self.page.record_model.data(self.page.record_model.index(0, 0))),
self.page.detail_original.toPlainText(),
self.page.detail_result.toPlainText(),
self.page.log_view.toPlainText(),
)
)
self.assertNotIn(token, visible)
self.assertIn("已隐藏", visible)
class MainWindowTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def test_fixed_default_tabs_and_close_do_not_start_or_stop_blocked_session(self) -> None:
coordinator = FakeCoordinator()
window = PurchaseToolWindow(
store=FakeStore(),
coordinator=coordinator,
profile_settings=None,
has_stored_device_token=False,
)
window.show()
QTest.qWait(10)
self.assertEqual(window.tabs.count(), 2)
self.assertEqual([window.tabs.tabText(i) for i in range(2)], ["采购执行", "配置"])
self.assertIs(window.tabs.currentWidget(), window.execution_page)
self.assertFalse(window.tabs.tabsClosable())
window.close()
self.assertEqual(coordinator.starts, 0)
self.assertEqual(coordinator.stops, 0)
def test_close_waits_for_inflight_settlement_instead_of_terminating(self) -> None:
coordinator = FakeCoordinator()
coordinator.state = PollingState.CLAIMING
coordinator.operation_in_flight = True
window = PurchaseToolWindow(
store=FakeStore(),
coordinator=coordinator,
profile_settings=None,
has_stored_device_token=False,
)
window.show()
self.assertFalse(window.close())
self.assertTrue(window.isVisible())
self.assertEqual(coordinator.stops, 1)
coordinator.operation_in_flight = False
coordinator.state = PollingState.STOPPED
coordinator.settled.emit()
QTest.qWait(20)
self.assertFalse(window.isVisible())
def test_recovery_status_freezes_and_unfreezes_non_token_settings(self) -> None:
coordinator = FakeCoordinator()
window = PurchaseToolWindow(
store=FakeStore(),
coordinator=coordinator,
profile_settings=None,
has_stored_device_token=False,
)
coordinator.recovery_status_changed.emit(RecoveryStatus(True, False, True, False, False))
self.assertFalse(window.settings_page.device_id.isEnabled())
self.assertTrue(window.settings_page.device_token.isEnabled())
coordinator.recovery_status_changed.emit(RecoveryStatus(False, False, False, False, False))
self.assertTrue(window.settings_page.device_id.isEnabled())
window.close()
class PurchaseRecordTimestampTests(unittest.TestCase):
def test_records_sort_by_real_rfc3339_nanoseconds_not_raw_text(self) -> None:
precise = [
PurchaseRecord("later", "稍后", "完成", "2026-08-04T10:00:00.9Z"),
PurchaseRecord("earlier", "稍早", "完成", "2026-08-04T10:00:00.11Z"),
]
from cmbuyer_client.ui.records import PurchaseRecordModel
model = PurchaseRecordModel(precise)
self.assertEqual(model.record_at(0).record_id, "later")
def test_record_timestamp_rejects_offset_and_noncanonical_trailing_zero(self) -> None:
for timestamp in ("2026-08-04T10:00:00+08:00", "2026-08-04T10:00:00.10Z"):
with self.subTest(timestamp=timestamp), self.assertRaises(ValueError):
PurchaseRecord("id", "标题", "完成", timestamp)
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import os
from pathlib import Path
import tempfile
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QPushButton
from cmbuyer_client.localstate.models import ProfileSettings
from cmbuyer_client.ui.settings import SettingsPage
DEVICE_ID = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
TOKEN = "b" * 64
class FakeStore:
def __init__(self, error: Exception | None = None) -> None:
self.calls: list[tuple[ProfileSettings, object]] = []
self.error = error
def save_profile(self, settings: ProfileSettings, token: object) -> None:
self.calls.append((settings, token))
if self.error is not None:
raise self.error
class SettingsPageTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def setUp(self) -> None:
self.directory = tempfile.TemporaryDirectory()
self.adb = Path(self.directory.name) / "adb.exe"
self.adb.touch()
def tearDown(self) -> None:
self.directory.cleanup()
def fill(self, page: SettingsPage) -> None:
page.device_id.setText(DEVICE_ID)
page.adb_path.setText(str(self.adb))
page.adb_serial.setText("device-serial")
def existing_settings(self) -> ProfileSettings:
return ProfileSettings(
"default",
"http://127.0.0.1:8080",
DEVICE_ID,
str(self.adb),
"device-serial",
"usb",
)
def test_first_save_requires_token_and_performs_zero_store_write(self) -> None:
store = FakeStore()
page = SettingsPage(store)
page.show()
QTest.qWait(10)
self.fill(page)
page.save()
self.assertEqual(store.calls, [])
self.assertTrue(page.device_token.hasFocus())
self.assertIn("首次保存", page.feedback.text())
def test_first_save_rejects_65_character_token_without_silent_truncation(self) -> None:
store = FakeStore()
page = SettingsPage(store)
page.show()
QTest.qWait(10)
self.fill(page)
invalid_token = TOKEN + "b"
page.device_token.setText(invalid_token)
page.save()
self.assertEqual(store.calls, [])
self.assertEqual(page.device_token.text(), invalid_token)
self.assertTrue(page.device_token.hasFocus())
self.assertIn("64 位", page.feedback.text())
def test_visual_order_focuses_invalid_device_before_missing_token_or_adb(self) -> None:
store = FakeStore()
page = SettingsPage(store)
page.show()
QTest.qWait(10)
page.device_id.setText("not-a-uuid")
page.save()
self.assertEqual(store.calls, [])
self.assertTrue(page.device_id.hasFocus())
self.assertIn("UUIDv4", page.feedback.text())
def test_existing_blank_token_passes_none_and_success_never_refills(self) -> None:
store = FakeStore()
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.save()
self.assertEqual(len(store.calls), 1)
self.assertIsNone(store.calls[0][1])
self.assertEqual(page.device_token.text(), "")
self.assertEqual(page.token_status.text(), "已保存")
def test_nonempty_token_replaces_once_then_is_cleared(self) -> None:
store = FakeStore()
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.device_token.setText(TOKEN)
page.save()
self.assertEqual(len(store.calls), 1)
self.assertEqual(store.calls[0][1].value, TOKEN)
self.assertEqual(page.device_token.text(), "")
self.assertNotIn(TOKEN, page.feedback.text())
self.assertNotIn(TOKEN, page.token_status.text())
def test_existing_token_replacement_rejects_65_characters_without_store_write(self) -> None:
store = FakeStore()
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.show()
QTest.qWait(10)
invalid_token = TOKEN + "b"
page.device_token.setText(invalid_token)
page.save()
# 零写入即表示已保存的原 token 未被替换。
self.assertEqual(store.calls, [])
self.assertEqual(page.device_token.text(), invalid_token)
self.assertTrue(page.device_token.hasFocus())
self.assertEqual(page.token_status.text(), "已保存")
def test_failed_save_preserves_token_and_focuses_secret_field_without_echo(self) -> None:
store = FakeStore(RuntimeError("must-not-appear-" + TOKEN))
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.show()
QTest.qWait(10)
page.device_token.setText(TOKEN)
page.save()
self.assertEqual(page.device_token.text(), TOKEN)
self.assertTrue(page.device_token.hasFocus())
self.assertNotIn(TOKEN, page.feedback.text())
def test_pending_or_active_freezes_every_non_token_field_and_preserves_exact_settings(self) -> None:
original = self.existing_settings()
store = FakeStore()
page = SettingsPage(
store,
settings=original,
has_stored_device_token=True,
identity_frozen=True,
)
for field in (
page.device_id,
page.adb_path,
page.adb_serial,
page.transport,
page.poll_interval,
page.failure_threshold,
page.http_timeout,
page.step_timeout,
):
self.assertFalse(field.isEnabled())
self.assertTrue(page.device_token.isEnabled())
page.device_token.setText(TOKEN)
page.save()
self.assertEqual(store.calls[0][0], original)
def test_page_has_no_connection_probe_command_and_explains_deferred_validation(self) -> None:
page = SettingsPage(FakeStore())
button_texts = [button.text() for button in page.findChildren(QPushButton)]
self.assertEqual(button_texts, ["保存配置"])
self.assertIn("首次真实领取", page.validation_hint.text())
self.assertEqual(page.service_url.text(), "http://127.0.0.1:8080")
self.assertTrue(page.service_url.isReadOnly())
+9 -7
View File
@@ -87,13 +87,15 @@ T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
1. T-111 先冻结“开始采购授权”的单趟契约;同时主审并合入只创建 `DRAFT` 的 T-202。
2. T-111 完成后恢复 **T-103 真机取证**;按依赖推进 T-104 → T-107 的分段安全判据。
3. admin agent 先做 T-209 把旧两趟 schema / 状态机迁到单趟,再推进 T-203 服务端批量开始采购 /
一次性授权;两者不依赖页面选择器。T-205 起的 attempt 与证据字段等待 T-103。
4. Phase 3:双端打通与单趟围栏前 dry-run。
5. Phase 4:单趟真实提交、待付款、调和与收尾。
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
1. 完成 **T-103 人工真机验收**,再按依赖推进 T-104 → T-107 的分段安全判据。
2. admin agent 在 T-107 后推进 T-210 → T-205 → T-206 → T-207 → T-208,冻结三闸门证据、
attempt 事件、围栏、一次性结果与人工调和。
3. client agent 先做当前可领取的 T-303,再推进 T-304 / T-306 / T-307 / T-308;与 admin 写路径
不重叠的任务继续并行。
4. T-305 完成围栏前真机 dry-run;T-400 只用 fixture/fake port 建立离线一次性提交安全闭包。
5. T-403 → T-402 先准备围栏后调和与人工付款事实记录;T-401 才在新真实单趟中首次点击一次,
点击后固定报 `UNKNOWN` 并由人调和,绝不付款。
6. T-405 生成可核验 Windows 包,T-404 对同一候选 commit 与产物做最终只读验收;V2 能力继续后置。
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
+8 -1
View File
@@ -31,7 +31,10 @@
| 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 |
| 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 |
| 截图处理 | `Pillow` | 已定 | 判断页面是否渲染完成,避免保存白屏壳层 |
| HTTP 客户端 | 标准库 `urllib` 或 `httpx` | **待定** | 先用标准库;确有重试/连接池需求再评估 |
| HTTP 客户端 | 标准库 `http.client` | 已定 | 只直连 `127.0.0.1:8080`;不读代理、不跟随重定向、不做隐藏重试或连接池 |
| 可恢复状态 | 标准库 `sqlite3`(WAL / FULL) | 已定 | request/slot 先落库再 HTTP;每个操作独立连接,恢复读取使用单一事务快照 |
| 秘密保护 | Windows Current User DPAPI | 已定 | token 原始 32 字节只以绑定 profile+device/attempt context 的密文 BLOB 入库;非 Windows 不降级 |
| 单实例 | Windows `Global\` named mutex | 已定 | 以规范数据库路径 hash 命名,先于 DPAPI/SQLite 取得,覆盖同用户跨 session |
| Excel | 不引入 | 已定 | Excel 解析移到采购服务;采购工具不再直接读表 |
| 测试 | `unittest`(标准库) | 已定 | 前序项目 171 项测试均用标准库,无需 pytest |
| 打包 | `pyinstaller` | 已定 | 交付给运营电脑;开发期依赖 |
@@ -55,6 +58,10 @@
Go 侧重写表头校验和行级报错,不能直接复用前序项目的 Python 实现。
- **采购工具不持有业务权威。** 金额上限、授权有效性、任务状态流转的判定权在采购服务;
采购工具本地校验只作为第二道防线,两边不一致时一律转人工。
- **HTTP 不做自动重试。** claim/renew/evidence 的重放权属于持有 durable 幂等槽的恢复门面;底层每次
方法最多一个请求。401 修复 Bearer 后、结果不明或重启恢复都必须复用原 key 与原 body/file。
- **客户端 SQLite 只保存恢复事实。** append-only session/claim/renew/evidence history 不是服务端任务
权威;它的作用是阻止崩溃、并发或状态损坏导致第二次领取、换图或换 key。
- **不引入 pytest / 不引入 ORM。** 同一职责不并存两套方案。
## 五、构建与运行命令
+71 -2
View File
@@ -229,9 +229,42 @@ CREATE TABLE purchase_attempts (
failure_code TEXT,
started_at TEXT NOT NULL,
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 (
id TEXT PRIMARY KEY,
@@ -327,6 +360,20 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
### 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`,必须重新点击
开始采购。旧授权永不复活。
- 设备租约丢失不等于授权可安全重用。只有服务端确认该 attempt 未建立围栏,才能关闭 attempt 并
@@ -334,6 +381,24 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
- 围栏建立后即使租约过期也只恢复同一 `order_submission` 的调和,不能回到可领取队列。
- 每种非终态都必须给出安全下一步,不能出现隐藏表单导致任务永久锁死。
#### 采购工具本地恢复状态
- 生产入口先取得基于规范数据库路径的 Windows `Global\` named mutex,再构造 DPAPI 和 SQLite;不能
以服务端“单设备最多一个 claim”替代本机单实例。
- profile、polling session、claim/renew request、open/historical claim、evidence marker/slot 使用 WAL、
`synchronous=FULL` 与 append-only/单调关闭约束。当前 session/claim 由 `closed_at IS NULL` partial
unique 保证唯一,历史关闭后不阻塞下一条,但不得删除或复活。
- `DurableClientGateway` 是轮询和截图接入的唯一顺序入口:先 durable prepare,再一次 HTTP,最后原子
commit。停止只把当前 session 的 `accept_new` 设为 false;飞行中响应仍提交,pending/open 不清除。
- 每次发送前校验 profile/session/request/active immutable business snapshot、renew history、evidence marker/slot/
receipt 的完整状态图;snapshot、renew response 与 evidence receipt 还保存不可变摘要。任一冗余事实不一致、
DPAPI context 不匹配、数据库缺行或文件 identity 变化时零 HTTP 停止,不能自行“修复”。
- 设备 Bearer 的 401 发生在服务端读取 body/写幂等事实前,因此槽保持 `PENDING`;只允许同 device id
更新 Bearer,并在用户再次开始后用原 key/body/file 尝试。claim token 和其他 frozen 配置不变。
- 原始 token 以 DPAPI current-user context 密文保存:device token 绑定 profile+device,claim/renew token
绑定 profile+attempt,密文不能跨行复用;日志 formatter 对 Bearer、裸 64 位 token 和
traceback 做最终脱敏。异常对象也只保留固定 reason,不挂接含响应 body/partial/path 的异常上下文。
### 5.4 证据分层
| 数据 | 位置 | 边界 |
@@ -353,6 +418,9 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
PNG 魔数、完整解码、字节数、尺寸和调用方声明的 SHA-256。
- 上传 handler 必须先通过逐请求 SQLite 设备认证并再次校验规范 principal,再解析 Content-Type 或
读取 body。空库、无效或已撤销凭据拒绝,认证存储故障返回 503;不把管理员 session 当设备身份。
- 首次 evidence INSERT 必须由 store 校验和 SQLite trigger 双重证明 `(task, attempt, authenticated device)`
对应未关闭 claim。历史同设备/upload key 重放先于该检查,因此人工关闭 claim 不会破坏已提交资产的
幂等读取;关闭后禁止新 upload key,且不为此增加 token/session 字段或放宽截图 kind。
- 文件写入显式配置的私有证据根目录:同目录随机临时文件 → 流式 hash → 校验 → `fsync` → 原子
rename 到 SHA-256 内容地址 → 最后事务写数据库。数据库永远不指向半文件或缺失文件。
- SQLite 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除
@@ -408,7 +476,8 @@ cmbuyer/
└── scripts/
```
执行器依赖 `TaskSource` / `ResultSink`,不直接读取 Excel 或拼接 HTTP。来源变化不得改变安全执行器。
执行器依赖核心端口,不直接读取 Excel 或拼接 HTTP。T-303 只提供 `TaskSource` / `EvidenceSink`;完整
`ResultSink` 在服务端 events/fail/fence/result 契约落地后分阶段组合,来源变化不得改变安全执行器。
## 九、架构纪律
+25 -20
View File
@@ -19,16 +19,17 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
| --- | --- | --- | --- |
| 1 | T-001 → T-004 → T-201 → T-202 | T-002 → T-101 → T-102 | T-003、原型与契约 |
| 2 | T-209 → T-203 → T-204 | T-103 → T-104 → T-105 → T-107 | T-111 单趟重构;逐任务主审 |
| 3 | T-205 → T-207 → T-208 | T-301 → T-304 → T-306 | 双端契约完整门禁 |
| 4 | - | T-305 | 围栏前端到端 dry-run 主审 |
| 5 | T-402 / T-403 | T-401 | 真机人工授权、提交与调和验收 |
| 6 | - | T-405 | T-404 完整验收 |
| 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-403 → T-402 | T-305;T-208 / T-303 / T-307 后做 T-308;再汇合到 T-400 | 围栏前 dry-run、客户端围栏恢复与离线单次提交主审 |
| 5 | - | T-401 | 新真实单趟、首次一次性提交与人工调和验收 |
| 6 | - | T-405 | T-404 完整只读验收 |
并行门禁:
- T-202 只创建/展示 `DRAFT`,可与 T-103 并行;T-203 只实现管理员授权与任务状态,不依赖页面
选择器,也可在 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-401 前任何任务都不得真实点击“提交订单”;支付能力永远不实现。
- 主 agent 独立审阅每个子 agent 的 diff 并重跑验证,不以其自报完成作为证据。
@@ -59,9 +60,9 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
| T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 |
| T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 |
| T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不进入确认页、不提交 |
| T-105 | 验证数量设置、读回与闸门二 | T-103 | 设置数量并精确读回;再次读规格/单价等于闸门一;不一致即停 |
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 进入确认页读规格、数量、应付总额并验证提交控件唯一;绝不点击 |
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只使用本项目证据;dry-run 静态不可达提交;版本失配 fail closed |
| T-105 | 验证数量设置、读回与闸门二 | T-104 | 先只读取证并由人确认,再设置数量、精确读回并复核规格/单价;不一致即停 |
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 只读采集导航前、确认页、最终控件及一次人工 Back 状态;agent 不执行页面动作 |
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只点已取证导航控件一次;最终控件只读观察;一次 Back;静态不可达提交 |
| T-110 | 批准证据绑定的受控规格入口 | T-103 no-go 决策 | 仅批准拼多多 8.17.0 已取证的精确唯一“快要抢光”,不泛化 |
| 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-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-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-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 · 双端打通(单趟围栏前)
@@ -88,20 +90,23 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
| --- | --- | --- | --- |
| T-301 | 设备凭据与身份隔离(F-013) | T-201 | Bearer 不能建单/授权;管理会话不能领任务;凭据可撤销 |
| T-302 | 已授权任务原子领取与租约(F-005) | T-301, T-203 | 只领 PENDING+有效授权;并发唯一;重放同一 attempt;claim token/generation 有效 |
| T-303 | `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-306 | 单趟截图与证据上传 | T-304, T-204 | 显式上传原始 screenshot;可含页面地址/手机号;完整 XML、本机路径、外部支付页不上传 |
| T-305 | **单趟围栏前端到端 dry-run** | T-306, T-107, T-205 | 从领取连续跑到闸门三与提交控件验证,随后安全停止;全程事件/证据齐全,不申请真实许可、不点击提交 |
| T-306 | 规格面板原始截图与可靠证据上传 | T-104, T-303 | 只发布 Gate1 的显式原始 PNG;唯一恢复槽;可含页面地址/手机号;不上传 XML/路径或扩 kind |
| T-307 | 客户端 attempt 事件与失败 sink | T-205, T-303 | 窄 events/fail HTTP 与同键恢复;不实现 ResultSink、围栏、结果、PDD 或 UI |
| T-308 | 客户端提交围栏与结果 sink | T-208, T-303, T-307 | 复用同一 transport/localstate;首次明确且 durable 的 CREATE_ONCE 才产生当前进程 opaque permit;重启不可重建 |
| T-305 | **单趟围栏前端到端 dry-run** | T-107, T-304, T-306, T-307, T-210 | 连续跑到三闸门及最终控件只读观察,随后一次安全 Back 并以固定 reason 停在围栏前;不申请许可、不点击提交 |
### Phase 4 · 真实提交与收尾
| ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- |
| T-401 | **单趟采购与提交围栏端到端** | T-305, T-208 | 同一设备会话通过三闸门、首次明确围栏许可后点击一次;结果不明只调和;明确创建转待付款;绝不付款 |
| T-402 | 待付款收口与手工完成 | T-401 | 详情展示订单证据;人付款后手工标记;待付款不等于成功 |
| T-403 | 失败分类与证据归档 | T-401 | 覆盖需求 reason;终态原子回写;围栏后无重试 |
| T-404 | 完整 MVP 验收 | T-402, T-403 | P0 验收、双端门禁、真机记录和 current-state 全部通过 |
| T-405 | 采购工具打包与运行文档 | T-404 | 运营电脑可安装运行,记录产物 SHA-256 |
| T-400 | 离线单次提交安全编排 | T-305, T-308 | 只用 fixture/fake port 证明四条件、点击意图先落盘、调用恰好一次和崩溃后只调和;生产接线仍不可达 |
| T-403 | 失败分类、证据归档与管理员调和视图 | T-208 | 只展示已有事实并复用同一 submission 调和事务;围栏后无重试、释放或重新授权 |
| T-402 | 待付款人工收口与付款事实记录 | T-403 | 只记录人已在外部付款的事实;三组确认、完整 lineage 与追加式审计;系统不付款 |
| T-401 | **首次真实提交与生产接线** | T-400, T-402 | 新真实单趟重新通过三闸门;首次明确许可后点击一次;首次运行固定 UNKNOWN 并人工调和;绝不付款 |
| T-405 | 采购工具 Windows 打包与运行文档 | T-401 | PyInstaller onedir ZIP;拒绝秘密/状态/证据/tests/XML;记录 commit 与 SHA-256 |
| T-404 | 完整 MVP 只读验收 | T-401, T-402, T-403, T-405 | 冻结同一候选 commit/产物做 P0、双端门禁和真机记录复核;缺陷退回原任务,不在验收任务修复 |
### V2 及以后
@@ -123,8 +128,8 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
- **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103)
- **M3**:真机设置数量、闸门二、确认页闸门三和唯一提交控件 dry-run 通过,不点击。(T-107)
- **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302)
- **M5**:单趟从领取到围栏前 dry-run 跑通,事件与证据齐全。(T-305)
- **M6**:单趟真实提交只创建待付款订单,围栏/调和/人工收口闭环。(T-401~T-404)
- **M5**:围栏前 dry-run 跑通,客户端围栏恢复和离线一次性提交安全闭包成立。(T-305、T-308、T-400)
- **M6**:新真实单趟只创建可能的待付款订单,围栏/调和/人工收口、Windows 交付和只读验收闭环。(T-401~T-405)
**M2 仍是生死线。** M2 前可以并行管理会话、DRAFT 建单和开始采购的服务端授权事务,但不能编写
依赖未取证页面字段的生产执行逻辑。提速来自两端并行、窄任务和主审复用门禁,不来自删除安全闸门。
+5 -3
View File
@@ -19,7 +19,7 @@
| IX-004 | US-002、US-004 | web 列表行 / `/tasks/{id}` | 双击行、浏览器前进后退 | 路由化详情抽屉或完整页,显示执行证据 | P0 | 已定 |
| IX-005 | US-005 | web 批量操作条 | 点击开始采购 | 创建一次性授权并进入 `PENDING` | P0 | 已定 |
| IX-006 | US-006 | web 待付款详情 | 查看后手工标记 | 只记录人工付款完成,不执行支付 | P0 | 已定 |
| IX-007 | US-003 | desk 配置 tab | 保存并检查连接 | 服务、ADB、App 版本就绪 | P0 | 已定 |
| IX-007 | US-003 | desk 配置 tab | 保存并校验配置 | 本地字段/路径有效;服务身份与真机能力在真实流程中验证 | P0 | 已定 |
| IX-008 | US-003、US-008 | desk 采购执行 tab | 开始 / 停止轮询、选择记录、Esc | 单趟执行和安全人工接管 | P0 | 已定 |
| IX-009 | US-008 | web / desk 通用错误反馈 | 请求或流程失败 | 显示阶段、原因和安全下一步 | P0 | 已定 |
| IX-010 | US-005 | web 围栏前人工处理 | 重置 / 取消 | 关闭旧授权后回 `DRAFT` 或取消 | P0 | 已定 |
@@ -57,7 +57,8 @@
### IX-006 待付款核对与标记完成
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和订单截图。
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和三闸门内部证据;MVP
没有提交后结果截图类型,不把确认页图片冒充订单结果图。
- 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。”
- “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。
- 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。
@@ -105,7 +106,8 @@
1. 当前任务在同一趟内完成闸门二和闸门三,并确认提交控件精确唯一。
2. 客户端显示“正在申请提交围栏”,提交当前验证摘要。
3. 只有服务端首次明确返回 `click_permitted=true`,客户端才先持久化唯一点击意图,再精确点击一次。
4. 明确创建转待付款;外部支付、安全校验、超时、断连或未知页面转待调和。
4. MVP 首次真实点击后因没有已取证的提交后页面判据,无条件上报 `UNKNOWN` 并转待调和;只有管理员
对同一 submission 明确调和为已创建,才转待付款。
5. 围栏后的 web 详情只显示同一 `submission_id` 的证据、调和记录与人工结论。
| 状态 | 允许动作 | 禁止动作 |
+144 -41
View File
@@ -48,7 +48,7 @@
"code": "version_conflict",
"message": "任务已变化,请刷新后重选",
"retryable": false,
"request_id": "018f..."
"request_id": "c3c9f507-7473-4fa6-8d71-8786c34c6301"
}
}
```
@@ -85,7 +85,7 @@
```json
{
"create_key": "018f...",
"create_key": "d3c9f507-7473-4fa6-8d71-8786c34c6301",
"title": "纯棉短袖",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
"sku_color": "黑色CHA(纯棉)",
@@ -96,6 +96,11 @@
```
- 服务端解析并保存 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` 是本任务允许创建待付款订单的总额上限,不是参考单价。
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
@@ -103,10 +108,10 @@
```json
{
"start_key": "018f...",
"start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"tasks": [
{"task_id": "018f-task-1", "expected_task_version": 1},
{"task_id": "018f-task-2", "expected_task_version": 1}
{"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1},
{"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1}
]
}
```
@@ -125,11 +130,11 @@
```json
{
"start_key": "018f...",
"start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"authorized_count": 2,
"tasks": [
{"task_id": "018f-task-1", "task_version": 2, "authorization_id": "018f-auth-1"},
{"task_id": "018f-task-2", "task_version": 2, "authorization_id": "018f-auth-2"}
{"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "a3c9f507-7473-4fa6-8d71-8786c34c6301"},
{"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "b3c9f507-7473-4fa6-8d71-8786c34c6301"}
],
"payment_automated": false
}
@@ -160,7 +165,7 @@
```json
{
"device_id": "desk-01",
"device_id": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
"client_version": "0.1.0",
"adb_serial": "192.168.0.173:5555",
"android_release": "16",
@@ -173,12 +178,22 @@
### `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
{
"task": {
"id": "018f-task",
"id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"version": 3,
"title": "纯棉短袖",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
@@ -189,24 +204,78 @@
"max_total_price": "30.00"
},
"authorization": {
"id": "018f-auth",
"id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
"task_version": 2,
"expires_at": "2026-08-04T10:00:00Z"
},
"attempt": {
"id": "018f-attempt",
"claim_token": "opaque-single-claim-token",
"id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:05:00Z"
}
}
```
- 只返回有 `ACTIVE` 授权的 `PENDING`;服务端在一个事务中转为 `CLAIMED` 并创建 attempt。
- 同一 `claim_request_id` 同载荷重放同一结果;并发设备只有一个成功。
- 一个设备有未结束领取时优先重放该领取,不能悄悄领第二条。
- 设备认证发生在 Content-Type 解析和 body 读取前。事务的第一条数据库业务语句取得 SQLite 写入
位置并再次条件确认设备仍为 `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、通用坐标或支付能力。
### `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、地址、手机号、
@@ -234,8 +303,10 @@
- 恰好一个带 `Content-Type: image/png` 的显式文件;除上述六个元数据字段外,未知或重复字段均拒绝。
- 单文件最多 10 MiB、单边最多 8192 px、总像素最多 16,777,216;服务端校验 PNG 魔数、完整解码、
字节数、尺寸与调用方声明的 64 位小写 SHA-256。
- `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task。认证必须先于 Content-Type 解析和请求体读取。
- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;任务、attempt、截图或元数据变化返回 `409`。
- `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task,且首次写入必须存在由认证设备持有的
未关闭 claim;设备 A 不能向设备 B 的 attempt 上传。认证必须先于 Content-Type 解析和请求体读取。
- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;即使 claim 后续由人工关闭,已成功资产仍先
重放历史结果。关闭后不得用新 upload key 写新证据;任务、attempt、截图或元数据变化返回 `409`。
- 首次成功返回 `201`,幂等重放返回 `200`。响应只含资产 id、关联 id、kind/tier、hash、字节数、
MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。
- 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、
@@ -247,11 +318,11 @@
```json
{
"fence_key": "018f-fence-request",
"task_id": "018f-task",
"fence_key": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
"task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"expected_task_version": 3,
"authorization_id": "018f-auth",
"claim_token": "opaque-single-claim-token",
"authorization_id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"selected_color": "黑色CHA(纯棉)",
"selected_size": "M(建议100-115)",
"gate1_unit_price": "12.88",
@@ -271,7 +342,7 @@
```json
{
"submission_id": "018f-submission",
"submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
"status": "FENCED",
"click_permitted": true,
"submit_text": "提交订单"
@@ -288,10 +359,10 @@
```json
{
"result_key": "018f-result",
"attempt_id": "018f-attempt",
"result_key": "03c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"observation": "SUBMITTED",
"evidence_asset_id": "018f-asset"
"evidence_asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301"
}
```
@@ -306,30 +377,62 @@
### 文本和金额校验
- 规格字段:Unicode 规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。
- `goods_id`:仅 ASCII 十进制数字,canonical URL 中唯一。
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后再比较和持久化。
- 标题/规格字段:输入按 Go `strings.TrimSpace` 的 Unicode White_Space 集形成实际持久化值;title 最多
120 个 Unicode code point,颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;
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
class TaskSource(Protocol):
def claim_next(self, session: Session) -> ClaimedPurchase | None: ...
def renew_lease(self, claim: Claim) -> Lease: ...
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
class ResultSink(Protocol):
def append_events(self, claim: Claim, events: list[TaskEvent]) -> None: ...
def upload_screenshot(self, claim: Claim, asset: ScreenshotAsset) -> AssetRef: ...
def fail_attempt(self, claim: Claim, failure: AttemptFailure) -> None: ...
def create_submission_fence(self, claim: Claim, proof: SubmissionProof) -> SubmissionPermit: ...
def report_submission_result(self, permit: SubmissionPermit, result: SubmissionResult) -> None: ...
class EvidenceSink(Protocol):
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
```
执行器不能依赖具体 HTTP 或 Excel 实现。`SubmissionPermit` 只能由 `ResultSink` 的服务端成功响应构造,
业务代码不能手工 new 一个许可。
T-303 只实现 claim/renew 与 `SKU_PANEL_GATE_1` 单张 PNG;不得用运行时 `NotImplementedError` 伪造
events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `DurableClientGateway` 调用:它先把
同一个 request/upload key 与完整载荷写入 SQLite,再最多发送一次 HTTP;401 保留 `PENDING`,由用户修复
同一 device id 的 Bearer 后显式重放;网络、超时、503、截断、非法/未知 2xx 同样只保留原槽。协议/409
终止槽但不换 key。成功响应落库失败时,重启仍用原 key 向服务端恢复事实。
金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零,最多
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` 只能由
服务端首次明确成功响应构造,业务代码不能手工创建。
### 真机能力分层
+31 -18
View File
@@ -19,15 +19,20 @@
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
使用 Python 3.11+ / uiautomator2 / PySide6。
详见 [`03-tech-stack.md`](03-tech-stack.md)
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
- 生产代码:`admin/` 已有登录/会话、手工 DRAFT 建单、批量开始采购与一次性授权、任务详情、内部
原始截图、设备身份,以及已授权任务的原子领取/幂等重放/租约续期;
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 80 项离线单元测试
(全部 mock,不连接真机)
- 数据:SQLite v2 单趟核心表与领域状态机已落成,旧 `spec_trials`、`authorized_unit_price` 和
两趟状态已由受保护迁移移除;无业务实例数据。T-203 可以基于新模型实现批量“开始采购”与
一次性授权事务。
绑定 PKG110 / Android 16 / 拼多多 8.17.0 的证据工具,以及受控规格入口、精确规格选择、SKU 当前价
读取、原始截图和一次安全退出能力;并已具备严格 localhost claim/renew/evidence HTTP、Windows DPAPI
凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。原生桌面主界面、
显式本地配置、停止/重启会话边界和安全轮询协调器已实现;未注入单趟执行 consumer 时保持零领取。
真机能力与桌面界面仍待最终人工验收,数量/确认页/提交仍未开放。
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
采购工具 232 项离线单元测试(全部 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 入口
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
并且不覆盖低版本环境;成功后打印真实启动命令。
@@ -39,20 +44,22 @@
分别取证后才接入生产单趟流程。T-103 的 v5 派生 XML 已由人确认无实际地址/
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
规格选择与读价;T-204 将直接上传内部原始截图供管理员查看。T-010 已允许不依赖真机字段的 T-201
和只创建 `DRAFT` 的 T-202 并行。T-209 已完成 schema / 状态机单趟迁移;T-203 服务端
“开始采购”授权事务可立即推进。T-205 起实际 attempt / 真机字段继续等待 T-103。
规格选择与读价;T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并
收紧为精确唯一“快要抢光”子节点及证据绑定可点击祖先,“免拼购买”仍为硬拒绝。离线实现和 112 项
测试已通过;T-103 只差人把手机置于 PDD 安全起点后完成精确规格恢复、读取 `12.88` 和一次安全退出。
完整事件链必须等待 T-107 与 T-210:先让 Gate2/Gate3 证据进入真实 schema,再实现 T-205 事件,
不用 mock 或无证据成功事件抢跑。
## 当前目录要点
| 路径 | 状态 | 说明 |
| --- | --- | --- |
| `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 采购执行 / 配置;均已人工确认 |
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开/规格面板只读取证、确定性证据脱敏、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/安全退出;数量、确认页和提交未开放 |
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
## 任务状态
@@ -68,11 +75,17 @@
- 已完成 T-010(安全并行门禁)、T-201(管理员登录与会话)与 T-202(手工 DRAFT 建单和
基础列表)。T-202 已通过主 agent 独立审查、竞态测试与完整门禁并合入主线,仍只创建/展示
`DRAFT`,未实现授权、设备领取或采购执行。
- 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为
`DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交;
内部原始截图上传交给 T-204。admin 方向已完成 T-209,正在转入 T-203“开始采购”授权事务。
- 已完成 T-209:SQLite v2 已迁移为单趟授权、采购尝试和提交围栏模型,并删除旧试选领域模型;
迁移和状态机护栏已通过完整门禁。下一项采购服务任务为 T-203 批量“开始采购”与一次性授权。
- 已完成 T-110、T-111、T-203、T-204、T-301 与 T-302。服务端已能授权、认证设备、原子领取、
稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。
- T-103 离线 `SkuSelectionFlow` 已实现并通过独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107
按数量/确认页风险逐段开放。服务端后续顺序已收紧为 T-210 → T-205 → T-206 → T-207 → T-208。
- T-303 已完成并通过三重审查;T-304 的轮询会话、配置页和固定双 Tab 主界面已通过两轮独立代码审计,
合并后 232 项客户端测试与根门禁通过,继续保持 `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
View File
@@ -76,7 +76,8 @@
2. 开始采购授权:授权 id、授权人、锁定任务版本、创建/有效期、当前状态。
3. 设备执行:attempt、设备、App 版本、步骤时间线和失败 code。
4. 三道闸门:两次规格面板单价、规格/数量读回、确认页总额与判定。
5. 内部截图:规格面板、确认页和结果页;只经受保护端点读取。
5. 内部截图:三道闸门已批准的规格面板/确认页证据;只经受保护端点读取。MVP 不存在提交后结果
截图类型,不展示外部支付页,也不把 Gate3 图片冒充结果图。
6. 提交围栏:submission id、是否首次明确许可、唯一点击和调和记录。
7. 待付款收口:明确写系统尚未付款;人工核对后记录完成。
@@ -122,10 +123,12 @@ T-204 只显示数据库中当前实际存在的任务、授权、attempt、subm
### 配置 tab
- 采购服务 URL、设备 token(密码框,不回显完整值);
- 采购服务 URL、设备 token(密码框,不回显完整值;首次必填,已有凭据时留空表示保留,非空表示替换);
- ADB 路径、设备 serial、USB/WiFi 通道选择;
- 轮询间隔与连续失败停止阈值;
- 连接检查:服务、设备身份、拼多多安装和已取证版本。
- 只做本地字段与路径格式检查,并提示服务身份、设备连接、拼多多安装和已取证版本将在首次真实领取/
后续已批准真机流程中验证。配置页不调用 `claim-next`、renew、evidence 等业务 API,也不运行 ADB/PDD
作为“连接检查”;MVP 不提供清除已保存设备凭据的命令。
配置凭据进入系统安全存储;日志和界面不显示完整 token。
+50 -29
View File
@@ -6,7 +6,7 @@ deps: [T-103]
status: TODO
created: 2026-08-04
vikunja_task_id: 32
context_ref: 9a4d11f
context_ref: 3a0a41d
work_branch: task/t-104-safe-exit
needs_device: true
needs_human_review: true
@@ -21,62 +21,83 @@ write_paths:
- 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 单趟流程中的安全退出边界。
- 架构/API:复用 `SkuSelectionFlow.exit_sku_panel_safely()`;不改变服务端 API。
- 依赖:T-103 完成真机规格选择/读价/人眼安全退出验收后才开工。
- 架构/API:复用并收紧 `SkuSelectionFlow.exit_sku_panel_safely()` 与现有 runner;不改变服务端 API,不新建第二套采购流程。
- 依赖:T-103 完成真机规格选择、读价、原始截图和人工安全退出验收后才可开工;T-105 与 T-306 消费本任务结果。
- `status` 保持 `TODO`,`needs_device` 与 `needs_human_review` 均保持 true;开始后也必须由人完成真机验收。
## 方案
1. 先新增窄取证脚本:只在已验证 PDD 8.17.0 目标规格面板上发送一次 Back,随后采集本机截图/XML/App/设备/goods_id 元数据;不得打开确认页、设置数量或点击任何页面控件。
2. 由人确认 post-exit 截图确为 goods_id `937122477375` 商品详情、规格面板已关闭、未进入确认页/外部支付页,并确认截图/XML 对应;`needs_device=true`,agent 保持 DOING。
3. 从本项目 post-exit XML 提取只含判据节点的最小 fixture。成功判据必须稳定命中该商品详情的证据绑定正结构;可复用 T-103 的“快要抢光”五层入口链,但必须由新的 post-exit 证据再次验证。
4. 收紧 `exit_sku_panel_safely()`:版本和前台包正确、节点树仅含 PDD 受控页面、目标商品详情正判据精确唯一并连续稳定;面板仍在、SystemUI/锁屏、PDD 其他页、确认/提交/支付语义、入口缺失/重复或结构漂移均不得记成功。
5. Back 最多发送一次。超时或响应不明不重试;故障调和也不能产生第二次 Back。失败不得发布 `safe_exit=completed` manifest。
6. 复用 T-103 runner/flow,不新建第二套采购流程;可增加 post-exit 本机原始截图和无页面正文摘要,完整 XML 仍只留本机,不上传、Git 或日志。
1. 阶段 A 只新增窄取证脚本。人先把手机停在已验证的拼多多 8.17.0、goods_id `937122477375` 目标规格面板;脚本必须先在内存中核验版本、PDD 前台、已知面板结构、目标颜色/尺码选中态与已取证当前价,任一不符时零动作、零产物。
2. 阶段 A 唯一状态改变是一次命名 Android Back。脚本不得发送商品 intent,不得调用任何页面 `click`,不得点击关闭坐标、空白、购买、数量、确认、提交或支付控件;Back 调用前即封存本次机会,超时、响应不明或故障调和均不得重试。
3. Back 返回后只读采集 post-exit screenshot/XML/App/设备/goods_id 摘要并原子发布本机证据。此阶段 manifest 只能声明 `human_review_required` 与 `back_attempts=1`,人工确认前绝不能写 `safe_exit=completed`,也不得由 agent 根据“面板消失”自行判定成功。
4. 人必须确认 post-exit 截图确为同一 goods_id `937122477375` 商品详情、规格面板已关闭、未进入确认/提交/支付或外部支付页、未创建订单,且截图/XML 对应。只有完成该确认后,阶段 B 才能从本项目 post-exit XML 提取不含个人信息或支付凭据的最小 `product_exit_8_17_0.xml` fixture。
5. T-103 的旧 `product_entry_8_17_0.xml` 只有通用“快要抢光”五层入口链和“免拼购买”,不能单独证明是同一商品。它最多作为新证据中的候选结构,必须由本轮 post-exit 证据重新验证,并与新证据里的同商品唯一正锚共同成立;不得把 Activity、旧 XML、前序项目、OCR、裸坐标、相似文本或其他购买文案当作同商品证明。
6. 如果新的真实 XML 没有可唯一绑定同一目标商品的正锚,任务开始后保持 `DOING` 并记录等待新证据,不编造 selector、不把通用入口链升级为同商品判据,也不放宽为“任意 PDD 商品页”。
7. 阶段 B 只收紧现有 Flow/runner:版本和前台包正确、节点树属于证据绑定的 PDD 受控页面、同一商品详情正判据精确唯一,并连续两次轮询命中后才算退出成功。两次之间发生漂移必须重新计数,超时仍失败。
8. 空 hierarchy、SystemUI、锁屏、非 PDD overlay、PDD 其他页、面板仍在、正锚缺失/重复/属性漂移、版本或前台漂移、确认/提交/支付危险语义均不得成功,且不得发布 completed manifest。危险词只能作为保守硬拒绝;没有 T-106/T-107 真机证据时不得把合成反例宣称为真实确认页或支付页分类器。
9. 所有正常、失败、超时和 finally 调和路径的 Back 总数都必须小于等于 1;Back 结果不明不重试。只有规格选择、闸门一读价、规格面板截图和严格安全退出全部成功,runner 才能原子发布结果与 `safe_exit=completed`。
10. `SkuSelectionRunResult` 必须显式返回规格面板原始 `screenshot_path` 和 UTC-aware `captured_at`。`captured_at` 在截图 RPC 成功附近获取,并把同一个值写入 manifest,禁止在退出后重新猜时间。若本机另存 post-exit 截图,必须使用独立文件名和独立角色;它没有 `SKU_PANEL_GATE_1` 资格,不能成为 `result.screenshot_path`,也不能被 T-306 上传。
11. 原始完整 post-exit XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入 Vikunja 或普通日志;runner/CLI 不输出页面正文、设备 serial 或敏感路径。
## 验收要点
- 真机证据记录路径、截图/XML hash、PKG110、Android 16、Wi-Fi/USB、拼多多 8.17.0 和 goods_id;只有人能完成页面对应性与无订单创建确认。
- 离线测试覆盖成功稳定详情页,以及面板未退、SystemUI/锁屏、PDD 其他页、确认/提交/支付页、入口缺失/重复/漂移、版本/前台漂移。
- 所有失败分支 Back 总数最多 1;超时不重试;失败不发布 completed manifest。
- 静态证明没有数量、确认页导航、提交订单、围栏或支付能力。
- client 全量单测、compileall、完整 init、上下文校验与 diff-check 通过。
- 阶段 A 证据记录 screenshot/XML 路径与 SHA-256、PKG110、Android 16、Wi-Fi/USB、拼多多 8.17.0、goods_id 和 `back_attempts=1`;人工确认前没有 `safe_exit=completed`。
- 用新的 `product_exit_8_17_0.xml` 正例替换旧“空 hierarchy 即成功”的 fake;空 hierarchy 必须失败。成功需连续两次正判据,单次命中后漂移必须重置。
- 离线覆盖面板未退、SystemUI/锁屏、非 PDD overlay、PDD 其他页、正锚缺失/重复/漂移、版本/前台漂移和保守危险语义反例;所有分支 Back 调用总数小于等于 1,失败无 target、manifest 或 staging 残留。
- runner 成功结果的 `screenshot_path` 精确指向规格面板原始 PNG,`captured_at` 是 UTC-aware 且与 manifest 同值;post-exit 图不能冒充 Gate1。
- 静态 API、AST 和导入检查证明取证脚本零 intent/页面 click,T-104 不可达数量、确认页导航、授权、提交围栏、`submit_order_once()` 或任何支付能力。
- 只有人能确认真实最终页面为同一商品详情、未进入确认/提交/支付页且未创建订单;agent 完成离线实现后仍保持 `DOING`,不得自行标 `DONE`。
- client focused/full unittest、compileall、完整 init、上下文校验、Vikunja 导出检查与 diff-check 全部通过。
## 执行记录
等待 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 -->
## 边界
- T-104 必须等待 T-103 完成人工真机验收后才可转 `DOING`。本任务先取证、后写退出成功判据;不得从
当前实现、前序项目、旧 XML、Activity 名或推理直接声明商品详情页。
- 取证动作只允许在已证明为拼多多 8.17.0、goods_id `937122477375` 的目标规格面板上发送一次
Android Back,随后只读采集截图/XML/App/设备摘要。不得点击关闭坐标、空白处、购买、数量、确认、
提交或支付控件,也不得在 Back 失败或结果不明时重试。
- “规格面板已消失”不是退出成功。成功必须由本项目 post-exit 真机证据证明:版本和前台包正确、节点树
仅属于 PDD 受控页面、同一目标商品详情正判据精确唯一并连续稳定;SystemUI/锁屏、PDD 其他页面、
确认页、提交页、外部支付页、入口缺失/重复或结构漂移一律停止且不得发布 completed manifest。
- 可以复用 T-103 已取证的“快要抢光”五层入口链作为候选结构,但必须在新的 post-exit 截图/XML 上
重新验证并由人确认页面对应性;不得使用相似文本、包含/前缀、OCR、裸坐标或其他购买文案兜底。
- 阶段 A 取证脚本必须先在内存中核验拼多多 8.17.0、goods_id `937122477375` 的已知目标规格面板、
目标颜色/尺码选中态和已取证当前价。唯一状态改变是一次命名 Android Back;不得发送 intent,不得调用
页面 `click`,不得点击关闭坐标、空白、购买、数量、确认、提交或支付控件。Back 调用前即封存机会,
超时、响应不明和 finally 调和均不得重试。
- 阶段 A 在 Back 后只能只读采集 post-exit screenshot/XML/App/设备摘要并原子发布本机证据;manifest
只能声明 `human_review_required` 与 `back_attempts=1`。人工确认前绝不能写 `safe_exit=completed`,
agent 也不得把“面板消失”或“前台包仍是 PDD”自行解释为安全退出。
- 人确认 post-exit 截图确为同一目标商品详情、面板已关闭、未进入确认/提交/支付或外部支付页、未创建
订单,且截图/XML 对应后,才允许从本项目 XML 提取最小 `product_exit_8_17_0.xml` fixture。
- 旧 `product_entry_8_17_0.xml` 只有通用“快要抢光”五层入口链和“免拼购买”,不能单独证明同一
商品;它最多是新证据中的候选结构,必须与新证据的同商品唯一正锚共同成立。若真实 XML 没有唯一
正锚,任务开始后保持 `DOING` 等待新证据,不臆造 selector、不放宽为任意 PDD 商品页。
- “规格面板已消失”不是退出成功。阶段 B 成功必须由本项目 post-exit 证据证明:版本和前台包正确、
节点树属于证据绑定的 PDD 受控页面、同一目标商品详情正判据精确唯一,并连续两次轮询命中;中间漂移
必须重新计数。空 hierarchy、SystemUI/锁屏、非 PDD overlay、PDD 其他页、面板仍在、正锚缺失/
重复/属性漂移、版本或前台漂移一律失败且不得发布 completed manifest。
- 确认/提交/支付文字只能作为保守硬拒绝。T-106/T-107 真机取证前,不得把合成反例宣称为真实确认页、
提交页或支付页分类器;未知页面只能 fail closed,不能加同义、包含、OCR、裸坐标或 Activity 兜底。
- 只收紧现有 `SkuSelectionFlow` 与 runner 的退出后置条件,不新建第二套采购流程,不新增通用 `click`、
数量、确认页导航、授权、提交围栏、创建待付款订单或支付能力。第一趟与任何真实提交函数继续静态隔离。
- 原始完整 XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入
Vikunja 或日志。Git 中最小 fixture 只能保留退出判据所需结构,并由人确认不含地址、手机号或支付凭据。
- 所有正常、失败、超时和 finally 调和路径的 Back 总数都必须小于等于 1;Back 结果不明不重试。
只有规格选择、闸门一读价、规格面板截图和严格安全退出全部成功,runner 才能原子发布结果与
`safe_exit=completed`;旧的空 hierarchy success 测试必须改成失败反例。
- 供 T-306 消费的 `SkuSelectionRunResult` 必须显式携带规格面板原始 `screenshot_path` 与截图 RPC
成功附近取得的 UTC-aware `captured_at`,并把同一个时间值写入 manifest。不得枚举目录或读取
manifest 猜文件/时间;post-exit 商品详情截图必须使用独立文件名和角色,绝不能冒充 Gate1 证据。
- `needs_device: true`:agent 不得自行标 `DONE`。只有人确认 post-exit 截图为同一目标商品详情、面板
已关闭、未进入确认/提交/支付页、未创建订单,且截图/XML 对应后,任务才可完成。
- 本任务不实现、不调用 `set_quantity_and_readback()`、`go_to_order_confirm()`、
`create_submission_fence()`、`submit_order_once()`、支付、免密支付、先用后付或任何扣款能力;既有
三道价格闸门、服务端围栏、只点一次且不重试的约束不得放宽。
+82
View File
@@ -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。只有人确认真机数量、规格、两次价格、
截图和安全退出,且未进入确认/提交/支付页、未创建订单,才可完成任务。
+82
View File
@@ -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。只有人确认四组证据对应、一次返回安全、未点击最终提交、
未创建订单且未进入支付相关页面,任务才可完成。
+87
View File
@@ -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、一次安全
返回,且未点击最终提交、未创建订单、未进入支付相关页面,任务才可完成。
+98
View File
@@ -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 代码。
+78
View File
@@ -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()` 或任何支付、免密支付、先用后付和扣款能力。
+85
View File
@@ -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 调和、
真机动作、点击“提交订单”或任何支付、免密支付、先用后付和扣款能力。
+107
View File
@@ -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()`,不点击“提交订单”,不编写支付、免密支付、先用后付或扣款能力。
+78
View File
@@ -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 页面判据,不实现事件、围栏、结果、
提交订单点击或任何付款能力。
+105
View File
@@ -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
View File
@@ -3,7 +3,7 @@ id: T-301
title: 设备凭据与身份隔离(F-013)
phase: 3
deps: [T-201, T-204]
status: DOING
status: DONE
created: 2026-08-04
vikunja_task_id: 33
context_ref: a6ad560
@@ -25,7 +25,7 @@ write_paths:
- 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 适配。
@@ -69,6 +69,10 @@ T-204 已提供设备认证注入点,但生产实现固定拒绝全部设备
### 2026-08-04T12:06:14Z · ila
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 -->
## 边界
+112
View File
@@ -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`。
+133
View File
@@ -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`、提交订单点击、支付、免密支付、先用后付或任何扣款控件代码。
+127
View File
@@ -0,0 +1,127 @@
---
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-04T18:02:28Z sha256=ad0773479601411e07157a5745bfd0644f756a8ebaec74e081a77fa5f6cace63 -->
## 问题 / 背景
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。
<!-- 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。
+92
View File
@@ -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 安全返回且没有创建订单。
+95
View File
@@ -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、规格/数量/确认页动作、事件、失败上报、
提交围栏或结果接口;不编写或引用点击“提交订单”的代码,不编写支付或任何扣款能力。
+78
View File
@@ -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,不实现规格、数量、确认页、围栏或结果动作;
不编写或引用点击“提交订单”的代码,不编写支付、免密支付、先用后付或任何扣款能力。
+87
View File
@@ -0,0 +1,87 @@
---
id: T-308
title: 客户端提交围栏与结果 sink
phase: 3
deps: [T-208, T-303, T-307]
status: TODO
created: 2026-08-04
vikunja_task_id: 49
context_ref: 8600c33
work_branch: task/t-308-submission-sink
needs_device: false
needs_human_review: true
write_paths:
- docs/tasks/T-308.md
- client/src/cmbuyer_client/submissions/**
- client/src/cmbuyer_client/core/**
- client/src/cmbuyer_client/remote/**
- client/src/cmbuyer_client/localstate/**
- client/tests/submissions/**
- client/tests/core/**
- client/tests/remote/**
- client/tests/localstate/**
---
<!-- BEGIN VIKUNJA EXPORT id=49 synced=2026-08-04T14:51:43Z sha256=ac3851bab830634e65029a7a80985946cb3aa5bf80bff1727e3e7e0fc8c30ad5 -->
## 问题 / 背景
T-303 只提供 claim/renew/evidence 的严格 HTTP、本地 DPAPI/SQLite 与恢复底座,T-307 只实现围栏前 events/fail,二者都明确禁止伪造 fence/result。T-208 将冻结服务端 CREATE_ONCE、LOOKUP_ONLY、一次性 result 与同一 submission 调和语义。T-308 补齐客户端提交围栏与结果 sink,并把许可、点击意图和结果恢复事实写入 T-303 的同一 localstate;本任务不连接真机、不接 UI、不执行或暴露任何点击。
## 关联需求与交互
- 功能:F-007、F-011、F-017。
- 用户故事:US-004、US-005、US-008。
- 交互:IX-011;只实现后台契约和恢复状态,不实现界面。
- 依赖:T-208、T-303、T-307。
- 后续消费者:T-400 离线单次提交安全编排。
- API:CREATE_ONCE / LOOKUP_ONLY submission fence 与一次性 submission result;严格消费 T-208 最终 wire。
## 方案
1. 定义窄 SubmissionSink:create_submission_fence、lookup_submission_fence、prepare_click_intent、report_submission_result。proof/result 值对象只含 T-208 固定 UUID、claim 归属、规范十进制金额、正整数、三闸门 AssetRef、精确 submit 文本/计数和固定 observation;不接收自由页面文本、XML、路径、selector、坐标或动作脚本。
2. HTTP 必须复用 T-303 的精确 loopback、禁代理/重定向、有界请求/响应和严格 JSON 解析。每次方法最多一次传输,不在 transport 或 sink 内隐藏重试。
3. 只扩展 T-303 的同一 SQLite/localstate,不建立第二个数据库。首次网络前原子保存 fence_key、operation、完整规范 proof、active claim 绑定和预期状态;DPAPI、SQLite 或归属校验失败时零 HTTP。
4. CREATE_ONCE 结果不明时只允许以原 fence_key、原 proof 调用 LOOKUP_ONLY。不得换 key、再次 CREATE_ONCE 或根据本地页面猜测围栏是否存在;LOOKUP_ONLY 找到或未找到都不得产生可点击许可。
5. 只有当前进程当前 CREATE_ONCE 调用收到首次、明确、完整且严格核对的 click_permitted=true 响应,并已把 submission 与许可事实 durable 落盘后,才由模块私有工厂返回 opaque SubmissionPermit。响应截断、协议漂移、落盘失败、重放、LOOKUP_ONLY 或 click_permitted=false 均不返回 permit。
6. SubmissionPermit 不提供公共构造器,不可 pickle/JSON/复制,不写入 SQLite、日志或异常。SQLite 只保存围栏和恢复审计事实;重启后无论数据库记录什么都不能重建可点击 permit,只能进入同一 submission 的调和路径。
7. prepare_click_intent 必须消费当前进程唯一 permit,在返回一次性 ArmedSubmission 前原子写入“即将发出唯一点击”。同 permit 二次调用、并发调用、active claim 漂移或已有点击意图均拒绝;重启只看到 reconcile-only 事实,不能重新 arm。
8. result_key、固定 observation 与 submission/attempt/冻结归属在首次 result HTTP 前原子保存。结果不明和重启只能重放原 key、原载荷;明确成功后保存服务端终态。同一 submission 不得创建第二个 observation 或把 UNKNOWN 改写成 SUBMITTED。
9. 401、403、409、非法 2xx、本地状态错误按 T-303 分类停止;网络、超时、5xx 只表示原幂等 HTTP 可调和,绝不表示 fence、permit、点击或页面动作可重试。
10. 构造和 import 闭包不包含 pdd、device、uiautomator2、UI、execution、app、通用 click、SubmitOrderPort、submit_order_once 或任何 payment 能力。
## 验收要点
- 覆盖首次 CREATE_ONCE、同键重放、LOOKUP_ONLY 找到/未找到、响应截断/漂移、落盘失败、401/403/409/503;只有首次明确且 durable 成功返回 opaque permit。
- 覆盖并发围栏、并发 prepare_click_intent、同 permit 二次消费、进程重启和 SQLite 恢复;任一非首次路径均无法获得或重建 permit/ArmedSubmission。
- 证明 fence/result 首次网络前已持久化完整键、载荷与 frozen claim 绑定;不明结果只重放原 HTTP 事实,不产生新 key 或第二 observation。
- 证明 SQLite/WAL、日志、异常和 DTO 不含明文 token、页面正文、XML、本机路径、selector、坐标或可点击对象。
- 静态 import/AST 检查证明 pdd/device/UI/app/execution、真机 click、submit_order_once 和任何付款能力不可达。
- client 全量 unittest、compileall、wheel metadata、完整 init、上下文、Vikunja export 与 diff-check 通过。
- needs_human_review:人工审阅 opaque permit 的构造边界、首次许可与重启不可重建证明;确认前不得标 DONE。
## 执行记录
(暂无)
<!-- END VIKUNJA EXPORT -->
## 边界
- 本任务只实现 T-208 已冻结的 `CREATE_ONCE`、`LOOKUP_ONLY` 与一次性 result 客户端契约,
并复用 T-303 同一 transport/localstate。不得另建 HTTP 客户端、SQLite、DPAPI 封装或第二份
权威恢复状态;本地与服务端事实冲突时只能停止并调和。
- 只有当前进程当前 `CREATE_ONCE` 调用收到首次、明确、完整且已 durable 落盘的
`click_permitted=true` 响应,才可由模块私有工厂产生 opaque `SubmissionPermit`。同键重放、
`LOOKUP_ONLY`、响应不明、解析/落盘失败、并发输家和服务重启均不得产生许可。
- `SubmissionPermit` 与一次性 `ArmedSubmission` 不得公开构造、序列化、复制、写入 SQLite 或从
恢复快照重建。SQLite 只能保存围栏、点击意图与调和事实;进程退出后即使尚不能确认是否点击,
也只能进入 `RECONCILE_ONLY`,不能重新 arm。
- fence/result 的 key、完整规范载荷和 frozen claim 绑定必须先原子持久化再发送。HTTP 结果不明
只能重放原 key、原载荷;不得换 key、再次 `CREATE_ONCE`、覆盖 observation 或把 HTTP 可恢复
错误解释为页面动作可重试。
- 本任务不得导入或修改 `client/src/cmbuyer_client/pdd/**`、`device/**`、`ui/**`、`execution/**`
或 `app.py`,不得连接真机、持有 selector/坐标/节点/点击对象,也不得实现 `SubmitOrderPort`、
`submit_order_once()` 或任何通用/具体点击。
- 本任务不编写支付、免密支付、先用后付或任何扣款能力。外部支付、安全校验与未知结果只能作为
T-208 固定 observation 保存并转调和,不能继续交互、释放围栏、重新授权、重新领取或再次提交。
- `needs_human_review: true`:自动验证完成后仍保持 `DOING`,等待人审 opaque permit 构造边界、
首次许可唯一性和重启不可重建证明;未经明确确认不得标 `DONE`。
+93
View File
@@ -0,0 +1,93 @@
---
id: T-400
title: 离线单次提交安全编排
phase: 4
deps: [T-305, T-308]
status: TODO
created: 2026-08-04
vikunja_task_id: 50
context_ref: 8600c33
work_branch: task/t-400-offline-submission-orchestrator
needs_device: false
needs_human_review: true
write_paths:
- docs/tasks/T-400.md
- client/src/cmbuyer_client/execution/submission_orchestrator.py
- client/tests/execution/test_submission_orchestrator.py
---
<!-- BEGIN VIKUNJA EXPORT id=50 synced=2026-08-04T15:04:36Z sha256=4f27bf3ca322f467cb742da1b9159cbb976f6939c0cd804021323a24fa4cc735 -->
## 问题 / 背景
T-305 的正常 dry-run 会在同一设备会话完成三闸门与最终控件只读观察,随后执行一次安全 Back,并以固定 reason DRY_RUN_COMPLETED_BEFORE_FENCE 终止 attempt。它只证明围栏前代码和判据契约已验收,绝不产生可供真实提交复用的运行时 ReadyForSubmission。T-308 将提供严格 fence/result sink、当前进程唯一 permit 和不可恢复的点击意图能力。T-400 只用测试 fixture 验证纯 SinglePassSubmissionOrchestrator、四条件、一次性调用和崩溃闭包;不读取或复用 T-305 的对象、截图、证据、journal、缓存或旧 proof,不提供生产 ReadyForSubmission 构造入口,也不接入 app/UI/PDD/真机。
## 关联需求与交互
- 功能:F-006、F-007、F-011、F-017 的离线提交安全闭包。
- 用户故事:US-003、US-005、US-008。
- 交互:IX-011 的后台顺序;本任务无生产界面。
- 依赖:T-305、T-308。T-305 只提供已验收的代码/判据契约,T-308 提供冻结接口;两者都不向本任务提供可提交的运行时事实。
- 后续消费者:T-401 在全新真实任务、全新 attempt 的同一趟内重跑全部三闸门并即时构造生产 ReadyForSubmission。
- 生产 app 的 import/call graph 在 T-401 前继续不可达本任务编排、ReadyForSubmission 生产构造和 SubmitOrderPort。
## 方案
1. 在单一窄 execution 模块实现纯 SinglePassSubmissionOrchestrator、只读 ReadyForSubmission 协议和抽象 SubmitOrderPort;只消费 T-308 已冻结的 SubmissionSink 接口,不修改 submissions/remote/localstate/core/PDD/UI/app。
2. T-305 的 PreFenceDryRunOrchestrator、终止结果、action journal、AssetRef、截图、缓存、页面对象、attempt 或 proof 不能作为 ReadyForSubmission 输入。T-305 已 Back 且终止的 attempt 永远不能申请 fence、取得 permit 或进入提交编排。
3. 本任务不提供任何生产 ReadyForSubmission 具体类、公共构造器、factory、adapter 或 from_dry_run/from_snapshot/from_evidence/from_localstate 路径。只有 tests 中的 immutable fixture 实现该只读协议;生产包内不存在把真实或旧事实装配成 ReadyForSubmission 的入口。
4. 测试 fixture 必须显式给出一个虚构但内部一致的同一 task/authorization/attempt/generation/App 版本事实:未消费授权快照、Gate2/Gate3 通过、精确规格/数量/金额、三个受控 AssetRef 和提交文本/唯一计数。它只用于纯逻辑测试,不是页面证据或可发布业务事实。
5. 编排在申请 fence 前重新校验 fixture 表达的四条件:一次性授权/claim 一致且未过期、Gate2 通过、Gate3 通过、submit 文本精确且唯一计数为一。任一缺失、漂移、金额不规范或版本不一致均零 fence、零 SubmitOrderPort 调用。
6. 只有测试注入的 T-308 当前进程首次明确 SubmissionPermit 可继续。先调用 prepare_click_intent durable 写入唯一点击意图并取得一次性 ArmedSubmission;未成功 arm、重放许可、LOOKUP_ONLY、并发进入或重启恢复均零 SubmitOrderPort 调用。
7. SubmitOrderPort 只暴露 submit_once(ArmedSubmission, VerifiedSubmitObservationBoundary)。编排层最多调用一次,不提供 retry、循环、回退、第二调用点或可重复 submit 函数;本任务只注入记录次数/故障点的 fake port,没有具体真机适配器。
8. 调用前已经进入不可逆 reconcile-only 状态。fake port 返回、抛错、超时、进程停止或结果不明都不能回到可提交状态;异常只形成 UNKNOWN 待调和事实,不再次调用 port。
9. fake port observation 只允许 SUBMITTED、EXTERNAL_PAYMENT_HANDOFF、SECURITY_CHALLENGE、UNKNOWN。编排通过 T-308 固定接口只上报一次 result;HTTP 不明只恢复原 key/载荷,不重复页面观察或提交动作。本任务不实现或测试任何基于真实页面的 SUBMITTED 推断。
10. 停止轮询、Esc、查看历史、关闭窗口和重启不是编排的取消/重试输入;它们不能清 permit、点击意图、submission 或 result pending,也不能释放、重新授权、重新领取或推进下一任务。
11. T-401 前生产 composition/import/call graph 必须同时证明:不可达 SinglePassSubmissionOrchestrator/SubmitOrderPort;不存在生产 ReadyForSubmission factory;T-305 dry-run 结果类型不可流入提交编排。
## 验收要点
- 参数化负例逐一移除授权/首次许可、Gate2、Gate3 或唯一控件,证明 fake SubmitOrderPort 调用数均为 0。
- 首次明确 permit + durable 点击意图的纯测试用例证明 fake port 调用数恰好为 1;并发、二次进入、permit 重放和重复 result 均不能增加计数。
- fake port 在调用中或返回解析时抛错/超时,结果均为 reconcile-only/UNKNOWN,调用数仍恰好为 1 且无 retry。
- 崩溃点覆盖 fence 响应前后、permit 落盘前后、点击意图前后、fake port 调用前后和 result 上报前后;恢复全部零 SubmitOrderPort 调用。
- 固定测试至少包含 test_missing_each_submission_condition_never_calls_port、test_first_permit_calls_port_exactly_once、test_port_exception_never_retries、test_restart_after_click_intent_is_reconcile_only、test_t305_runtime_artifacts_are_not_accepted、test_no_production_ready_for_submission_factory、test_production_app_import_closure_cannot_reach_submission。
- AST/import/call-graph 证明生产代码不存在 ReadyForSubmission 构造入口,T-305 已终止结果/截图/缓存/proof 到编排无数据流,app/UI/PDD/device/真实 click/payment 不在本任务闭包。
- 任务 diff 只能修改窄 execution 实现/测试;T-308 submissions/remote/localstate/core 接口保持冻结且零 diff。
- client 全量 unittest、compileall、wheel metadata、完整 init、上下文、Vikunja export 与 diff-check 通过。
- needs_human_review:人工审阅 T-305 零运行时复用、生产 factory 不存在、一次性 fake port 调用和全部崩溃点;确认前不得标 DONE。
## 执行记录
(暂无)
<!-- END VIKUNJA EXPORT -->
## 边界
- T-305 正常 dry-run 已执行一次安全 Back,并以 `DRY_RUN_COMPLETED_BEFORE_FENCE` 终止 attempt。
本任务只能复用其已验收的代码/判据契约;不得接收或复用该次/任何 dry-run 的结果对象、attempt、
screenshot、AssetRef、action journal、缓存、页面对象、本地快照或旧 proof 来申请围栏或提交。
- 本任务只在单一窄 execution 文件实现纯 `SinglePassSubmissionOrchestrator`、只读
`ReadyForSubmission` 协议和抽象 `SubmitOrderPort`;仅测试 fixture 可实现/构造该协议。生产代码
不得存在具体 `ReadyForSubmission` 类、公共构造器、factory、adapter、反序列化或
`from_dry_run` / `from_snapshot` / `from_evidence` / `from_localstate` 路径。
- 本任务只消费 T-308 已冻结接口,不得修改 `submissions/**`、`remote/**`、`localstate/**` 或
`core/**`。不得修改或导入 `app.py`、`ui/**`、`pdd/**`、`device/**`,不得连接真机、使用
uiautomator2 或实现任何具体页面点击适配器。
- 测试 fixture 表达的四条件必须属于同一虚构 task/authorization/attempt/generation 和 App 版本:
未消费授权与首次明确围栏许可、Gate2、Gate3、精确唯一提交控件。它只验证纯逻辑,不是可复用的
页面或业务事实;缺失、漂移、金额/规格/数量冲突均零 fake `SubmitOrderPort` 调用。
- 编排必须先通过 T-308 durable 写入唯一点击意图,再把当前进程的一次性 `ArmedSubmission` 交给
fake `SubmitOrderPort.submit_once()`。该方法在纯测试生命周期最多调用一次;不得存在 retry、循环、
fallback、第二调用点或异常后重新进入路径。
- 点击意图落盘后即进入 `RECONCILE_ONLY`。fake port 返回、抛错、超时、停止、关闭或进程崩溃都不得
回到可提交状态;结果不明确只能形成 `UNKNOWN` 并调和同一 submission,不能再次调用 port、重新
申请许可、释放围栏、重新授权或重新领取。
- 本任务不实现真实页面结果判据。fake fixture/port 的 `SUBMITTED` 只用于协议分支测试,不能成为
生产页面事实;不得从 cmroubao/cmpdd、旧版本、页面语义或人工经验推断真实 `SUBMITTED`。
- T-401 前 production composition/import/call graph 必须同时不可达本任务编排/`SubmitOrderPort`,
不存在生产 `ReadyForSubmission` 构造入口,并证明 T-305 终止结果到提交编排无数据流。应用启动、
Esc、停止轮询、查看历史和关闭窗口均保持零提交调用。
- T-401 才能领取一条全新已授权真实任务,在新的同一趟 attempt 中重新执行全部实时闸门并即时构造
生产 `ReadyForSubmission`。本任务不提供人工提交入口,不编写支付、免密支付、先用后付或扣款能力。
- `needs_human_review: true`:自动验证后仍保持 `DOING`,等待人审 T-305 零运行时复用、生产 factory
不存在、一次性 fake port 调用和全部崩溃点;未经明确确认不得标 `DONE`。
+104
View File
@@ -0,0 +1,104 @@
---
id: T-401
title: 首次真实提交与生产接线
phase: 4
deps: [T-400, T-402]
status: TODO
created: 2026-08-04
vikunja_task_id: 52
context_ref: 8600c33
work_branch: task/t-401-first-real-submission
needs_device: true
needs_human_review: true
write_paths:
- docs/tasks/T-401.md
- client/src/cmbuyer_client/pdd/order_submission.py
- client/src/cmbuyer_client/execution/**
- client/src/cmbuyer_client/ui/**
- client/src/cmbuyer_client/app.py
- client/tests/pdd/test_order_submission.py
- client/tests/execution/**
- client/tests/ui/**
- client/tests/test_app.py
---
<!-- BEGIN VIKUNJA EXPORT id=52 synced=2026-08-04T15:06:03Z sha256=4ec2adedf5a6ab06ece40e32e6619992fbf8cc071a95977a80a796b6b1cd9d98 -->
## 问题 / 背景
T-107 已把订单确认页、闸门三与精确唯一“提交订单”控件限制为证据/App 版本绑定的只读能力,T-308 将只在当前进程首次明确围栏许可后产生不可恢复 permit,T-400 只用测试 fixture 证明四条件与一次性编排。T-305 的真实 dry-run 已安全 Back 并以 DRY_RUN_COMPLETED_BEFORE_FENCE 终止 attempt,只能提供已验收代码/判据契约,任何运行对象、截图、缓存或 proof 都不能提交。T-401 必须领取一条新的已授权真实任务,在全新 attempt 的同一设备会话中从打开商品开始实时重跑 Gate1、数量/Gate2、确认页/Gate3,并在仍停留于当前确认页时即时构造生产 ReadyForSubmission,随后才可申请围栏和精确点击一次。系统只创建可能的待付款订单,绝不付款;首次提交后页面无本项目证据,因此点击后无条件上报 UNKNOWN 并由 T-403 调和。
## 关联需求与交互
- 功能:F-005、F-006、F-007、F-011、F-017。
- 用户故事:US-003、US-004、US-005、US-008。
- 交互:IX-008、IX-011;执行员没有人工“提交订单”按钮。
- 依赖:T-400、T-402;T-400 传递 T-305/T-308 的已验收契约但不传递任何运行时 ReadyForSubmission,T-402 传递 T-403 并保证点击后已有安全调和与待付款收口。
- 需要真机和人工安全验收;agent 只能实现、离线验证和准备命令,不得自行触发真实提交或标 DONE。
## 方案
1. 生产执行只接受本次新领取的 PENDING + ACTIVE 授权任务,由服务端创建新的 active attempt/generation。已有 DRY_RUN_COMPLETED_BEFORE_FENCE、FAILED、FENCED、ABANDONED 或其它结束 attempt,以及 T-305 的 task/attempt id 都必须在任何页面动作和 fence 前拒绝。
2. 在一个不中断的全新设备会话中按固定顺序执行:打开本次任务 canonical 商品 → 受控入口/精确选规格 → Gate1 当前 SKU 单价 → 设置并读回数量 → 重读规格与 Gate2 单价 → 进入订单确认页 → Gate3 规格/数量/总额 → 只读确认精确唯一最终控件。每一步都使用当前 attempt 的实时事实和新证据,不能跳步或使用历史值。
3. 生产 ReadyForSubmission 构造入口首次且仅在 T-401 composition 内出现:只有上述当前步骤全部成功、当前 claim/authorization/attempt/generation/App 版本仍一致、三个 AssetRef 均属于本次 attempt,并且仍停留在刚验证的确认页时,才即时构造不可持久化的临时对象交 T-400 编排。
4. ReadyForSubmission 不写入 SQLite、文件、缓存、日志或恢复快照,不提供 from_dry_run/from_snapshot/from_evidence/from_localstate。构造后页面 Back/导航、claim/lease/授权变化、App 版本/前台包变化、证据或任务版本漂移都立即使其失效并零 fence/零点击;不能重新加载或跨重启恢复。
5. 静态和运行边界显式拒绝 T-305 PreFenceDryRunResult、DRY_RUN_COMPLETED_BEFORE_FENCE attempt、dry-run screenshot/AssetRef、action journal、缓存页面对象和旧 proof。T-305 已 Back 的页面不能“返回去继续”,必须领取新任务从商品打开开始。
6. 在生产 composition root 接入 T-400 SinglePassSubmissionOrchestrator、T-308 SubmissionSink 与极窄 PDD SubmitOrderPort。UI 只显示自动阶段和安全状态,不提供执行员可点击的提交、重试、释放围栏或继续按钮。
7. 具体 SubmitOrderPort 只接受当前进程一次性 ArmedSubmission 和本次 T-107 判据生成的 VerifiedSubmitObservationBoundary。点击前在当前确认页再次执行精确文本、唯一可点击祖先、前台包与 App 版本检查;不得接受任务下发 selector/坐标、通用 click、相似文案、OCR 或旧项目常量。
8. T-308 必须已 durable 写入“即将发出唯一点击”且本地状态进入 RECONCILE_ONLY,才允许调用底层真机点击。低层点击语句在生产调用图中恰好一个调用点、执行恰好一次;不能放在循环、重试器、finally、恢复回调或可重复 signal handler 中。
9. 四条件任一缺失、页面实时复核漂移、围栏响应不明、许可不是首次、持久化失败、App/设备/claim 状态变化均零点击并停止。停止轮询、Esc、查看历史、关闭窗口、超时、异常和进程重启都不得重构 ReadyForSubmission、重新 arm、重做页面动作或再次点击。
10. 点击调用返回、抛错或超时后不再与页面交互。首次真实运行没有提交后页面判据,客户端必须无条件以固定 UNKNOWN 上报同一 submission,任务进入 RECONCILIATION_REQUIRED;即使人看到待付款页,也不得由客户端推断或改报 SUBMITTED。
11. T-401 不采集、持久化或上传点击后的截图/XML,不新增 SUBMISSION_RESULT evidence kind,不把 Gate3 图冒充结果图。尤其不得截取或上传外部支付页、支付凭据、安全校验页面;点击后结果只由管理员在 T-403 调和同一 submission。
12. UNKNOWN result HTTP 不明时只由 T-308 用原 result_key、原载荷调和;不得重复观察页面、生成新 key、重新提交、释放围栏、重新授权、重新领取或推进下一任务。
13. 首次真实运行必须使用项目所有者专门创建并授权的低风险新任务,在 T-305 dry-run 和全部离线门禁通过后,由项目所有者在执行记录中给出针对本次运行的一次性明确批准。额外批准只是首次发布验收,不新增产品内第二授权按钮。
14. 真实点击后由人检查手机和采购服务,只通过 T-403 选择 CONFIRMED_CREATED、CONFIRMED_NOT_CREATED_OR_CANNOT_COMPLETE 或 STILL_UNKNOWN。系统不支付、不自动取消外部订单;人负责说明订单是否产生及如何处置。
## 验收要点
- 测试证明只有新领取、未结束且当前归属一致的 attempt 能开始;T-305/DRY_RUN_COMPLETED_BEFORE_FENCE、FAILED、FENCED、ABANDONED、旧 generation 和跨 task/authorization 均零页面动作、零 fence、零点击。
- 动作/事件序列严格证明本次新 attempt 在同一会话执行商品打开→Gate1→数量/Gate2→确认页/Gate3→即时 ReadyForSubmission→fence→点击;不得出现引用 T-305 截图、缓存、AssetRef、journal 或 proof。
- ReadyForSubmission factory 仅 T-401 production composition 可达,输入只来自本次内存实时结果;AST/数据流测试证明无 dry-run/storage/cache/restart factory,Back/导航/版本/claim 漂移后对象失效。
- 离线 fake-device/port 测试覆盖四条件缺一、控件 0/2 个、文本/版本/前台包漂移、非首次 permit、点击意图落盘失败、并发进入、停止/关闭/重启,所有负例底层点击数为 0。
- 唯一成功调用测试证明点击意图已先落盘、底层点击调用数恰好为 1;底层返回、异常和超时后再次进入/重启的累计点击数仍为 1,没有 retry/fallback。
- 点击后的所有离线分支都只生成 UNKNOWN;静态测试禁止 SUBMITTED 推断、提交后截图/XML/evidence 上传,以及页面观察或第二次点击。
- AST/import/call graph 证明只有一个生产低层提交点击点,且支付、免密支付、先用后付、扣款控件、人工提交按钮和通用 click 不可达。
- 运行 client 全量 unittest、compileall、wheel metadata、完整 init、上下文、Vikunja export 与 diff-check;人工放行前不得运行真机提交命令。
- 真机记录必须包含“全新任务/授权/attempt”证明、设备型号、Android、连接方式、拼多多版本、goods_id、task/authorization/attempt/submission id、本次三闸门值/AssetRef、唯一控件计数、fence 首次许可、点击意图先落盘、低层点击恰好一次、UNKNOWN/RECONCILIATION_REQUIRED、T-403 人工结论、订单是否产生/如何处置和“未付款”。不得记录 token、地址/手机号字段、支付凭据或点击后截图/XML。
- 真机运行与最终结论只能由项目所有者明确批准并人工确认;确认前保持 DOING,agent 不得自行执行、推断成功或标 DONE。
## 执行记录
(暂无)
<!-- END VIKUNJA EXPORT -->
## 边界
- 本任务只把 T-107 已取证且绑定拼多多 App 版本的精确唯一最终控件、T-308 当前进程首次明确
`SubmissionPermit` / 一次性 `ArmedSubmission` 和 T-400 离线编排接入生产。不得接收服务端
selector/坐标/动作脚本,不得使用相似文案、OCR、通用 click、旧项目常量或未取证页面能力。
- 每次生产执行必须领取一条新的 `PENDING` + `ACTIVE` 授权真实任务,并由服务端创建新的 active
attempt/generation。在一个全新且不中断的同一设备会话内,严格重新执行商品打开 → Gate1 → 数量
设置/读回与 Gate2 → 确认页/Gate3 → 当前最终控件只读复核;不能从中间页面或旧 attempt 续跑。
- T-305 正常 dry-run 已安全 Back,并以 `DRY_RUN_COMPLETED_BEFORE_FENCE` 终止 attempt。其任何
结果对象、task/attempt id、screenshot/AssetRef、action journal、缓存页面对象、本地快照或旧 proof
永远不得用于生产 `ReadyForSubmission`、申请 fence 或提交;结束/失败/围栏后的其它 attempt 同样拒绝。
- 生产 `ReadyForSubmission` 只能在本次新 attempt 的 Gate3 与唯一控件刚刚实时通过、仍停留当前
确认页且 claim/lease/authorization/generation/App 版本全部一致时即时构造。对象不得持久化、缓存、
序列化或跨导航/Back/重启恢复;任一事实漂移立即失效并零 fence、零点击。
- 只有一次性授权与服务端首次围栏许可、Gate2、Gate3、精确唯一提交控件四条件同时成立,并且
T-308 已 durable 写入点击意图和 `RECONCILE_ONLY`,才可调用低层提交点击。生产调用图中低层
点击恰好一个调用点、运行恰好一次;不得位于循环、retry、fallback、`finally` 或恢复回调。
- 围栏/许可/持久化/页面复核任一失败均零点击。点击调用返回、抛错、超时、停止轮询、Esc、查看
历史、关闭窗口或重启都不得重新 arm、重复页面动作或再次点击,也不得释放围栏、重新授权、
重新领取或推进下一任务。
- 首次真实提交后的页面尚无本项目真机证据。点击后客户端必须无条件上报固定 `UNKNOWN` 并进入
`RECONCILIATION_REQUIRED`;不得解析页面、根据人眼结论推断 `SUBMITTED`,也不得把结果改报为
其他 observation。订单是否产生只由管理员通过 T-403 调和同一 submission。
- 本任务不得采集、持久化或上传点击后的截图/XML,不新增结果 evidence kind,不把 Gate3 图冒充
结果图。尤其不得保存或上传外部支付页、支付凭据或安全校验页面。
- 采购工具不得提供人工“提交订单”、重试提交、释放围栏或继续执行按钮。UI 只能显示后台自动阶段、
submission id 和“可能已创建订单,禁止再次提交,只能人工调和”的安全状态。
- 系统不点击支付、免密支付、先用后付或任何扣款控件,不自动取消外部订单。真实订单是否产生、
是否付款和如何处置均由人负责;`WAITING_PAYMENT` 不等于成功。
- 真实运行除管理员“开始采购”的业务授权外,还必须由项目所有者在任务执行记录中针对本次首次
发布验收给出明确一次性批准。agent 不得自行运行真实提交命令、确认真机结论或标 `DONE`;
`needs_device` 与 `needs_human_review` 未同时由人确认前必须保持 `DOING`。
+101
View File
@@ -0,0 +1,101 @@
---
id: T-402
title: 待付款人工收口与付款事实记录
phase: 4
deps: [T-403]
status: TODO
created: 2026-08-04
vikunja_task_id: 51
context_ref: 8600c33
work_branch: task/t-402-payment-closure
needs_device: false
needs_human_review: true
write_paths:
- docs/tasks/T-402.md
- admin/migrations/00009_payment_closure.sql
- admin/internal/migrations/migrations_test.go
- admin/internal/domain/payment_closure.go
- admin/internal/domain/payment_closure_test.go
- admin/internal/paymentclosure/**
- admin/internal/taskdetail/**
- admin/internal/server/mark_paid.go
- admin/internal/server/mark_paid_test.go
- admin/internal/server/router.go
- admin/internal/server/task_detail.go
- admin/internal/server/task_detail_test.go
- admin/internal/transport/webui/webui.go
- admin/internal/transport/webui/templates/task-detail.html
- admin/cmd/server/**
- admin/README.md
- docs/api.md
- docs/04-architecture.md
- docs/routes.md
- docs/08-interaction-checklist.md
- docs/current-state.md
---
<!-- BEGIN VIKUNJA EXPORT id=51 synced=2026-08-04T15:03:09Z sha256=c9d2806cefd7ce0cbbb96bd1b764eb54ff713cfb25715daa3581933c2f74ec4c -->
## 背景
T-208 已冻结创建待付款订单后的 `WAITING_PAYMENT` 事实:仅当提交记录为 `SUBMITTED`,或管理员调和结论为 `CONFIRMED_CREATED` 时,系统才知道订单已创建。T-403 负责展示这些既有事实。本任务只记录管理员已经在拼多多人工完成真实付款这一外部事实,并把任务收口为成功;系统本身没有付款,也不得验证或自动执行付款。
## 关联
- Feature:F011
- Story:US006
- Integration:IX006
- 前置任务:T-403
- 后续消费者:T-404
## 实现方案
1. 新增唯一迁移 `00009_payment_closure.sql`,建立追加式 `payment_closures`:绑定同一 lineage 的 task、submission、attempt、authorization,以及管理员 actor、服务端 UTC 记录时间、幂等键与规范化请求摘要。唯一约束保证同一任务/提交只能收口一次,并保证同一管理员幂等键不可复用为不同请求;外键/约束必须阻止跨 lineage 事实。不得保存付款方式、账户、卡号、订单凭据、地址、手机号或截图/XML。migration down 只允许在 `payment_closures` 为空时执行;存在任意事实必须在删除表或约束前原子拒绝,禁止丢失付款审计事实。
2. 在任务详情的 `WAITING_PAYMENT` 区域明确显示“订单已创建,系统尚未付款”,同时展示商品、规格、数量、金额上限,以及 T-208/T-403 已有提交、调和和三道闸门证据。不得暗示系统已付款。
3. 管理员必须先在拼多多外部人工完成真实付款,再完成三个且仅三个确认组:`goods_spec_quantity_confirmed` 表示商品/规格/数量已核对,`amount_within_cap_confirmed` 表示订单金额未超过授权上限,`externally_paid_confirmed` 表示管理员已在拼多多人工完成真实付款。三项 wire 值必须全部严格为 `true`;任一缺失、为 `false` 或无法严格解析均返回 400 且零写入。按钮文案明确为“记录人工付款并标记完成(不执行付款)”。
4. `POST /tasks/{id}/mark-paid` 仅接受管理员会话与 CSRF,通过严格表单读取 UUIDv4 幂等键、expected task version、当前 submission id 和上述三个固定布尔确认组,不接受别名或额外确认字段。actor 只能来自会话,时间只能来自服务端 UTC。
5. 在单一事务和写锁内重新读取 task、submission、attempt、authorization:四者必须属于同一 lineage;任务必须正好是 `WAITING_PAYMENT`,submission 必须正好是该任务当前唯一提交且关联同一 attempt/authorization,authorization 必须正好是 `CONSUMED`。submission 必须为 `SUBMITTED`,或为已有 T-208 `CONFIRMED_CREATED` 最终调和事实的 `MANUAL_RESOLVED`。旧 task version、错误/过期 submission 固定返回 409 且零写入;任一 lineage、authorization、submission 或 task 状态冲突也必须返回 409、零写入,并让详情进入只读安全核查,绝不能推进为 `SUCCEEDED`。
6. 所有检查通过后,同一事务追加 payment closure,并将任务从 `WAITING_PAYMENT` 单向推进到 `SUCCEEDED`、version + 1。submission、attempt、authorization 和已消费的提交围栏永久不可变,不得重开、释放、重置、重新授权或重新提交。
7. 幂等性必须先查事实:同 key、同规范化 payload 在响应丢失和进程重启后稳定重放同一结果;同 key、不同 payload 返回 409;不同 key 在任务已收口后返回 `payment_already_recorded` 409。规范化 payload 必须包含 task、submission、expected version 和三个固定确认组。不得仅因任务已是 `SUCCEEDED` 就伪造成功响应。
8. 成功页/详情页显示记录 actor、服务端时间和“仅记录人工付款事实,系统未执行付款”。失败保留用户输入并给出可操作提示,焦点回到错误摘要;事实冲突时只显示安全核查,不提供重试提交、释放或重新授权动作。
9. 自动化测试只使用本地数据库夹具构造 `WAITING_PAYMENT` 和既有提交事实,覆盖事务、冲突、幂等、重启与 UI;不得连接拼多多、请求真实资金、模拟付款控件或要求测试人员实际付款。`needs_human_review` 只验收语义、权限和交互,不代表测试中发生真实付款。
## 验收
- 三个且仅三个 wire 确认组固定为 `goods_spec_quantity_confirmed`、`amount_within_cap_confirmed`、`externally_paid_confirmed`;三项全部严格为 `true` 才能继续,逐项覆盖缺失、`false`、非法值和额外别名,均返回 400 且零写入。
- 只有管理员、有效 CSRF、精确 task version、精确当前 submission、同 lineage 的 task/submission/attempt/authorization 和 `CONSUMED` authorization 能写入;旧版本和错误/过期 submission 稳定返回 409 且零写入。
- 覆盖 `SUBMITTED` 与 `CONFIRMED_CREATED` 两条允许路径,以及跨 task/attempt/authorization、authorization 非 `CONSUMED`、其它任务/提交状态和事实冲突;冲突均返回 409、零写入、进入只读安全核查且绝不 `SUCCEEDED`。
- 覆盖并发双击、同键同载荷重放、同键异载荷冲突、不同键重复收口和进程重启;事实表和任务状态在同一事务提交或回滚。
- migration 覆盖 up、重开、外键/唯一/lineage/append-only 约束、空表 down 成功、存在任意 payment closure 时 down 在删表前原子拒绝,以及 `foreign_key_check`。
- 页面显著显示“系统尚未付款”和“不执行付款”,键盘操作、焦点、错误摘要、安全核查态及移动宽度可用。
- 自动测试不需要真实付款,不访问手机或拼多多;代码不得包含任何支付、免密、先用后付或扣款控件操作。
- 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、前端交互检查、根目录初始化门禁、上下文校验、导出校验和 `git diff --check`。
## 执行记录
(暂无)
<!-- END VIKUNJA EXPORT -->
## 边界
- `mark-paid` 只记录管理员已经在拼多多外部人工完成真实付款的事实;系统没有执行付款,也不得发起、
验证、模拟或自动化支付,不得读取或保存付款方式、账户、卡号、订单凭据、地址或手机号。
- 三个且仅三个确认组固定为 `goods_spec_quantity_confirmed`、`amount_within_cap_confirmed`、
`externally_paid_confirmed`;三项 wire 值必须全部严格为 `true`。任一缺失、为 `false`、非法值或
别名返回 400 且零写入;旧 task version 与错误/过期 submission 固定返回 409。
- 只允许精确处于 `WAITING_PAYMENT` 的任务和精确当前 submission 收口。事务必须确认 task、
submission、attempt、authorization 属于同一 lineage,authorization 正好为 `CONSUMED`;
submission 必须是 `SUBMITTED`,或已有 T-208 `CONFIRMED_CREATED` 最终调和事实。任一归属、
状态或事实冲突都返回 409、零写入并进入只读安全核查,绝不能推进为 `SUCCEEDED`。
- actor 只能取自已认证管理员会话,记录时间只能取服务端 UTC。幂等事实必须在任何状态短路前检查:
同 key 同规范化载荷稳定重放,同 key 异载荷返回 409;不同 key 重复收口返回
`payment_already_recorded` 409,不得仅因任务已是 `SUCCEEDED` 就伪造成功响应。
- payment closure 事实与 task `WAITING_PAYMENT → SUCCEEDED`、version + 1 必须在同一事务内原子
提交或回滚。submission、authorization 和已消费围栏永久不可变,不得重开、释放、重置、重新授权、
重新领取或重新提交。
- 本任务唯一新增 migration 是 `00009_payment_closure.sql`;事实表只保存同一 lineage 的 task、
submission、attempt、authorization、actor、服务端时间、幂等键和请求摘要,不保存截图、XML、
支付凭据或新的 evidence kind。`payment_closures` 为空时才允许 migration down;存在任意事实时
必须在删表或约束前原子拒绝,禁止丢失付款审计事实。
- 自动化测试只使用本地数据库夹具,不访问手机或拼多多,不请求真实资金,也不要求测试人员真实付款。
`needs_human_review` 只验收管理 UI 的未付款语义、权限、确认和无障碍交互。
- 本任务不修改 `client/` 或拼多多页面判据,不点击支付、免密支付、先用后付或任何扣款控件。
+83
View File
@@ -0,0 +1,83 @@
---
id: T-403
title: 失败分类、证据归档与管理员调和视图
phase: 4
deps: [T-208]
status: TODO
created: 2026-08-04
vikunja_task_id: 48
context_ref: 8600c33
work_branch: task/t-403-failure-reconciliation-view
needs_device: false
needs_human_review: true
write_paths:
- docs/tasks/T-403.md
- admin/internal/taskdetail/**
- admin/internal/server/task_detail.go
- admin/internal/server/task_detail_test.go
- admin/internal/server/task_failure_view_test.go
- admin/internal/transport/webui/webui.go
- admin/internal/transport/webui/templates/task-detail.html
- admin/README.md
- docs/routes.md
- docs/08-interaction-checklist.md
- docs/current-state.md
---
<!-- BEGIN VIKUNJA EXPORT id=48 synced=2026-08-04T14:51:01Z sha256=f3ddd60f4b212c01b488f92a91148bfcb299c20cc8f8084f198971c18b3a4b49 -->
## 问题 / 背景
T-208 将冻结三闸门、submission fence、一次性 result 和管理员调和事务,T-206 提供状态详情底座。采购管理员仍需要在同一任务详情中看懂围栏前固定失败、围栏后结果不明、已有证据和人工调和历史,并只调用 T-208 已有的同一 submission 调和接口。T-403 只完成失败分类/证据归档事实的展示和调和 UI,不新增失败码、数据库事实或第二套调和事务。
## 关联需求与交互
- 功能:F-007、F-011、F-017 的管理展示与人工调和入口。
- 用户故事:US-002、US-004、US-008。
- 交互:IX-004、IX-009、IX-011。
- 依赖:T-208;由其传递 T-205 固定事件/失败码、T-210 三类证据、T-206 状态详情和 T-207 恢复边界。
- API:只消费 T-208 既有 POST /order-submissions/{sid}/reconcile;不新增或重复实现 reconcile store/事务。
- 后续消费者:T-402 待付款人工收口、T-404 MVP 验收。
## 方案
1. 扩展任务详情只读模型,按同一 task/authorization/attempt/submission 展示 T-205 已保存的固定 failure code、失败阶段、最后安全步骤、事件时间线、执行摘要及 Gate1/Gate2/Gate3 内部证据;不复制、删除、重命名或重新归类原始事实。
2. failure code 只使用 T-205/T-208 已有版本化 allowlist。UI 可为现有码提供固定中文解释和安全下一步,但必须同时显示原始 code;未知 code 显示“未知失败码/停止处理”,不得猜测、合并、改写或映射成另一个码。
3. 围栏前 NEEDS_MANUAL 只展示失败原因、证据和 T-207 已计算的安全恢复资格;T-403 不实现新的重置/取消事务,也不让查看证据触发恢复。
4. 围栏后以 order_submission 为唯一主线,显示 submission id、三闸门 proof、首次许可事实、result observation、append-only reconciliation records 和当前结论。task.status 与 submission 冲突时以 submission 围栏事实优先并进入安全核查。
5. RECONCILIATION_REQUIRED 页面只渲染 T-208 已冻结的 CONFIRMED_CREATED、CONFIRMED_NOT_CREATED_OR_CANNOT_COMPLETE、STILL_UNKNOWN 三种结论,并把管理员 session、CSRF、submission id、expected task version 和幂等键提交到既有 reconcile handler。
6. T-403 不解析或重放 T-208 事务结果,不在 UI 层直接改 task/authorization/attempt/submission。响应不明时冻结原幂等键和原表单,只允许重放既有 reconcile 请求;不生成新结论或乐观更新终态。
7. 围栏后任何状态都持续显示“可能已创建订单,只能调和同一提交,禁止再次提交”,并彻底隐藏重试下单、重置、取消、释放、重新授权、重新领取和设备继续动作。
8. 内部 evidence 继续通过受保护 no-store 端点延迟加载;只展示已批准的 Gate1/Gate2/Gate3 PNG。没有 SUBMISSION_RESULT kind,不展示或上传外部支付页、支付凭据、完整 XML、路径或页面全文。
9. 完整页与抽屉共享同一模板/事实;调和表单具备可见 label、错误关联、焦点恢复、44px 目标、aria-live、响应不明提示和 reduced-motion。只读终态不渲染表单。
## 验收要点
- 覆盖每个既有固定 failure code、未知 code、最后安全步骤、三类 evidence、空证据和关联冲突;测试只断言显示,不改写原始码。
- 覆盖 NEEDS_MANUAL、RECONCILIATION_REQUIRED、WAITING_PAYMENT、FAILED 及 submission/task 状态冲突;任何 submission 存在时均无围栏前恢复或再次提交入口。
- 三种调和结论只调用注入的 T-208 既有 handler/store 一次;T-403 没有第二套 SQL、migration、状态机或直接数据库写入。
- 同幂等键响应不明重放保持原载荷;异载荷、旧版本、错 submission 的错误按 T-208 原结果展示,不乐观改变页面状态。
- 完整页/抽屉、键盘、焦点、加载/失败/空态、权限、CSRF、HTML escaping、no-store evidence 和移动宽度通过测试与人工 UI 评审。
- 静态检查确认没有新 failure/reason 常量、migration、reconcile transaction、client/PDD、提交点击、付款或扣款能力。
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、相关前端静态测试、完整 init、上下文、Vikunja export 与 diff-check 通过。
## 执行记录
(暂无)
<!-- END VIKUNJA EXPORT -->
## 边界
- 本任务只展示 T-205/T-208 已有失败、事件、三闸门 evidence、submission 与 reconciliation 事实,
并把管理员表单提交到 T-208 既有 reconcile handler/store;不得新增或改写 failure/reason code、
migration、状态机、调和事务或直接数据库写入。
- 原始 failure code 必须始终可见;固定中文解释只能附加在已有 allowlist 上。未知 code 必须安全失败并
显示“未知失败码/停止处理”,不得猜测、合并或映射为另一个 code。
- 任一 submission 的存在都优先于 task.status。围栏后不得显示或提供重试下单、重置、取消、释放、
重新授权、重新领取、再次提交或设备继续动作;只能查看和调和同一 submission。
- evidence 只读取 T-210 已批准的 Gate1/Gate2/Gate3 内部 PNG,不上传、删除、重命名或重新归类资产,
不新增结果截图 kind,不展示外部支付页、支付凭据、完整 XML、文件路径或页面全文。
- 调和结论只能是 T-208 已冻结的 `CONFIRMED_CREATED`、
`CONFIRMED_NOT_CREATED_OR_CANNOT_COMPLETE`、`STILL_UNKNOWN`。响应不明时只允许用原幂等键和
原载荷重放既有请求,不得生成新结论或乐观更新终态。
- 本任务不修改 `client/` 或拼多多页面判据,不连接真机,不点击“提交订单”,不编写支付、免密支付、
先用后付或任何扣款能力。

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