Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8600c33391 | ||
|
|
8092431208 | ||
|
|
cd47d0c959 | ||
|
|
ec42640123 | ||
|
|
5f060f60ee | ||
|
|
19f81a5e58 | ||
|
|
c1cee49fb1 | ||
|
|
772e379fd6 | ||
|
|
83b244ff7c | ||
|
|
d9a31cbfa6 | ||
|
|
014405466a | ||
|
|
8c50e1579e | ||
|
|
35d7ce11d6 | ||
|
|
41e54e313e | ||
|
|
e5de76503f | ||
|
|
542b2283f4 | ||
|
|
eb29bcd8b7 | ||
|
|
526af1eb31 | ||
|
|
d9381a644b | ||
|
|
e863a29641 | ||
|
|
5c97954fa1 | ||
|
|
bd4c855ad2 | ||
|
|
ee90d76893 | ||
|
|
4a65138fd7 | ||
|
|
8ec740dcef | ||
|
|
590c84660a | ||
|
|
233188297a | ||
|
|
e674b7f131 |
@@ -13,6 +13,8 @@
|
||||
| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
|
||||
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
|
||||
| `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 |
|
||||
| `CMBUYER_CLAIM_TOKEN_SECRET` | claim token 专用 32 字节密钥的 64 位小写十六进制;不得复用 session 或设备 token。 |
|
||||
| `CMBUYER_CLAIM_LEASE_TTL` | 正 Go duration,且必须严格短于 `CMBUYER_AUTHORIZATION_TTL`。 |
|
||||
|
||||
示例仅展示变量名,不提供可运行凭据:
|
||||
|
||||
@@ -26,6 +28,8 @@ $env:CMBUYER_AUTHORIZATION_TTL = '10m'
|
||||
$env:CMBUYER_MAX_TASK_QUANTITY = '99'
|
||||
$env:CMBUYER_MAX_TOTAL_PRICE = '999.99'
|
||||
$env:CMBUYER_EVIDENCE_DIR = '<内部截图绝对目录>'
|
||||
$env:CMBUYER_CLAIM_TOKEN_SECRET = '<64 位小写十六进制随机值>'
|
||||
$env:CMBUYER_CLAIM_LEASE_TTL = '1m'
|
||||
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"cmbuyer/admin/internal/server"
|
||||
evidencestorage "cmbuyer/admin/internal/storage/evidence"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
@@ -52,6 +53,10 @@ func run() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
claimStore, err := taskclaim.NewStore(database, configuration.ClaimTokenSecret, configuration.ClaimLeaseTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(server.Options{
|
||||
AdminUsername: configuration.AdminUsername,
|
||||
@@ -61,6 +66,7 @@ func run() error {
|
||||
TaskDetails: detailStore,
|
||||
Evidence: evidenceStore,
|
||||
DeviceAuthenticator: deviceAuthenticator,
|
||||
TaskClaims: claimStore,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -23,6 +25,8 @@ const (
|
||||
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
|
||||
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
|
||||
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
|
||||
claimTokenSecretEnv = "CMBUYER_CLAIM_TOKEN_SECRET"
|
||||
claimLeaseTTLEnv = "CMBUYER_CLAIM_LEASE_TTL"
|
||||
minimumSecretLength = 32
|
||||
)
|
||||
|
||||
@@ -37,6 +41,8 @@ type Config struct {
|
||||
MaxTaskQuantity int
|
||||
MaxTotalPrice string
|
||||
EvidenceDirectory string
|
||||
ClaimTokenSecret []byte
|
||||
ClaimLeaseTTL time.Duration
|
||||
}
|
||||
|
||||
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
||||
@@ -112,6 +118,27 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
|
||||
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
|
||||
}
|
||||
claimSecretText, err := required(lookup, claimTokenSecretEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
claimSecret, err := hex.DecodeString(claimSecretText)
|
||||
if err != nil || len(claimSecret) != 32 || hex.EncodeToString(claimSecret) != claimSecretText {
|
||||
return Config{}, fmt.Errorf("%s must be exactly 64 lowercase hexadecimal characters", claimTokenSecretEnv)
|
||||
}
|
||||
// Claim ownership, admin sessions and device authentication are separate security domains.
|
||||
// Reject both identical configuration text and identical effective key bytes.
|
||||
if claimSecretText == secret || bytes.Equal(claimSecret, []byte(secret)) {
|
||||
return Config{}, fmt.Errorf("%s must be isolated from %s", claimTokenSecretEnv, sessionSecretEnv)
|
||||
}
|
||||
claimTTLText, err := required(lookup, claimLeaseTTLEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
claimTTL, err := time.ParseDuration(claimTTLText)
|
||||
if err != nil || claimTTL <= 0 || claimTTL >= ttl {
|
||||
return Config{}, fmt.Errorf("%s must be positive and shorter than %s", claimLeaseTTLEnv, authorizationTTLEnv)
|
||||
}
|
||||
|
||||
return Config{
|
||||
AdminUsername: username,
|
||||
@@ -121,6 +148,8 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
DatabaseSource: databaseSource,
|
||||
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
|
||||
EvidenceDirectory: evidenceDirectory,
|
||||
ClaimTokenSecret: claimSecret,
|
||||
ClaimLeaseTTL: claimTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package config_test
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/config"
|
||||
|
||||
@@ -25,13 +26,15 @@ func TestLoad(t *testing.T) {
|
||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
|
||||
"CMBUYER_CLAIM_LEASE_TTL": "1m",
|
||||
}
|
||||
|
||||
got, err := config.Load(lookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure {
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure || len(got.ClaimTokenSecret) != 32 || got.ClaimLeaseTTL != time.Minute {
|
||||
t.Fatalf("Load returned unexpected public configuration: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +54,8 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
|
||||
"CMBUYER_CLAIM_LEASE_TTL": "1m",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -68,6 +73,12 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
{"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"},
|
||||
{"missing evidence directory", func(values map[string]string) { delete(values, "CMBUYER_EVIDENCE_DIR") }, "CMBUYER_EVIDENCE_DIR"},
|
||||
{"relative evidence directory", func(values map[string]string) { values["CMBUYER_EVIDENCE_DIR"] = "evidence" }, "CMBUYER_EVIDENCE_DIR"},
|
||||
{"invalid claim secret", func(values map[string]string) { values["CMBUYER_CLAIM_TOKEN_SECRET"] = strings.Repeat("A", 64) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"claim secret same raw session secret", func(values map[string]string) {
|
||||
values["CMBUYER_SESSION_SECRET"] = values["CMBUYER_CLAIM_TOKEN_SECRET"]
|
||||
}, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"claim secret same decoded session secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = strings.Repeat("\xab", 32) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"invalid claim lease ttl", func(values map[string]string) { values["CMBUYER_CLAIM_LEASE_TTL"] = "10m" }, "CMBUYER_CLAIM_LEASE_TTL"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertVersion(t, database, 5)
|
||||
assertTableExists(t, database, "tasks", true)
|
||||
assertTableExists(t, database, "spec_trials", false)
|
||||
assertTableExists(t, database, "order_authorizations", true)
|
||||
@@ -34,12 +34,20 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
||||
assertTableExists(t, database, "order_submissions", true)
|
||||
assertTableExists(t, database, "evidence_assets", true)
|
||||
assertTableExists(t, database, "device_credentials", true)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", true)
|
||||
assertTableExists(t, database, "single_pass_upgrade_guard", false)
|
||||
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("reapply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 5)
|
||||
|
||||
if err := migrations.Down(context, database, directory); err != nil {
|
||||
t.Fatalf("roll back task claim migration: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", false)
|
||||
assertTableExists(t, database, "device_credentials", true)
|
||||
|
||||
if err := migrations.Down(context, database, directory); err != nil {
|
||||
t.Fatalf("roll back device credential migration: %v", err)
|
||||
@@ -65,7 +73,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("reapply v2 after rollback: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertVersion(t, database, 5)
|
||||
}
|
||||
|
||||
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
|
||||
@@ -84,7 +92,7 @@ func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("upgrade v1 draft: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertVersion(t, database, 5)
|
||||
var got struct {
|
||||
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
|
||||
quantity, version int
|
||||
@@ -235,9 +243,7 @@ func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
|
||||
|
||||
func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
migrateToV3(t, database)
|
||||
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
|
||||
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
|
||||
@@ -271,9 +277,6 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("roll back empty device credential migration: %v", err)
|
||||
}
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("evidence-bearing schema downgraded successfully")
|
||||
}
|
||||
@@ -287,9 +290,7 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
|
||||
func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
migrateToV4(t, database)
|
||||
deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
hash := make([]byte, 32)
|
||||
for index := range hash {
|
||||
@@ -354,6 +355,132 @@ func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskClaimMigrationGuardsOwnershipConstraintsAndDowngradeFacts(t *testing.T) {
|
||||
t.Run("upgrade rejects unmappable execution facts atomically", func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
migrateToV4(t, database)
|
||||
insertV2Task(t, database, "legacy-task", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "legacy-auth", "legacy-task", 1, "legacy-start")
|
||||
insertV2Attempt(t, database, "legacy-attempt", "legacy-task", "legacy-auth", 1)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("v5 upgrade accepted an attempt without device/session ownership")
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", false)
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("legacy attempt after rejected upgrade = %d, err %v", count, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("schema binds authorization device session generation and token", func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
deviceA := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
deviceB := "23c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
sessionA := "33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
sessionB := "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
taskA := "53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
authA := "63c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
attemptA := "73c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
taskB := "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
authB := "93c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
attemptB := "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
tokenA := make([]byte, 32)
|
||||
for index := range tokenA {
|
||||
tokenA[index] = byte(index + 1)
|
||||
}
|
||||
for index, device := range []string{deviceA, deviceB} {
|
||||
hash := make([]byte, 32)
|
||||
hash[0] = byte(index + 100)
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, device, "device "+strconv.Itoa(index), hash, migrationTime); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
}
|
||||
insertV2Task(t, database, taskA, "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, authA, taskA, 1, "start-a")
|
||||
insertV2Attempt(t, database, attemptA, taskA, authA, 1)
|
||||
insertClaim := `INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 2, 'task', 1, 'goods', 'white', 'XL', 1, '1.00',
|
||||
'2026-08-04T01:00:00Z', ?, ?, '2026-08-04T00:05:00Z', ?, NULL)`
|
||||
if _, err := database.Exec(insertClaim, attemptA, taskA, authA, deviceA, sessionA, 1, make([]byte, 32), tokenA, migrationTime); err != nil {
|
||||
t.Fatalf("insert valid claim: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts
|
||||
(id,task_id,authorization_id,claim_generation,status,started_at)
|
||||
VALUES ('b3c9f507-7473-4fa6-8d71-8786c34c6301', ?, ?, 2, 'CLAIMED', ?)`, taskA, authA, migrationTime); err == nil {
|
||||
t.Fatal("second attempt for one authorization succeeded")
|
||||
}
|
||||
|
||||
insertV2Task(t, database, taskB, "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, authB, taskB, 1, "start-b")
|
||||
insertV2Attempt(t, database, attemptB, taskB, authB, 1)
|
||||
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceB, sessionB, 2, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
|
||||
t.Fatal("claim with generation different from its attempt succeeded")
|
||||
}
|
||||
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceA, sessionB, 1, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
|
||||
t.Fatal("second open claim for one device succeeded")
|
||||
}
|
||||
|
||||
claimRequest := `INSERT INTO task_claim_requests
|
||||
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
|
||||
VALUES (?, ?, ?, 'CLAIMED', ?, '2026-08-04T00:05:00Z', NULL, ?)`
|
||||
if _, err := database.Exec(claimRequest, "c3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionB, attemptA, migrationTime); err == nil {
|
||||
t.Fatal("claim request with another session succeeded")
|
||||
}
|
||||
if _, err := database.Exec(claimRequest, "d3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionA, attemptA, migrationTime); err != nil {
|
||||
t.Fatalf("insert bound claim request: %v", err)
|
||||
}
|
||||
renewal := `INSERT INTO purchase_attempt_lease_renewals
|
||||
(renew_request_id,task_id,attempt_id,device_id,session_id,claim_generation,
|
||||
claim_token_sha256,expected_lease_expires_at,lease_expires_at,created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '2026-08-04T00:05:00Z', '2026-08-04T00:06:00Z', ?)`
|
||||
if _, err := database.Exec(renewal, "e3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 2, tokenA, migrationTime); err == nil {
|
||||
t.Fatal("renewal with another generation succeeded")
|
||||
}
|
||||
wrongHash := append([]byte(nil), tokenA...)
|
||||
wrongHash[0] ^= 0xff
|
||||
if _, err := database.Exec(renewal, "f3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 1, wrongHash, migrationTime); err == nil {
|
||||
t.Fatal("renewal with another token hash succeeded")
|
||||
}
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("claim-bearing schema downgraded successfully")
|
||||
}
|
||||
assertVersion(t, database, 5)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", true)
|
||||
})
|
||||
|
||||
t.Run("empty request alone blocks downgrade", func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
device := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'device', ?, 'ACTIVE', ?, NULL)`, device, make([]byte, 32), migrationTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO task_claim_requests
|
||||
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
|
||||
VALUES ('23c9f507-7473-4fa6-8d71-8786c34c6301', ?,
|
||||
'33c9f507-7473-4fa6-8d71-8786c34c6301', 'EMPTY', NULL, NULL, NULL, ?)`, device, migrationTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("EMPTY request was silently dropped by downgrade")
|
||||
}
|
||||
assertVersion(t, database, 5)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -416,6 +543,24 @@ func migrateToV2(t *testing.T, database *sql.DB) {
|
||||
assertVersion(t, database, 2)
|
||||
}
|
||||
|
||||
func migrateToV3(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
migrateToV2(t, database)
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("apply v3: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 3)
|
||||
}
|
||||
|
||||
func migrateToV4(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
migrateToV3(t, database)
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("apply v4: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
}
|
||||
|
||||
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil {
|
||||
|
||||
@@ -134,6 +134,7 @@ func TestRealDeviceCredentialIdentityIsolationAndMixedCredentials(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("issue credential: %v", err)
|
||||
}
|
||||
insertEvidenceClaim(t, database, issued.DeviceID)
|
||||
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
|
||||
if err != nil {
|
||||
t.Fatalf("new authenticator: %v", err)
|
||||
@@ -353,6 +354,8 @@ func newEvidenceRouter(t *testing.T, authenticator deviceauth.Authenticator) (ht
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
insertEvidenceAttempt(t, database)
|
||||
insertEvidenceClaimDevice(t, database, evidenceDeviceID)
|
||||
insertEvidenceClaim(t, database, evidenceDeviceID)
|
||||
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
|
||||
if err != nil {
|
||||
t.Fatalf("new evidence store: %v", err)
|
||||
@@ -384,6 +387,30 @@ func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
func insertEvidenceClaimDevice(t *testing.T, database *sql.DB, deviceID string) {
|
||||
t.Helper()
|
||||
digest := sha256.Sum256([]byte("fake evidence device"))
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'fake evidence device', ?, 'ACTIVE', '2026-08-04T00:00:00Z', NULL)`, deviceID, digest[:]); err != nil {
|
||||
t.Fatalf("insert evidence device: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertEvidenceClaim(t *testing.T, database *sql.DB, deviceID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, '23c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
|
||||
1, '123', 'black', 'M', 1, '1.00', '2026-08-04T00:00:00Z', ?, ?,
|
||||
'2026-08-04T02:00:00Z', '2026-08-04T00:00:00Z', NULL)`, evidenceAttemptID,
|
||||
evidenceTaskID, evidenceAuthID, deviceID, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32)); err != nil {
|
||||
t.Fatalf("insert evidence claim: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
"cmbuyer/admin/internal/transport/webui"
|
||||
@@ -36,11 +37,12 @@ type Options struct {
|
||||
TaskDetails taskdetail.Store
|
||||
Evidence evidence.Store
|
||||
DeviceAuthenticator deviceauth.Authenticator
|
||||
TaskClaims taskclaim.Service
|
||||
}
|
||||
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter(options Options) (*gin.Engine, error) {
|
||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil {
|
||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil || options.TaskClaims == nil {
|
||||
return nil, errors.New("server authentication options are incomplete")
|
||||
}
|
||||
|
||||
@@ -57,6 +59,8 @@ func NewRouter(options Options) (*gin.Engine, error) {
|
||||
router.POST("/tasks", createTask(options))
|
||||
router.POST("/tasks/start-purchases", startPurchases(options))
|
||||
router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(options))
|
||||
router.POST("/api/v1/tasks/claim-next", claimNext(options))
|
||||
router.POST("/api/v1/tasks/:id/lease/renew", renewLease(options))
|
||||
router.GET("/evidence/:asset_id", readEvidence(options))
|
||||
router.GET("/static/tasks.js", func(context *gin.Context) {
|
||||
context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript())
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/server"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
|
||||
@@ -493,6 +494,10 @@ func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Man
|
||||
}
|
||||
|
||||
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) {
|
||||
return newRouterWithClaimService(t, store, details, evidenceStore, deviceAuthenticator, emptyTaskClaimService{})
|
||||
}
|
||||
|
||||
func newRouterWithClaimService(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator, claims taskclaim.Service) (*gin.Engine, *auth.Manager) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
@@ -508,6 +513,7 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
|
||||
TaskDetails: details,
|
||||
Evidence: evidenceStore,
|
||||
DeviceAuthenticator: deviceAuthenticator,
|
||||
TaskClaims: claims,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
@@ -517,6 +523,16 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
|
||||
|
||||
type emptyDetailStore struct{}
|
||||
|
||||
type emptyTaskClaimService struct{}
|
||||
|
||||
func (emptyTaskClaimService) ClaimNext(context.Context, string, taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
|
||||
return taskclaim.ClaimResponse{}, false, nil
|
||||
}
|
||||
|
||||
func (emptyTaskClaimService) Renew(context.Context, string, taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
|
||||
return taskclaim.RenewResponse{}, taskclaim.ErrNotCurrent
|
||||
}
|
||||
|
||||
func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
|
||||
return taskdetail.Detail{}, taskdetail.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
context.JSON(http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
func renewLease(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
principal, ok := authenticateDevice(context, options)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var command taskclaim.RenewCommand
|
||||
if !decodeClaimJSON(context, &command) {
|
||||
return
|
||||
}
|
||||
command.TaskID = context.Param("id")
|
||||
response, err := options.TaskClaims.Renew(context.Request.Context(), principal.ID, command)
|
||||
if err != nil {
|
||||
writeTaskClaimError(context, err)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication precedes path interpretation, Content-Type parsing and every body read. This
|
||||
// keeps rejected devices from using parsing differences as an oracle or making the server buffer data.
|
||||
func authenticateDevice(context *gin.Context, options Options) (deviceauth.Principal, bool) {
|
||||
principal, err := options.DeviceAuthenticator.Authenticate(context.Request)
|
||||
if errors.Is(err, deviceauth.ErrUnauthenticated) {
|
||||
context.Header("WWW-Authenticate", "Bearer")
|
||||
context.Status(http.StatusUnauthorized)
|
||||
return deviceauth.Principal{}, false
|
||||
}
|
||||
if err != nil || !deviceauth.ValidDeviceID(principal.ID) {
|
||||
context.Status(http.StatusServiceUnavailable)
|
||||
return deviceauth.Principal{}, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func decodeClaimJSON(context *gin.Context, target any) bool {
|
||||
if !isJSONContentType(context.GetHeader("Content-Type")) {
|
||||
writeFixedError(context, http.StatusUnsupportedMediaType, "unsupported_media_type")
|
||||
return false
|
||||
}
|
||||
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxClaimJSONBytes)
|
||||
raw, err := io.ReadAll(context.Request.Body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeFixedError(context, http.StatusRequestEntityTooLarge, "request_too_large")
|
||||
} else {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(raw) == 0 || !utf8.Valid(raw) {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
if !hasUniqueTopLevelJSONFields(raw) {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasUniqueTopLevelJSONFields(raw []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
first, err := decoder.Token()
|
||||
if err != nil || first != json.Delim('{') {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for decoder.More() {
|
||||
key, err := decoder.Token()
|
||||
name, ok := key.(string)
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
if _, duplicate := seen[name]; duplicate {
|
||||
return false
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
var value json.RawMessage
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
last, err := decoder.Token()
|
||||
return err == nil && last == json.Delim('}')
|
||||
}
|
||||
|
||||
func writeTaskClaimError(context *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, taskclaim.ErrInvalid):
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
case errors.Is(err, taskclaim.ErrIdempotencyConflict):
|
||||
writeFixedError(context, http.StatusConflict, "idempotency_conflict")
|
||||
case errors.Is(err, taskclaim.ErrRequiresManual):
|
||||
writeFixedError(context, http.StatusConflict, "claim_requires_manual")
|
||||
case errors.Is(err, taskclaim.ErrNotCurrent):
|
||||
writeFixedError(context, http.StatusConflict, "claim_not_current")
|
||||
case errors.Is(err, taskclaim.ErrDeviceInactive):
|
||||
context.Header("WWW-Authenticate", "Bearer")
|
||||
context.Status(http.StatusUnauthorized)
|
||||
default:
|
||||
// Storage and transaction failures are intentionally bodyless: SQL, paths and candidate
|
||||
// details are server-only and must not become a device-facing diagnostic oracle.
|
||||
context.Status(http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFixedError(context *gin.Context, status int, code string) {
|
||||
context.JSON(status, gin.H{"error": code})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"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 TestRenewStrictBindingResponseAndFixedErrors(t *testing.T) {
|
||||
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
||||
service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}}
|
||||
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
||||
body := `{"renew_request_id":"` + claimRenewID + `","session_id":"` + claimSessionID + `","attempt_id":"` + claimAttemptID + `","claim_generation":1,"claim_token":"` + strings.Repeat("a", 64) + `","expected_lease_expires_at":"2026-08-04T01:03:00Z"}`
|
||||
response := serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
|
||||
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "claim_token") || service.renewCommand.TaskID != claimTaskID {
|
||||
t.Fatalf("renew response = %d %q command %#v", response.Code, response.Body.String(), service.renewCommand)
|
||||
}
|
||||
|
||||
duplicateToken := strings.Replace(body, `"expected_lease_expires_at"`, `"claim_token":"`+strings.Repeat("a", 64)+`","expected_lease_expires_at"`, 1)
|
||||
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", duplicateToken, "application/json")
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("duplicate token status = %d", response.Code)
|
||||
}
|
||||
|
||||
errorsToCodes := []struct {
|
||||
err error
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{taskclaim.ErrIdempotencyConflict, http.StatusConflict, `{"error":"idempotency_conflict"}`},
|
||||
{taskclaim.ErrRequiresManual, http.StatusConflict, `{"error":"claim_requires_manual"}`},
|
||||
{taskclaim.ErrNotCurrent, http.StatusConflict, `{"error":"claim_not_current"}`},
|
||||
{taskclaim.ErrDeviceInactive, http.StatusUnauthorized, ""},
|
||||
{errors.New("database path and SQL must stay private"), http.StatusServiceUnavailable, ""},
|
||||
}
|
||||
for _, test := range errorsToCodes {
|
||||
service.renewErr = test.err
|
||||
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
|
||||
if response.Code != test.status || response.Body.String() != test.body {
|
||||
t.Fatalf("error %v = %d %q", test.err, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTaskClaimService struct {
|
||||
claimResponse taskclaim.ClaimResponse
|
||||
claimFound bool
|
||||
claimErr error
|
||||
renewResponse taskclaim.RenewResponse
|
||||
renewErr error
|
||||
claimCommand taskclaim.ClaimCommand
|
||||
renewCommand taskclaim.RenewCommand
|
||||
calls int
|
||||
}
|
||||
|
||||
func (service *fakeTaskClaimService) ClaimNext(_ context.Context, _ string, command taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
|
||||
service.calls++
|
||||
service.claimCommand = command
|
||||
return service.claimResponse, service.claimFound, service.claimErr
|
||||
}
|
||||
|
||||
func (service *fakeTaskClaimService) Renew(_ context.Context, _ string, command taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
|
||||
service.calls++
|
||||
service.renewCommand = command
|
||||
return service.renewResponse, service.renewErr
|
||||
}
|
||||
|
||||
func serveClaimJSON(router http.Handler, path, body, contentType string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodPost, path, io.NopCloser(strings.NewReader(body)))
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
@@ -185,11 +185,16 @@ func (store *Store) Commit(ctx context.Context, principal deviceauth.Principal,
|
||||
return existing, true, nil
|
||||
}
|
||||
|
||||
var attemptCount int
|
||||
if err := transaction.QueryRowContext(ctx, "SELECT COUNT(*) FROM purchase_attempts WHERE task_id = ? AND id = ?", metadata.TaskID, metadata.AttemptID).Scan(&attemptCount); err != nil {
|
||||
var ownedClaimCount int
|
||||
if err := transaction.QueryRowContext(ctx, `SELECT COUNT(*) FROM purchase_attempt_claims
|
||||
WHERE task_id = ? AND attempt_id = ? AND claimed_by_device_id = ? AND closed_at IS NULL`,
|
||||
metadata.TaskID, metadata.AttemptID, principal.ID).Scan(&ownedClaimCount); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if attemptCount != 1 {
|
||||
// Evidence is auditable only when the authenticated device owns the current attempt. The
|
||||
// idempotent asset lookup above deliberately remains first so closing a claim later cannot
|
||||
// destroy stable replay of an already committed screenshot.
|
||||
if ownedClaimCount != 1 {
|
||||
return core.Asset{}, false, core.ErrInvalid
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,41 @@ func TestStageCommitReplayAndOpen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitRequiresCurrentClaimOwnerButClosedClaimKeepsHistoricalReplay(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
metadata := testMetadata(sha256Hex(pngBytes))
|
||||
stage := func() core.StagedFile {
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
return staged
|
||||
}
|
||||
otherDevice := deviceauth.Principal{ID: "73c9f507-7473-4fa6-8d71-8786c34c6301"}
|
||||
if _, _, err := store.Commit(context.Background(), otherDevice, metadata, stage()); !errors.Is(err, core.ErrInvalid) {
|
||||
t.Fatalf("device B upload to device A attempt error = %v", err)
|
||||
}
|
||||
principal := deviceauth.Principal{ID: testDeviceID}
|
||||
asset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
|
||||
if err != nil || replayed {
|
||||
t.Fatalf("owner first Commit = replayed %v, err %v", replayed, err)
|
||||
}
|
||||
if _, err := database.Exec("UPDATE purchase_attempt_claims SET closed_at='2026-08-04T03:00:00Z' WHERE attempt_id=?", testAttemptID); err != nil {
|
||||
t.Fatalf("close claim: %v", err)
|
||||
}
|
||||
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
|
||||
if err != nil || !replayed || replayedAsset.ID != asset.ID {
|
||||
t.Fatalf("closed claim historical replay = %#v replayed %v err %v", replayedAsset, replayed, err)
|
||||
}
|
||||
newMetadata := metadata
|
||||
newMetadata.UploadKey = "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
if _, _, err := store.Commit(context.Background(), principal, newMetadata, stage()); !errors.Is(err, core.ErrInvalid) {
|
||||
t.Fatalf("closed claim new upload error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
@@ -494,6 +529,12 @@ func newTestStore(t *testing.T) (*sql.DB, *Store) {
|
||||
func insertAttemptFixture(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
timestamp := "2026-08-04T00:00:00Z"
|
||||
tokenHash := sha256.Sum256([]byte("evidence-device-token"))
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'evidence device', ?, 'ACTIVE', ?, NULL)`, testDeviceID, tokenHash[:], timestamp); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, testTaskID, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
@@ -503,6 +544,15 @@ func insertAttemptFixture(t *testing.T, database *sql.DB) {
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, testAttemptID, testTaskID, testAuthID, timestamp); err != nil {
|
||||
t.Fatalf("insert attempt: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, '63c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
|
||||
1, '123', 'black', 'M', 1, '1.00', ?, ?, ?, '2026-08-04T02:00:00Z', ?, NULL)`,
|
||||
testAttemptID, testTaskID, testAuthID, testDeviceID, timestamp, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), timestamp); err != nil {
|
||||
t.Fatalf("insert claim: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testMetadata(hash string) core.UploadMetadata {
|
||||
|
||||
@@ -0,0 +1,830 @@
|
||||
package taskclaim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
)
|
||||
|
||||
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.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, 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,
|
||||
&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 || !digitsOnly(goodsID) || color == "" || size == "" || quantity <= 0 ||
|
||||
!canonicalMoney(price) || !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 || strings.TrimSpace(record.TaskTitle) == "" ||
|
||||
!digitsOnly(record.GoodsID) || record.SKUColor == "" || record.SKUSize == "" || record.Quantity <= 0 ||
|
||||
!canonicalMoney(record.TotalPriceCap) || 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 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) {
|
||||
if !validCanonicalTime(responseLease) {
|
||||
return ClaimResponse{}, errors.New("stored claim response lease 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
|
||||
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.expires_at
|
||||
FROM order_authorizations AS authorizations
|
||||
JOIN tasks ON tasks.id = authorizations.task_id
|
||||
WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING'
|
||||
AND authorizations.task_version = tasks.version
|
||||
AND authorizations.goods_id = tasks.goods_id
|
||||
AND authorizations.sku_color = tasks.sku_color
|
||||
AND authorizations.sku_size = tasks.sku_size
|
||||
AND authorizations.quantity = tasks.quantity
|
||||
AND authorizations.total_price_cap = tasks.max_total_price
|
||||
ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`)
|
||||
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.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 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 &&
|
||||
strings.TrimSpace(item.Title) != "" && digitsOnly(item.GoodsID) && item.SKUColor != "" && item.SKUSize != "" &&
|
||||
item.Quantity > 0 && canonicalMoney(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 digitsOnly(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < '0' || character > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func canonicalMoney(value string) bool {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
|
||||
return false
|
||||
}
|
||||
for _, part := range parts {
|
||||
if !digitsOnly(part) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
cents := new(big.Int)
|
||||
_, ok := cents.SetString(parts[0]+parts[1], 10)
|
||||
return ok && cents.Sign() > 0
|
||||
}
|
||||
|
||||
func productURL(goodsID string) string {
|
||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
package taskclaim
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
testDeviceA = "10000000-0000-4000-8000-000000000001"
|
||||
testDeviceB = "10000000-0000-4000-8000-000000000002"
|
||||
testSessionA = "20000000-0000-4000-8000-000000000001"
|
||||
testSessionB = "20000000-0000-4000-8000-000000000002"
|
||||
testTaskA = "30000000-0000-4000-8000-000000000001"
|
||||
testTaskB = "30000000-0000-4000-8000-000000000002"
|
||||
testAuthA = "40000000-0000-4000-8000-000000000001"
|
||||
testAuthB = "40000000-0000-4000-8000-000000000002"
|
||||
testClaimRequestA = "50000000-0000-4000-8000-000000000001"
|
||||
testClaimRequestB = "50000000-0000-4000-8000-000000000002"
|
||||
testClaimRequestC = "50000000-0000-4000-8000-000000000003"
|
||||
testRenewRequestA = "60000000-0000-4000-8000-000000000001"
|
||||
testRenewRequestB = "60000000-0000-4000-8000-000000000002"
|
||||
)
|
||||
|
||||
var testNow = time.Date(2026, 8, 4, 1, 2, 3, 123000000, time.UTC)
|
||||
|
||||
func TestClaimReplayEmptyManualAndSecretRecovery(t *testing.T) {
|
||||
database := openClaimTestDatabase(t)
|
||||
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
||||
insertDevice(t, database, testDeviceB, []byte("device-b"))
|
||||
insertCandidate(t, database, testTaskA, testAuthA, testNow.Add(-time.Minute), testNow.Add(10*time.Minute), true)
|
||||
secret := bytes.Repeat([]byte{0x11}, 32)
|
||||
store := mustStore(t, database, secret, 30*time.Second)
|
||||
store.now = func() time.Time { return testNow }
|
||||
|
||||
command := ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}
|
||||
claimed, found, err := store.ClaimNext(context.Background(), testDeviceA, command)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("ClaimNext = found %v, err %v", found, err)
|
||||
}
|
||||
if claimed.Task.ID != testTaskA || claimed.Task.Version != 3 || claimed.Authorization.ID != testAuthA ||
|
||||
claimed.Authorization.TaskVersion != 2 || claimed.Attempt.ClaimGeneration != 1 ||
|
||||
len(claimed.Attempt.ClaimToken) != 64 || strings.ToLower(claimed.Attempt.ClaimToken) != claimed.Attempt.ClaimToken {
|
||||
t.Fatalf("unexpected claim response: %#v", claimed)
|
||||
}
|
||||
assertClaimState(t, database, 1, "CLAIMED", "CLAIMED")
|
||||
assertNoPlaintextTokenColumnOrValue(t, database, claimed.Attempt.ClaimToken)
|
||||
|
||||
replayed, found, err := store.ClaimNext(context.Background(), testDeviceA, command)
|
||||
if err != nil || !found || !reflect.DeepEqual(replayed, claimed) {
|
||||
t.Fatalf("same request replay = %#v, found %v, err %v", replayed, found, err)
|
||||
}
|
||||
restarted := mustStore(t, database, secret, 30*time.Second)
|
||||
restarted.now = func() time.Time { return testNow.Add(5 * time.Second) }
|
||||
replayed, found, err = restarted.ClaimNext(context.Background(), testDeviceA, command)
|
||||
if err != nil || !found || !reflect.DeepEqual(replayed, claimed) {
|
||||
t.Fatalf("restart replay = %#v, found %v, err %v", replayed, found, err)
|
||||
}
|
||||
if _, err := NewStore(database, bytes.Repeat([]byte{0x22}, 32), 30*time.Second); err == nil {
|
||||
t.Fatal("NewStore accepted a secret that cannot rebuild existing claims")
|
||||
}
|
||||
sameSession, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{
|
||||
SessionID: testSessionA, ClaimRequestID: "50000000-0000-4000-8000-000000000005",
|
||||
})
|
||||
if err != nil || !found || sameSession.Attempt.ID != claimed.Attempt.ID || sameSession.Attempt.ClaimToken != claimed.Attempt.ClaimToken {
|
||||
t.Fatalf("same-session recovery = %#v, found %v, err %v", sameSession, found, err)
|
||||
}
|
||||
if _, err := database.Exec(`UPDATE order_authorizations SET goods_id='937122477376', sku_color='白色',
|
||||
sku_size='L', quantity=3, total_price_cap='40.00', expires_at=? WHERE id=?`,
|
||||
formatTime(testNow.Add(20*time.Minute)), testAuthA); err != nil {
|
||||
t.Fatalf("mutate authorization source: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`UPDATE tasks SET title='漂移标题', goods_id='937122477376', sku_color='白色',
|
||||
sku_size='L', quantity=3, max_total_price='40.00' WHERE id=?`, testTaskA); err != nil {
|
||||
t.Fatalf("mutate task source: %v", err)
|
||||
}
|
||||
afterDrift := mustStore(t, database, secret, 30*time.Second)
|
||||
afterDrift.now = func() time.Time { return testNow.Add(6 * time.Second) }
|
||||
stable, found, err := afterDrift.ClaimNext(context.Background(), testDeviceA, command)
|
||||
if err != nil || !found || !reflect.DeepEqual(stable, claimed) {
|
||||
t.Fatalf("source-drift replay = %#v, found %v, err %v; want original %#v", stable, found, err, claimed)
|
||||
}
|
||||
if _, _, err := afterDrift.ClaimNext(context.Background(), testDeviceA, ClaimCommand{
|
||||
SessionID: testSessionA, ClaimRequestID: "50000000-0000-4000-8000-000000000006",
|
||||
}); !errors.Is(err, ErrRequiresManual) {
|
||||
t.Fatalf("new recovery after source drift error = %v", err)
|
||||
}
|
||||
|
||||
manualCommand := ClaimCommand{SessionID: testSessionB, ClaimRequestID: testClaimRequestB}
|
||||
if _, _, err := store.ClaimNext(context.Background(), testDeviceA, manualCommand); !errors.Is(err, ErrRequiresManual) {
|
||||
t.Fatalf("different session error = %v, want ErrRequiresManual", err)
|
||||
}
|
||||
if _, _, err := store.ClaimNext(context.Background(), testDeviceA, manualCommand); !errors.Is(err, ErrRequiresManual) {
|
||||
t.Fatalf("manual replay error = %v, want ErrRequiresManual", err)
|
||||
}
|
||||
assertClaimState(t, database, 1, "CLAIMED", "CLAIMED")
|
||||
|
||||
emptyCommand := ClaimCommand{SessionID: testSessionB, ClaimRequestID: testClaimRequestC}
|
||||
if _, found, err := store.ClaimNext(context.Background(), testDeviceB, emptyCommand); err != nil || found {
|
||||
t.Fatalf("empty claim = found %v, err %v", found, err)
|
||||
}
|
||||
insertCandidate(t, database, testTaskB, testAuthB, testNow, testNow.Add(10*time.Minute), true)
|
||||
if _, found, err := store.ClaimNext(context.Background(), testDeviceB, emptyCommand); err != nil || found {
|
||||
t.Fatalf("persisted EMPTY replay = found %v, err %v", found, err)
|
||||
}
|
||||
claimedB, found, err := store.ClaimNext(context.Background(), testDeviceB, ClaimCommand{
|
||||
SessionID: testSessionB, ClaimRequestID: "50000000-0000-4000-8000-000000000004",
|
||||
})
|
||||
if err != nil || !found || claimedB.Task.ID != testTaskB {
|
||||
t.Fatalf("new request after EMPTY = %#v, found %v, err %v", claimedB, found, err)
|
||||
}
|
||||
var distinctNonces int
|
||||
if err := database.QueryRow("SELECT COUNT(DISTINCT claim_nonce) FROM purchase_attempt_claims").Scan(&distinctNonces); err != nil || distinctNonces != 2 {
|
||||
t.Fatalf("distinct claim nonces = %d, err %v", distinctNonces, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameSessionOrderingRecoveryNeverClaimsAnotherTask(t *testing.T) {
|
||||
database := openClaimTestDatabase(t)
|
||||
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
||||
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true)
|
||||
insertCandidate(t, database, testTaskB, testAuthB, testNow.Add(time.Second), testNow.Add(10*time.Minute), true)
|
||||
store := mustStore(t, database, bytes.Repeat([]byte{0x21}, 32), time.Minute)
|
||||
store.now = func() time.Time { return testNow }
|
||||
claimed, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA})
|
||||
if err != nil || !found || claimed.Task.ID != testTaskA {
|
||||
t.Fatalf("initial claim = %#v, found %v, err %v", claimed, found, err)
|
||||
}
|
||||
if _, err := database.Exec("UPDATE tasks SET status='ORDERING', version=version+1 WHERE id=?", testTaskA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Exec("UPDATE purchase_attempts SET status='ORDERING' WHERE id=?", claimed.Attempt.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recovered, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestB})
|
||||
if err != nil || !found || recovered.Attempt.ID != claimed.Attempt.ID || recovered.Task.ID != testTaskA {
|
||||
t.Fatalf("ORDERING recovery = %#v, found %v, err %v", recovered, found, err)
|
||||
}
|
||||
var attempts int
|
||||
var taskBStatus string
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&attempts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.QueryRow("SELECT status FROM tasks WHERE id=?", testTaskB).Scan(&taskBStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts != 1 || taskBStatus != "PENDING" {
|
||||
t.Fatalf("ORDERING recovery attempts/taskB = %d/%s", attempts, taskBStatus)
|
||||
}
|
||||
if _, err := database.Exec("UPDATE tasks SET version=version+1 WHERE id=?", testTaskA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestC}); !errors.Is(err, ErrRequiresManual) {
|
||||
t.Fatalf("ORDERING recovery with drifted version error = %v", err)
|
||||
}
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&attempts); err != nil || attempts != 1 {
|
||||
t.Fatalf("attempts after drifted ORDERING recovery = %d, err %v", attempts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimRollsBackEveryBusinessMutationOnLateFailure(t *testing.T) {
|
||||
database := openClaimTestDatabase(t)
|
||||
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
||||
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true)
|
||||
if _, err := database.Exec(`CREATE TRIGGER fail_claim_insert BEFORE INSERT ON purchase_attempt_claims
|
||||
BEGIN SELECT RAISE(ABORT, 'injected claim failure'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := mustStore(t, database, bytes.Repeat([]byte{0x31}, 32), 30*time.Second)
|
||||
store.now = func() time.Time { return testNow }
|
||||
if _, _, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}); err == nil {
|
||||
t.Fatal("ClaimNext succeeded despite injected late failure")
|
||||
}
|
||||
assertClaimState(t, database, 0, "PENDING", "ACTIVE")
|
||||
for _, table := range []string{"purchase_attempts", "task_claim_requests"} {
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("%s rows after rollback = %d, err %v", table, count, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimEligibilityStableOrderAndConcurrentUniqueness(t *testing.T) {
|
||||
database := openClaimTestDatabase(t)
|
||||
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
||||
insertDevice(t, database, testDeviceB, []byte("device-b"))
|
||||
// The oldest row has a mismatched snapshot and is ineligible; the next oldest valid row wins.
|
||||
insertCandidate(t, database, testTaskA, testAuthA, testNow.Add(-2*time.Minute), testNow.Add(10*time.Minute), false)
|
||||
insertCandidate(t, database, testTaskB, testAuthB, testNow.Add(-time.Minute), testNow.Add(10*time.Minute), true)
|
||||
store := mustStore(t, database, bytes.Repeat([]byte{0x33}, 32), time.Minute)
|
||||
store.now = func() time.Time { return testNow }
|
||||
|
||||
type result struct {
|
||||
response ClaimResponse
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
commands := []struct{ device, session, request string }{
|
||||
{testDeviceA, testSessionA, testClaimRequestA},
|
||||
{testDeviceB, testSessionB, testClaimRequestB},
|
||||
}
|
||||
results := make(chan result, 2)
|
||||
var wait sync.WaitGroup
|
||||
for _, command := range commands {
|
||||
command := command
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
response, found, err := store.ClaimNext(context.Background(), command.device, ClaimCommand{SessionID: command.session, ClaimRequestID: command.request})
|
||||
results <- result{response, found, err}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
close(results)
|
||||
foundCount := 0
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
t.Fatalf("concurrent ClaimNext error: %v", result.err)
|
||||
}
|
||||
if result.found {
|
||||
foundCount++
|
||||
if result.response.Task.ID != testTaskB {
|
||||
t.Fatalf("claimed task = %s, want stable eligible task B", result.response.Task.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if foundCount != 1 {
|
||||
t.Fatalf("successful claims = %d, want 1", foundCount)
|
||||
}
|
||||
var claimCount int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_claims").Scan(&claimCount); err != nil || claimCount != 1 {
|
||||
t.Fatalf("claim count = %d, err %v", claimCount, err)
|
||||
}
|
||||
var taskStatus, authorizationStatus string
|
||||
if err := database.QueryRow(`SELECT tasks.status, order_authorizations.status FROM tasks
|
||||
JOIN order_authorizations ON order_authorizations.task_id=tasks.id WHERE tasks.id=?`, testTaskB).
|
||||
Scan(&taskStatus, &authorizationStatus); err != nil || taskStatus != "CLAIMED" || authorizationStatus != "CLAIMED" {
|
||||
t.Fatalf("claimed B states = %s/%s, err %v", taskStatus, authorizationStatus, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimConcurrencyAcrossDistinctDatabasesAndStores(t *testing.T) {
|
||||
path := filepath.ToSlash(filepath.Join(t.TempDir(), "shared-claim.db"))
|
||||
source := "file:" + path + "?_busy_timeout=5000&_journal_mode=WAL"
|
||||
databaseA, err := sqlite.Open(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = databaseA.Close() })
|
||||
if err := migrations.Up(context.Background(), databaseA, claimMigrationDirectory(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
databaseB, err := sqlite.Open(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = databaseB.Close() })
|
||||
databaseA.SetMaxOpenConns(1)
|
||||
databaseB.SetMaxOpenConns(1)
|
||||
insertDevice(t, databaseA, testDeviceA, []byte("device-a"))
|
||||
insertDevice(t, databaseA, testDeviceB, []byte("device-b"))
|
||||
insertCandidate(t, databaseA, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true)
|
||||
secret := bytes.Repeat([]byte{0x39}, 32)
|
||||
storeA := mustStore(t, databaseA, secret, time.Minute)
|
||||
storeB := mustStore(t, databaseB, secret, time.Minute)
|
||||
storeA.now = func() time.Time { return testNow }
|
||||
storeB.now = func() time.Time { return testNow }
|
||||
firstLinearized := make(chan struct{})
|
||||
releaseFirst := make(chan struct{})
|
||||
secondAtFirstWrite := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
release := func() { releaseOnce.Do(func() { close(releaseFirst) }) }
|
||||
t.Cleanup(release)
|
||||
storeA.afterLinearization = func() {
|
||||
close(firstLinearized)
|
||||
<-releaseFirst
|
||||
}
|
||||
storeB.beforeLinearization = func() {
|
||||
// Reaching this hook means B has begun its own transaction and its very next
|
||||
// database operation is the first-write UPDATE currently held by A.
|
||||
close(secondAtFirstWrite)
|
||||
}
|
||||
|
||||
type result struct {
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
firstResult := make(chan result, 1)
|
||||
secondResult := make(chan result, 1)
|
||||
go func() {
|
||||
_, found, err := storeA.ClaimNext(context.Background(), testDeviceA, ClaimCommand{
|
||||
SessionID: testSessionA, ClaimRequestID: testClaimRequestA,
|
||||
})
|
||||
firstResult <- result{found: found, err: err}
|
||||
}()
|
||||
select {
|
||||
case <-firstLinearized:
|
||||
case result := <-firstResult:
|
||||
t.Fatalf("first ClaimNext returned before holding SQLite write position: found %v, err %v", result.found, result.err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first ClaimNext did not reach SQLite write position")
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, found, err := storeB.ClaimNext(context.Background(), testDeviceB, ClaimCommand{
|
||||
SessionID: testSessionB, ClaimRequestID: testClaimRequestB,
|
||||
})
|
||||
secondResult <- result{found: found, err: err}
|
||||
}()
|
||||
select {
|
||||
case <-secondAtFirstWrite:
|
||||
// A still owns the SQLite write position here. B cannot have observed or
|
||||
// changed claim state, so releasing A below creates deterministic contention.
|
||||
case result := <-secondResult:
|
||||
release()
|
||||
<-firstResult
|
||||
t.Fatalf("second ClaimNext returned before reaching the contended first write: found %v, err %v", result.found, result.err)
|
||||
case <-time.After(time.Second):
|
||||
release()
|
||||
<-firstResult
|
||||
t.Fatal("second ClaimNext did not reach the contended SQLite first write")
|
||||
}
|
||||
select {
|
||||
case result := <-secondResult:
|
||||
release()
|
||||
<-firstResult
|
||||
t.Fatalf("second ClaimNext completed while first transaction held SQLite write position: found %v, err %v", result.found, result.err)
|
||||
default:
|
||||
}
|
||||
release()
|
||||
first := <-firstResult
|
||||
second := <-secondResult
|
||||
if first.err != nil || !first.found {
|
||||
t.Fatalf("first cross-database ClaimNext = found %v, err %v", first.found, first.err)
|
||||
}
|
||||
if second.err != nil || second.found {
|
||||
t.Fatalf("second cross-database ClaimNext = found %v, err %v", second.found, second.err)
|
||||
}
|
||||
var attempts, claims, requestsCount, claimedRequests, emptyRequests int
|
||||
queries := []struct {
|
||||
query string
|
||||
value *int
|
||||
}{
|
||||
{"SELECT COUNT(*) FROM purchase_attempts", &attempts},
|
||||
{"SELECT COUNT(*) FROM purchase_attempt_claims", &claims},
|
||||
{"SELECT COUNT(*) FROM task_claim_requests", &requestsCount},
|
||||
{"SELECT COUNT(*) FROM task_claim_requests WHERE outcome='CLAIMED'", &claimedRequests},
|
||||
{"SELECT COUNT(*) FROM task_claim_requests WHERE outcome='EMPTY'", &emptyRequests},
|
||||
}
|
||||
for _, query := range queries {
|
||||
if err := databaseA.QueryRow(query.query).Scan(query.value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if attempts != 1 || claims != 1 || requestsCount != 2 || claimedRequests != 1 || emptyRequests != 1 {
|
||||
t.Fatalf("cross-database attempts/claims/requests/claimed/empty = %d/%d/%d/%d/%d",
|
||||
attempts, claims, requestsCount, claimedRequests, emptyRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewCASReplayCapAndNoResurrection(t *testing.T) {
|
||||
database := openClaimTestDatabase(t)
|
||||
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
||||
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(40*time.Second), true)
|
||||
store := mustStore(t, database, bytes.Repeat([]byte{0x44}, 32), 30*time.Second)
|
||||
current := testNow
|
||||
store.now = func() time.Time { return current }
|
||||
claim, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA})
|
||||
if err != nil || !found {
|
||||
t.Fatalf("ClaimNext = found %v, err %v", found, err)
|
||||
}
|
||||
|
||||
current = testNow.Add(20 * time.Second)
|
||||
command := RenewCommand{TaskID: testTaskA, RenewRequestID: testRenewRequestA, SessionID: testSessionA,
|
||||
AttemptID: claim.Attempt.ID, ClaimGeneration: claim.Attempt.ClaimGeneration,
|
||||
ClaimToken: claim.Attempt.ClaimToken, ExpectedLeaseExpiresAt: claim.Attempt.LeaseExpiresAt}
|
||||
renewed, err := store.Renew(context.Background(), testDeviceA, command)
|
||||
if err != nil {
|
||||
t.Fatalf("Renew: %v", err)
|
||||
}
|
||||
wantCap := formatTime(testNow.Add(40 * time.Second))
|
||||
if renewed.LeaseExpiresAt != wantCap {
|
||||
t.Fatalf("renewed lease = %s, want authorization cap %s", renewed.LeaseExpiresAt, wantCap)
|
||||
}
|
||||
current = testNow.Add(25 * time.Second)
|
||||
replay, err := store.Renew(context.Background(), testDeviceA, command)
|
||||
if err != nil || !reflect.DeepEqual(replay, renewed) {
|
||||
t.Fatalf("renew replay = %#v, err %v", replay, err)
|
||||
}
|
||||
changed := command
|
||||
changed.ExpectedLeaseExpiresAt = renewed.LeaseExpiresAt
|
||||
if _, err := store.Renew(context.Background(), testDeviceA, changed); !errors.Is(err, ErrIdempotencyConflict) {
|
||||
t.Fatalf("same key different payload error = %v", err)
|
||||
}
|
||||
stale := command
|
||||
stale.RenewRequestID = "60000000-0000-4000-8000-000000000004"
|
||||
if _, err := store.Renew(context.Background(), testDeviceA, stale); !errors.Is(err, ErrNotCurrent) {
|
||||
t.Fatalf("out-of-order expected lease error = %v", err)
|
||||
}
|
||||
wrongToken := command
|
||||
wrongToken.RenewRequestID = testRenewRequestB
|
||||
wrongToken.ClaimToken = strings.Repeat("0", 64)
|
||||
if _, err := store.Renew(context.Background(), testDeviceA, wrongToken); !errors.Is(err, ErrNotCurrent) {
|
||||
t.Fatalf("wrong token error = %v", err)
|
||||
}
|
||||
|
||||
current = testNow.Add(40 * time.Second) // equality is expired; no grace and no resurrection.
|
||||
expired := command
|
||||
expired.RenewRequestID = "60000000-0000-4000-8000-000000000003"
|
||||
expired.ExpectedLeaseExpiresAt = renewed.LeaseExpiresAt
|
||||
if _, err := store.Renew(context.Background(), testDeviceA, expired); !errors.Is(err, ErrNotCurrent) {
|
||||
t.Fatalf("expired renewal error = %v", err)
|
||||
}
|
||||
var lease, taskStatus, attemptStatus, authorizationStatus string
|
||||
if err := database.QueryRow(`SELECT claims.lease_expires_at, tasks.status, attempts.status, authorizations.status
|
||||
FROM purchase_attempt_claims claims JOIN tasks ON tasks.id=claims.task_id
|
||||
JOIN purchase_attempts attempts ON attempts.id=claims.attempt_id
|
||||
JOIN order_authorizations authorizations ON authorizations.id=claims.authorization_id`).
|
||||
Scan(&lease, &taskStatus, &attemptStatus, &authorizationStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lease != wantCap || taskStatus != "CLAIMED" || attemptStatus != "CLAIMED" || authorizationStatus != "CLAIMED" {
|
||||
t.Fatalf("renew changed business state: lease=%s task=%s attempt=%s auth=%s", lease, taskStatus, attemptStatus, authorizationStatus)
|
||||
}
|
||||
if _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=?`, formatTime(current), testDeviceA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revoked := expired
|
||||
revoked.RenewRequestID = "60000000-0000-4000-8000-000000000005"
|
||||
if _, err := store.Renew(context.Background(), testDeviceA, revoked); !errors.Is(err, ErrDeviceInactive) {
|
||||
t.Fatalf("renew after revocation error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewRequiresPairedBusinessStateAndExactTaskVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*testing.T, *sql.DB, ClaimResponse)
|
||||
wantError bool
|
||||
}{
|
||||
{"claimed exact version", func(*testing.T, *sql.DB, ClaimResponse) {}, false},
|
||||
{"ordering exact next version", func(t *testing.T, database *sql.DB, claim ClaimResponse) {
|
||||
if _, err := database.Exec("UPDATE tasks SET status='ORDERING',version=version+1 WHERE id=?", testTaskA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Exec("UPDATE purchase_attempts SET status='ORDERING' WHERE id=?", claim.Attempt.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, false},
|
||||
{"claimed version drift", func(t *testing.T, database *sql.DB, _ ClaimResponse) {
|
||||
if _, err := database.Exec("UPDATE tasks SET version=version+1 WHERE id=?", testTaskA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, true},
|
||||
{"ordering version drift", func(t *testing.T, database *sql.DB, claim ClaimResponse) {
|
||||
if _, err := database.Exec("UPDATE tasks SET status='ORDERING',version=version+2 WHERE id=?", testTaskA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Exec("UPDATE purchase_attempts SET status='ORDERING' WHERE id=?", claim.Attempt.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, true},
|
||||
{"task ordering attempt claimed", func(t *testing.T, database *sql.DB, _ ClaimResponse) {
|
||||
if _, err := database.Exec("UPDATE tasks SET status='ORDERING',version=version+1 WHERE id=?", testTaskA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, true},
|
||||
{"task claimed attempt ordering", func(t *testing.T, database *sql.DB, claim ClaimResponse) {
|
||||
if _, err := database.Exec("UPDATE purchase_attempts SET status='ORDERING' WHERE id=?", claim.Attempt.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, true},
|
||||
}
|
||||
for index, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
database, store, claim := claimedRenewFixture(t, byte(0x50+index))
|
||||
test.mutate(t, database, claim)
|
||||
_, err := store.Renew(context.Background(), testDeviceA, renewCommandFor(claim, testRenewRequestA))
|
||||
if test.wantError {
|
||||
if !errors.Is(err, ErrNotCurrent) {
|
||||
t.Fatalf("Renew error = %v, want ErrNotCurrent", err)
|
||||
}
|
||||
var lease string
|
||||
var renewals int
|
||||
if scanErr := database.QueryRow("SELECT lease_expires_at FROM purchase_attempt_claims WHERE attempt_id=?", claim.Attempt.ID).Scan(&lease); scanErr != nil {
|
||||
t.Fatal(scanErr)
|
||||
}
|
||||
if scanErr := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_lease_renewals").Scan(&renewals); scanErr != nil {
|
||||
t.Fatal(scanErr)
|
||||
}
|
||||
if lease != claim.Attempt.LeaseExpiresAt || renewals != 0 {
|
||||
t.Fatalf("rejected renew changed lease/rows = %s/%d", lease, renewals)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Renew valid state: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentRenewCASUsesSQLiteNotOneStoreGate(t *testing.T) {
|
||||
database := openClaimTestDatabase(t)
|
||||
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
||||
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true)
|
||||
secret := bytes.Repeat([]byte{0x48}, 32)
|
||||
storeA := mustStore(t, database, secret, time.Minute)
|
||||
storeA.now = func() time.Time { return testNow }
|
||||
claim, found, err := storeA.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA})
|
||||
if err != nil || !found {
|
||||
t.Fatalf("ClaimNext = found %v, err %v", found, err)
|
||||
}
|
||||
storeB := mustStore(t, database, secret, time.Minute)
|
||||
renewNow := testNow.Add(10 * time.Second)
|
||||
storeA.now = func() time.Time { return renewNow }
|
||||
storeB.now = func() time.Time { return renewNow }
|
||||
base := RenewCommand{TaskID: testTaskA, SessionID: testSessionA, AttemptID: claim.Attempt.ID,
|
||||
ClaimGeneration: claim.Attempt.ClaimGeneration, ClaimToken: claim.Attempt.ClaimToken,
|
||||
ExpectedLeaseExpiresAt: claim.Attempt.LeaseExpiresAt}
|
||||
commands := []RenewCommand{base, base}
|
||||
commands[0].RenewRequestID = testRenewRequestA
|
||||
commands[1].RenewRequestID = testRenewRequestB
|
||||
type result struct{ err error }
|
||||
results := make(chan result, 2)
|
||||
var wait sync.WaitGroup
|
||||
for index, claimStore := range []*Store{storeA, storeB} {
|
||||
index, claimStore := index, claimStore
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
_, err := claimStore.Renew(context.Background(), testDeviceA, commands[index])
|
||||
results <- result{err: err}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
close(results)
|
||||
successes, stale := 0, 0
|
||||
for result := range results {
|
||||
switch {
|
||||
case result.err == nil:
|
||||
successes++
|
||||
case errors.Is(result.err, ErrNotCurrent):
|
||||
stale++
|
||||
default:
|
||||
t.Fatalf("concurrent Renew error = %v", result.err)
|
||||
}
|
||||
}
|
||||
var renewalCount int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_lease_renewals").Scan(&renewalCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if successes != 1 || stale != 1 || renewalCount != 1 {
|
||||
t.Fatalf("concurrent renew success/stale/rows = %d/%d/%d", successes, stale, renewalCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewRevocationLinearizationBothOrders(t *testing.T) {
|
||||
t.Run("revocation first", func(t *testing.T) {
|
||||
database, store, claim := claimedRenewFixture(t, 0x49)
|
||||
if _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=?`, formatTime(testNow.Add(time.Second)), testDeviceA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := renewCommandFor(claim, testRenewRequestA)
|
||||
if _, err := store.Renew(context.Background(), testDeviceA, command); !errors.Is(err, ErrDeviceInactive) {
|
||||
t.Fatalf("Renew after revocation error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("renew write position first", func(t *testing.T) {
|
||||
database, store, claim := claimedRenewFixture(t, 0x4a)
|
||||
linearized := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
store.afterLinearization = func() { close(linearized); <-release }
|
||||
renewResult := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := store.Renew(context.Background(), testDeviceA, renewCommandFor(claim, testRenewRequestA))
|
||||
renewResult <- err
|
||||
}()
|
||||
<-linearized
|
||||
revocationStarted := make(chan struct{})
|
||||
revocationResult := make(chan error, 1)
|
||||
go func() {
|
||||
close(revocationStarted)
|
||||
_, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=?
|
||||
WHERE device_id=? AND status='ACTIVE'`, formatTime(testNow.Add(2*time.Second)), testDeviceA)
|
||||
revocationResult <- err
|
||||
}()
|
||||
<-revocationStarted
|
||||
close(release)
|
||||
if err := <-renewResult; err != nil {
|
||||
t.Fatalf("renew holding first write position: %v", err)
|
||||
}
|
||||
if err := <-revocationResult; err != nil {
|
||||
t.Fatalf("revocation after renew: %v", err)
|
||||
}
|
||||
var renewals int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_lease_renewals").Scan(&renewals); err != nil || renewals != 1 {
|
||||
t.Fatalf("renewal rows = %d, err %v", renewals, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStartupValidatesClosedClaimStorageTypesAndStatuses(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*testing.T, *sql.DB)
|
||||
}{
|
||||
{"text nonce", func(t *testing.T, database *sql.DB) {
|
||||
if _, err := database.Exec(`UPDATE purchase_attempt_claims
|
||||
SET claim_nonce=CAST('12345678901234567890123456789012' AS TEXT)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{"invalid attempt status", func(t *testing.T, database *sql.DB) {
|
||||
if _, err := database.Exec("UPDATE purchase_attempts SET status='CORRUPT'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
database := openClaimTestDatabase(t)
|
||||
database.SetMaxOpenConns(1)
|
||||
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
||||
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true)
|
||||
secret := bytes.Repeat([]byte{0x4b}, 32)
|
||||
store := mustStore(t, database, secret, time.Minute)
|
||||
store.now = func() time.Time { return testNow }
|
||||
claim, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA})
|
||||
if err != nil || !found {
|
||||
t.Fatalf("ClaimNext = found %v, err %v", found, err)
|
||||
}
|
||||
if _, err := database.Exec("UPDATE purchase_attempt_claims SET closed_at=? WHERE attempt_id=?", formatTime(testNow.Add(2*time.Minute)), claim.Attempt.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewStore(database, secret, time.Minute); err != nil {
|
||||
t.Fatalf("valid closed claim rejected: %v", err)
|
||||
}
|
||||
if _, err := database.Exec("PRAGMA ignore_check_constraints=ON"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
test.mutate(t, database)
|
||||
if _, err := NewStore(database, secret, time.Minute); err == nil {
|
||||
t.Fatal("NewStore accepted corrupted closed claim storage")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupRejectsClaimAttemptGenerationCorruption(t *testing.T) {
|
||||
database := openClaimTestDatabase(t)
|
||||
database.SetMaxOpenConns(1)
|
||||
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
||||
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true)
|
||||
secret := bytes.Repeat([]byte{0x4c}, 32)
|
||||
store := mustStore(t, database, secret, time.Minute)
|
||||
store.now = func() time.Time { return testNow }
|
||||
claim, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA})
|
||||
if err != nil || !found {
|
||||
t.Fatalf("ClaimNext = found %v, err %v", found, err)
|
||||
}
|
||||
var nonce []byte
|
||||
if err := database.QueryRow("SELECT claim_nonce FROM purchase_attempt_claims WHERE attempt_id=?", claim.Attempt.ID).Scan(&nonce); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
corruptGeneration := claim.Attempt.ClaimGeneration + 1
|
||||
corruptToken := deriveToken(secret, testDeviceA, testTaskA, testAuthA, claim.Attempt.ID, corruptGeneration, nonce)
|
||||
if _, err := database.Exec("PRAGMA foreign_keys=OFF"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Exec(`UPDATE purchase_attempt_claims SET claim_generation=?,claim_token_sha256=? WHERE attempt_id=?`,
|
||||
corruptGeneration, tokenHash(corruptToken), claim.Attempt.ID); err != nil {
|
||||
t.Fatalf("inject generation corruption: %v", err)
|
||||
}
|
||||
if _, err := NewStore(database, secret, time.Minute); err == nil {
|
||||
t.Fatal("NewStore accepted claim generation different from its attempt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevocationLinearizesBeforeOrAfterClaim(t *testing.T) {
|
||||
t.Run("revocation first", func(t *testing.T) {
|
||||
database := openClaimTestDatabase(t)
|
||||
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
||||
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true)
|
||||
if _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=?`, formatTime(testNow), testDeviceA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := mustStore(t, database, bytes.Repeat([]byte{0x55}, 32), 30*time.Second)
|
||||
if _, _, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}); !errors.Is(err, ErrDeviceInactive) {
|
||||
t.Fatalf("ClaimNext error = %v, want inactive", err)
|
||||
}
|
||||
assertClaimState(t, database, 0, "PENDING", "ACTIVE")
|
||||
})
|
||||
|
||||
t.Run("claim write position first", func(t *testing.T) {
|
||||
database := openClaimTestDatabase(t)
|
||||
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
||||
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true)
|
||||
store := mustStore(t, database, bytes.Repeat([]byte{0x66}, 32), 30*time.Second)
|
||||
store.now = func() time.Time { return testNow }
|
||||
linearized := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
store.afterLinearization = func() { close(linearized); <-release }
|
||||
claimResult := make(chan error, 1)
|
||||
go func() {
|
||||
_, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA})
|
||||
if err == nil && !found {
|
||||
err = errors.New("claim unexpectedly empty")
|
||||
}
|
||||
claimResult <- err
|
||||
}()
|
||||
<-linearized
|
||||
revocationStarted := make(chan struct{})
|
||||
revocationResult := make(chan error, 1)
|
||||
go func() {
|
||||
close(revocationStarted)
|
||||
_, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=? AND status='ACTIVE'`, formatTime(testNow.Add(time.Second)), testDeviceA)
|
||||
revocationResult <- err
|
||||
}()
|
||||
<-revocationStarted
|
||||
close(release)
|
||||
if err := <-claimResult; err != nil {
|
||||
t.Fatalf("claim holding first write position: %v", err)
|
||||
}
|
||||
if err := <-revocationResult; err != nil {
|
||||
t.Fatalf("revocation after claim: %v", err)
|
||||
}
|
||||
assertClaimState(t, database, 1, "CLAIMED", "CLAIMED")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTokenDomainSeparationAndDeviceSecretIsolation(t *testing.T) {
|
||||
secret := bytes.Repeat([]byte{0x77}, 32)
|
||||
nonce := bytes.Repeat([]byte{0x88}, 32)
|
||||
base := deriveToken(secret, testDeviceA, testTaskA, testAuthA, "70000000-0000-4000-8000-000000000001", 1, nonce)
|
||||
variants := [][]byte{
|
||||
deriveToken(secret, testDeviceB, testTaskA, testAuthA, "70000000-0000-4000-8000-000000000001", 1, nonce),
|
||||
deriveToken(secret, testDeviceA, testTaskB, testAuthA, "70000000-0000-4000-8000-000000000001", 1, nonce),
|
||||
deriveToken(secret, testDeviceA, testTaskA, testAuthB, "70000000-0000-4000-8000-000000000001", 1, nonce),
|
||||
deriveToken(secret, testDeviceA, testTaskA, testAuthA, "70000000-0000-4000-8000-000000000002", 1, nonce),
|
||||
deriveToken(secret, testDeviceA, testTaskA, testAuthA, "70000000-0000-4000-8000-000000000001", 2, nonce),
|
||||
}
|
||||
for index, variant := range variants {
|
||||
if matchingHash(base, variant) {
|
||||
t.Fatalf("token variant %d was not domain separated", index)
|
||||
}
|
||||
}
|
||||
|
||||
database := openClaimTestDatabase(t)
|
||||
insertDevice(t, database, testDeviceA, secret)
|
||||
if _, err := NewStore(database, secret, time.Minute); err == nil {
|
||||
t.Fatal("NewStore accepted a key equal to a device token")
|
||||
}
|
||||
}
|
||||
|
||||
func openClaimTestDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
path := filepath.ToSlash(filepath.Join(t.TempDir(), "claim.db"))
|
||||
database, err := sqlite.Open("file:" + path + "?_busy_timeout=5000&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := migrations.Up(context.Background(), database, claimMigrationDirectory(t)); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
func claimMigrationDirectory(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate test file")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
|
||||
func mustStore(t *testing.T, database *sql.DB, secret []byte, ttl time.Duration) *Store {
|
||||
t.Helper()
|
||||
store, err := NewStore(database, secret, ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func claimedRenewFixture(t *testing.T, secretByte byte) (*sql.DB, *Store, ClaimResponse) {
|
||||
t.Helper()
|
||||
database := openClaimTestDatabase(t)
|
||||
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
||||
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true)
|
||||
store := mustStore(t, database, bytes.Repeat([]byte{secretByte}, 32), time.Minute)
|
||||
store.now = func() time.Time { return testNow }
|
||||
claim, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA})
|
||||
if err != nil || !found {
|
||||
t.Fatalf("ClaimNext = found %v, err %v", found, err)
|
||||
}
|
||||
store.now = func() time.Time { return testNow.Add(10 * time.Second) }
|
||||
return database, store, claim
|
||||
}
|
||||
|
||||
func renewCommandFor(claim ClaimResponse, requestID string) RenewCommand {
|
||||
return RenewCommand{TaskID: testTaskA, RenewRequestID: requestID, SessionID: testSessionA,
|
||||
AttemptID: claim.Attempt.ID, ClaimGeneration: claim.Attempt.ClaimGeneration,
|
||||
ClaimToken: claim.Attempt.ClaimToken, ExpectedLeaseExpiresAt: claim.Attempt.LeaseExpiresAt}
|
||||
}
|
||||
|
||||
func insertDevice(t *testing.T, database *sql.DB, deviceID string, token []byte) {
|
||||
t.Helper()
|
||||
digest := sha256.Sum256(token)
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, deviceID, "test device", digest[:], formatTime(testNow.Add(-time.Hour))); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertCandidate(t *testing.T, database *sql.DB, taskID, authorizationID string, createdAt, expiresAt time.Time, snapshotMatches bool) {
|
||||
t.Helper()
|
||||
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', '测试商品', '937122477375', '黑色', 'M', 2, '30.00', 'PENDING', 2, ?, ?)`,
|
||||
taskID, formatTime(createdAt), formatTime(createdAt)); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
color := "黑色"
|
||||
if !snapshotMatches {
|
||||
color = "白色"
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations
|
||||
(id,task_id,task_version,start_key,goods_id,sku_color,sku_size,quantity,total_price_cap,status,created_by,created_at,expires_at)
|
||||
VALUES (?, ?, 2, ?, '937122477375', ?, 'M', 2, '30.00', 'ACTIVE', 'admin', ?, ?)`,
|
||||
authorizationID, taskID, authorizationID, color, formatTime(createdAt), formatTime(expiresAt)); err != nil {
|
||||
t.Fatalf("insert authorization: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertClaimState(t *testing.T, database *sql.DB, wantClaims int, wantTaskStatus, wantAuthorizationStatus string) {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_claims").Scan(&count); err != nil || count != wantClaims {
|
||||
t.Fatalf("claim count = %d, err %v, want %d", count, err, wantClaims)
|
||||
}
|
||||
var taskStatus, authorizationStatus string
|
||||
if err := database.QueryRow(`SELECT tasks.status, order_authorizations.status FROM tasks
|
||||
JOIN order_authorizations ON order_authorizations.task_id=tasks.id
|
||||
WHERE tasks.id=?`, testTaskA).Scan(&taskStatus, &authorizationStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taskStatus != wantTaskStatus || authorizationStatus != wantAuthorizationStatus {
|
||||
t.Fatalf("states = %s/%s, want %s/%s", taskStatus, authorizationStatus, wantTaskStatus, wantAuthorizationStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoPlaintextTokenColumnOrValue(t *testing.T, database *sql.DB, token string) {
|
||||
t.Helper()
|
||||
rows, err := database.Query("PRAGMA table_info(purchase_attempt_claims)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid, notNull, primaryKey int
|
||||
var name, kind string
|
||||
var defaultValue any
|
||||
if err := rows.Scan(&cid, &name, &kind, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name == "claim_token" {
|
||||
t.Fatal("schema contains a plaintext claim_token column")
|
||||
}
|
||||
}
|
||||
decoded, _ := hex.DecodeString(token)
|
||||
var nonce, storedHash []byte
|
||||
if err := database.QueryRow("SELECT claim_nonce, claim_token_sha256 FROM purchase_attempt_claims").Scan(&nonce, &storedHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Equal(nonce, decoded) || bytes.Equal(storedHash, decoded) || len(nonce) != 32 || len(storedHash) != 32 {
|
||||
t.Fatal("database contains plaintext token or malformed token metadata")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package taskclaim
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
)
|
||||
|
||||
const tokenDomain = "cmbuyer/task-claim-token/v1\x00"
|
||||
|
||||
func deriveToken(secret []byte, deviceID, taskID, authorizationID, attemptID string, generation int, nonce []byte) []byte {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(tokenDomain))
|
||||
writeTokenField(mac, deviceID)
|
||||
writeTokenField(mac, taskID)
|
||||
writeTokenField(mac, authorizationID)
|
||||
writeTokenField(mac, attemptID)
|
||||
var number [8]byte
|
||||
binary.BigEndian.PutUint64(number[:], uint64(generation))
|
||||
_, _ = mac.Write(number[:])
|
||||
writeTokenBytes(mac, nonce)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func writeTokenField(writer hash.Hash, value string) { writeTokenBytes(writer, []byte(value)) }
|
||||
|
||||
func writeTokenBytes(writer hash.Hash, value []byte) {
|
||||
var size [4]byte
|
||||
binary.BigEndian.PutUint32(size[:], uint32(len(value)))
|
||||
_, _ = writer.Write(size[:])
|
||||
_, _ = writer.Write(value)
|
||||
}
|
||||
|
||||
func tokenHash(token []byte) []byte {
|
||||
sum := sha256.Sum256(token)
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func matchingHash(left, right []byte) bool {
|
||||
return len(left) == sha256.Size && len(right) == sha256.Size && subtle.ConstantTimeCompare(left, right) == 1
|
||||
}
|
||||
|
||||
func decodeToken(value string) ([]byte, bool) {
|
||||
if len(value) != sha256.Size*2 {
|
||||
return nil, false
|
||||
}
|
||||
decoded, err := hex.DecodeString(value)
|
||||
return decoded, err == nil && hex.EncodeToString(decoded) == value
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type RenewResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
ClaimGeneration int `json:"claim_generation"`
|
||||
LeaseExpiresAt string `json:"lease_expires_at"`
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
ClaimNext(context.Context, string, ClaimCommand) (ClaimResponse, bool, error)
|
||||
Renew(context.Context, string, RenewCommand) (RenewResponse, error)
|
||||
}
|
||||
@@ -17,11 +17,17 @@ const (
|
||||
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
+51
-1
@@ -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 并
|
||||
@@ -353,6 +400,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 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除
|
||||
|
||||
+13
-10
@@ -19,7 +19,7 @@ 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 | 双端契约完整门禁 |
|
||||
| 3 | T-210 → T-205 → T-206 → T-207 → T-208 | T-301 → T-302 → T-303,再并行 T-304 / T-306 / T-307 | 双端契约完整门禁 |
|
||||
| 4 | - | T-305 | 围栏前端到端 dry-run 主审 |
|
||||
| 5 | T-402 / T-403 | T-401 | 真机人工授权、提交与调和验收 |
|
||||
| 6 | - | T-405 | T-404 完整验收 |
|
||||
@@ -28,7 +28,8 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
|
||||
|
||||
- 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,10 +90,11 @@ 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 | 严格 claim/renew/evidence HTTP、DPAPI/SQLite、单实例和原子恢复槽;不伪造完整 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-305 | **单趟围栏前端到端 dry-run** | T-107, T-304, T-306, T-307, T-210 | 连续跑到三闸门及最终控件只读观察,随后一次安全 Back 并以固定 reason 停在围栏前;不申请许可、不点击提交 |
|
||||
|
||||
### Phase 4 · 真实提交与收尾
|
||||
|
||||
|
||||
+86
-27
@@ -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(纯棉)",
|
||||
@@ -103,10 +103,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 +125,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 +160,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 +173,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 +199,71 @@
|
||||
"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 的标题、规格、
|
||||
数量、金额、版本或到期时间漂移。源快照不一致时,新恢复/续租失败闭合。
|
||||
- 一个设备最多有一个未关闭 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 +291,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 +306,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 +330,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"submission_id": "018f-submission",
|
||||
"submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"status": "FENCED",
|
||||
"click_permitted": true,
|
||||
"submit_text": "提交订单"
|
||||
@@ -288,10 +347,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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+20
-18
@@ -19,15 +19,16 @@
|
||||
- 技术栈:已定。采购服务(`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 当前价
|
||||
读取、原始截图和一次安全退出能力;该能力尚待最终人工真机验收,数量/确认页/提交仍未开放。
|
||||
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
|
||||
采购工具 112 项离线单元测试(全部 mock,不连接真机)。
|
||||
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
|
||||
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。仓库不含业务实例数据。
|
||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
|
||||
并且不覆盖低版本环境;成功后打印真实启动命令。
|
||||
@@ -39,20 +40,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-210、T-301~T-307) | T-302 已完成;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、真机取证及受控规格选择/读价/安全退出;数量、确认页和提交未开放 |
|
||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||
|
||||
## 任务状态
|
||||
@@ -68,11 +71,10 @@
|
||||
- 已完成 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。
|
||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||
可读字段时必须先回修原型与交互清单。
|
||||
|
||||
|
||||
+7
-3
@@ -21,7 +21,7 @@ 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-04T13:46:36Z sha256=1bbf16ce51d223a2f5c6b756424d004d15fe04d99dd9e744c303ecac64fa0b49 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-103 已实现受控规格选择、读价、截图和单次 Back,但当前 `exit_sku_panel_safely()` 在 hierarchy 变化且规格面板判据不再成立时就返回成功。2026-08-04 的只读诊断已证明:通知栏覆盖时 `app_current` 仍可能报告拼多多,而节点树全部属于 SystemUI。因此“已不在面板”不是安全退出的充分条件。T-104 用独立真机证据把后置条件收紧为“稳定回到同一目标商品详情页”。
|
||||
@@ -55,7 +55,9 @@ T-103 已实现受控规格选择、读价、截图和单次 Back,但当前 `e
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
### 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 -->
|
||||
|
||||
## 边界
|
||||
@@ -74,9 +76,11 @@ T-103 已实现受控规格选择、读价、截图和单次 Back,但当前 `e
|
||||
数量、确认页导航、授权、提交围栏、创建待付款订单或支付能力。第一趟与任何真实提交函数继续静态隔离。
|
||||
- 原始完整 XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入
|
||||
Vikunja 或日志。Git 中最小 fixture 只能保留退出判据所需结构,并由人确认不含地址、手机号或支付凭据。
|
||||
- 供 T-306 消费的成功结果必须显式携带规格面板原始 `screenshot_path` 与实际截图时刻 `captured_at`
|
||||
(UTC aware);只有规格选择、闸门一读价、截图和严格安全退出全部成功后才可返回该结果。不得让
|
||||
T-306 枚举目录或读取 manifest 猜文件/时间;post-exit 商品详情截图也不得冒充规格面板证据。
|
||||
- `needs_device: true`:agent 不得自行标 `DONE`。只有人确认 post-exit 截图为同一目标商品详情、面板
|
||||
已关闭、未进入确认/提交/支付页、未创建订单,且截图/XML 对应后,任务才可完成。
|
||||
- 本任务不实现、不调用 `set_quantity_and_readback()`、`go_to_order_confirm()`、
|
||||
`create_submission_fence()`、`submit_order_once()`、支付、免密支付、先用后付或任何扣款能力;既有
|
||||
三道价格闸门、服务端围栏、只点一次且不重试的约束不得放宽。
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: T-105
|
||||
title: 数量设置、读回与闸门二
|
||||
phase: 1
|
||||
deps: [T-104]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 40
|
||||
context_ref: 8c50e15
|
||||
work_branch: task/t-105-quantity-gate2
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-105.md
|
||||
- client/src/cmbuyer_client/pdd/quantity_gate2_spike.py
|
||||
- client/src/cmbuyer_client/pdd/quantity_gate2.py
|
||||
- client/src/cmbuyer_client/pdd/quantity_gate2_runner.py
|
||||
- client/tests/pdd/test_quantity_gate2.py
|
||||
- client/tests/pdd/fixtures/quantity_gate2_initial_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/quantity_gate2_target_8_17_0.xml
|
||||
- client/scripts/capture_quantity_gate2_spike.py
|
||||
- client/scripts/run_t105_quantity_gate2.py
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=40 synced=2026-08-04T13:59:13Z sha256=86e0079c5db57033818f75763d3e9012764e11f805a1ab12cafbe58a8ba9a526 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-104 将把规格选择、闸门一读价、原始截图和安全退出固化为受控能力。T-105 先对当前真机数量控件和读回位置取证,再实现规格面板内的数量设置、精确读回与闸门二;不进入确认页、不接触最终提交。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-010、F-015。
|
||||
- 用户故事:US-004、US-005。
|
||||
- 依赖:T-104;T-103 由其传递满足。
|
||||
- 后续消费者:T-106、T-305。
|
||||
- 本任务需要真机与人工证据确认。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 阶段一只读取证:人分别准备初始数量与目标数量状态,脚本只采集 screenshot/XML/App/设备摘要;记录 PDD 版本、goods_id、规格、目标数量、路径和 hash。人工确认数量控件、读回位置和页面对应性前,不写点击判据。
|
||||
2. 阶段二从本项目证据提取最小 fixture,固化数量显示、增减控件、选中规格和价格节点的精确唯一结构;版本/包/面板/规格/控件任一失配都零点击。
|
||||
3. QuantityGate2Flow 接受 T-104 Gate1Observation、目标数量和最高总价。数量必须为服务端任务正整数,不接受浮点、字符串猜测或超上限值。
|
||||
4. 每次数量修改只点击证据证明的唯一控件一次,并等待明确后置条件;超时或结果不明不得重试该次点击。达到目标前每一步都重新定位和读回,不使用裸坐标或缓存节点。
|
||||
5. 闸门二重新读取颜色、尺码、数量和规格面板单价;颜色/尺码必须等于目标,数量精确等于请求,gate2 单价逐字规范化后等于 gate1 单价,并用十进制运算确认单价×数量不超最高总价。
|
||||
6. 成功结果是不可变 Gate2Observation,包含请求/实际规格、quantity_read、gate1/gate2_unit_price、max_total_price、规格面板原始 screenshot_path 与 UTC captured_at。
|
||||
7. 失败、价格不可读/多候选、规格漂移、数量不符或超价都安全停止并使用 T-104 一次性安全退出;Back 结果不明不重试。
|
||||
8. 能力与 T-103/T-104 分层:T-103 不能反向导入本任务;本任务不暴露确认页导航、通用 click、最终提交控件、围栏或付款能力。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 先证据后实现,执行记录包含两态截图/XML/hash、版本、设备、goods_id 与人工确认。
|
||||
- 离线覆盖数量边界、每步单击一次、后置条件、超时不重试、节点缺失/重复、SystemUI/锁屏/版本漂移。
|
||||
- 覆盖闸门二规格不变、价格严格相等、Decimal 总价、数量读回及所有失败分支零确认页动作。
|
||||
- 静态证明 T-103 不导入数量,本任务不可达确认页、围栏、submit_order_once 或付款。
|
||||
- 真机人工确认目标数量、规格、两次价格、截图、一次安全退出,且未进入确认/提交/支付页、未创建订单。
|
||||
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 保持 DOING 等人工验收。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T13:58:34Z · ila
|
||||
|
||||
2026-08-04 预研定值:T-105 依赖 T-104 的一次安全退出,严格分“数量两态只读取证”和“人工确认后实现”两个门禁;闸门二输出显式 screenshot_path/captured_at,且不可达确认页、提交或付款。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 必须先由人在拼多多 8.17.0 的目标规格面板分别准备初始数量与目标数量状态,只读采集并人工确认
|
||||
screenshot/XML/版本/goods_id/规格/数量的对应性,之后才允许从本项目证据编写数量判据和点击代码。
|
||||
不得从前序项目、旧 XML、控件常识、OCR、裸坐标或推理直接实现。
|
||||
- 数量控件、读回位置、规格选中态和两次价格都必须证据绑定且精确唯一。每次修改数量最多点击已批准
|
||||
控件一次并等待明确后置条件;超时或结果不明不得重试同一次点击,不得缓存节点跨状态继续操作。
|
||||
- 闸门二必须重新核对颜色/尺码未漂移、数量精确等于任务、gate2 单价严格等于 gate1 单价,并以
|
||||
十进制运算验证单价乘数量不超最高总价。价格只从规格面板读取,不用详情页或其他数字兜底。
|
||||
- 只有规格、数量、价格、总价上限和原始截图全部通过后才返回不可变 Gate2Observation;它必须显式
|
||||
携带 `screenshot_path` 与 UTC `captured_at`,不得让后续任务枚举目录或读取 manifest 猜证据。
|
||||
- 任一判据失败、规格漂移、数量不符、价格不可读/多候选/变化或超价都停止前进,只能调用 T-104
|
||||
已验证的一次性安全退出;Back 超时或结果不明不重试,不把“面板消失”当作退出成功。
|
||||
- T-103/T-104 不得反向导入本任务。本任务不进入确认页、不观察或获取最终提交控件,不实现通用 click、
|
||||
事件、证据上传、围栏或结果接口;不编写或引用点击“提交订单”的代码,不编写任何付款能力。
|
||||
- `needs_device: true`:agent 完成离线实现后仍保持 DOING。只有人确认真机数量、规格、两次价格、
|
||||
截图和安全退出,且未进入确认/提交/支付页、未创建订单,才可完成任务。
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: T-106
|
||||
title: 确认页、闸门三与返回路径真机取证
|
||||
phase: 1
|
||||
deps: [T-105]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 41
|
||||
context_ref: 0144054
|
||||
work_branch: task/t-106-confirm-evidence
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-106.md
|
||||
- client/src/cmbuyer_client/pdd/order_confirm_spike.py
|
||||
- client/tests/pdd/test_order_confirm_spike.py
|
||||
- client/scripts/capture_order_confirm_spike.py
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=41 synced=2026-08-04T14:02:41Z sha256=1b7fda4d3b4951f20504d45b88676fda9271f1aaeaacb67b0674e4a6357ca3cc -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-105 验证规格面板数量与闸门二后,下一步会进入更高风险的订单确认页。T-106 只由人手工准备确认页、最终提交控件可见态和一次返回后的页面,再用完全只读脚本采集真机事实;本任务不自动导航、不写确认页判据。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-011、F-016。
|
||||
- 用户故事:US-004、US-005。
|
||||
- 依赖:T-105。
|
||||
- 后续消费者:T-107。
|
||||
- 需要真机和人工页面对应性、安全结果确认。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 提供只读取证 CLI,唯一能力为核对指定设备/App 版本、app_current、screenshot 与 compressed=false XML、hash 和原子 manifest;静态禁止 click、press/back、swipe、input、intent 和任意 UI 修改。
|
||||
2. 人先手工完成已取证规格/数量并进入确认页,停在规格、数量、应付总额及最终“提交订单”控件可见的安全状态;绝不能点击最终提交控件。
|
||||
3. 只读采集确认页主体与最终控件状态,记录准确规格、数量、应付总额、控件精确文案/匹配数/启用态;这些只是人工观察,不转成生产 selector。
|
||||
4. 随后由人只按一次返回并停手,再用同一只读取证 CLI 采集返回后的页面。Back 结果不明不再次按;记录页面身份与是否回到证据允许的安全页面。
|
||||
5. 三组证据都记录设备型号、Android、USB/WiFi、serial、PDD 版本、goods_id、截图/XML 路径和 SHA-256;完整 XML 仅留本机。
|
||||
6. 如果人误触最终提交、产生待付款订单、进入支付/安全验证/外部页面或页面结果不明,立即停止、不重试、不继续取证,并如实记录路径为不可用;绝不操作付款。
|
||||
7. 当前规格面板中出现“提交订单 ¥...”不能仅凭文案推断为确认页导航或最终控件;T-107 只能消费本任务由人确认的结构事实。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 静态证明 spike/CLI 没有任何 UI 修改 API;mock 覆盖设备/版本/包/截图/XML/超时/原子发布/异常脱敏。
|
||||
- 人工确认三组截图/XML对应:确认页规格数量金额、最终控件可见态、一次返回后页面。
|
||||
- 执行记录列出准确控件文案与匹配数,但不把坐标/selector 作为结论;完整 XML、地址、手机号、支付信息不进 Git/Vikunja/日志。
|
||||
- 明确记录未点击最终提交、未创建订单、未进入支付;若相反则任务保持 DOING/受阻且不自动处置。
|
||||
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 不得自行标 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T14:01:13Z · ila
|
||||
|
||||
2026-08-04 预研定值:T-106 与 T-107 分开,严格只做人工准备页面后的只读取证;采集代码零 UI 修改,最终提交控件不点击,一次返回也由人执行且结果不明不重试。
|
||||
|
||||
### 2026-08-04T14:02:39Z · ila
|
||||
|
||||
2026-08-04 主审补齐导航来源事实:T-106 必须采集四态——闸门二后的确认页导航前状态、确认页闸门三、最终提交控件可见态、人工一次返回后状态;人记录进入确认页时实际单击的精确控件/容器与匹配数,agent 仍只读取证。缺少来源态时 T-107 不得从目标页反推入口。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只实现和运行只读取证能力:设备/App 摘要、`app_current`、screenshot、`compressed=false` XML、
|
||||
hash 与原子 manifest。代码中不得出现 click、press/back、swipe、input、intent 或其他 UI 修改 API,
|
||||
不得自动进入或退出确认页,也不得从前序项目、旧 XML 或推理写生产判据。
|
||||
- 确认页、最终提交控件可见态和一次返回后的页面都由人手工准备;人不得点击最终“提交订单”控件。
|
||||
一次返回结果不明时不得再次按 Back。agent 只采集已准备状态,不远程替人执行这些动作。
|
||||
- 人工验收必须明确规格、数量、应付总额、最终控件精确文案/匹配数/启用态和返回后的页面身份;
|
||||
本任务只记录真机事实,不把文案、坐标、节点或 selector 批准为生产能力。
|
||||
- 取证必须包含四个稳定状态:闸门二通过后的**确认页导航前**页面、确认页闸门三、最终提交控件
|
||||
可见态、一次返回后页面。人还需记录从第一态进入第二态时实际只点击的精确控件文本/容器及当时
|
||||
匹配数量;agent 不执行该点击。缺少来源态时,T-107 不得仅凭“已到确认页”反推导航控件。
|
||||
- 规格面板中出现的“提交订单 ¥...”不得凭文案推断为确认页导航或最终提交控件。任何确认页入口、
|
||||
金额节点、最终控件和返回后置条件都必须等 T-107 从本任务已确认的证据中最小化固化。
|
||||
- 若人工误触最终提交、产生待付款订单、进入支付/安全验证/外部页面或结果不明,立即停止、不重试、
|
||||
不继续取证并如实记录;绝不点击支付、免密支付、先用后付或任何扣款控件。
|
||||
- 原始 screenshot/XML 只留 `%LOCALAPPDATA%\cmbuyer\artifacts\T-106\...`,不提交 Git、不上传采购服务,
|
||||
不把完整 XML、地址、手机号、支付信息、页面全文或本机路径写入普通日志/Vikunja/fixture。
|
||||
- `needs_device: true`:agent 不得自行标 DONE。只有人确认四组证据对应、一次返回安全、未点击最终提交、
|
||||
未创建订单且未进入支付相关页面,任务才可完成。
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
id: T-107
|
||||
title: 固化确认页、闸门三与提交控件只读判据
|
||||
phase: 1
|
||||
deps: [T-106]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 42
|
||||
context_ref: 83b244f
|
||||
work_branch: task/t-107-confirm-gate3
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-107.md
|
||||
- client/src/cmbuyer_client/pdd/order_confirm.py
|
||||
- client/src/cmbuyer_client/pdd/order_confirm_runner.py
|
||||
- client/tests/pdd/test_order_confirm.py
|
||||
- client/tests/pdd/fixtures/order_confirm_source_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/order_confirm_gate3_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/order_submit_control_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/order_confirm_exit_8_17_0.xml
|
||||
- client/scripts/run_t107_order_confirm_dry_run.py
|
||||
- docs/api.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=42 synced=2026-08-04T14:05:17Z sha256=56bb3d2575e60ac2ede9ba9c86178f5eefc4470d06b973d9f701fbc8c91635da -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-106 将形成确认页导航前、闸门三、最终提交控件可见态和一次返回后的四组真机证据。T-107 只从这些已由人确认的本项目事实固化一次性确认页导航、闸门三读取、最终控件纯观察与一次安全返回;绝不点击最终提交。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-011、F-016、F-017 的围栏前只读部分。
|
||||
- 用户故事:US-004、US-005。
|
||||
- 依赖:T-106。
|
||||
- 后续消费者:T-210、T-305、T-208。
|
||||
- 需要真机与人工安全验收。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 从 T-106 四态证据提取只含必要节点且去隐私的最小 fixture;页面版本、前台包、来源页面、导航控件或目标页面任一漂移都零点击。
|
||||
2. go_to_order_confirm 只允许点击 T-106 人工证明的导航前状态中精确唯一的已批准控件一次;点击后等待确认页精确后置条件。超时或结果不明不重试,不使用相似文案、OCR、裸坐标或其他购买按钮兜底。
|
||||
3. observe_gate3 只从确认页读取颜色、尺码、数量和应付总额;必须等于目标规格/数量且金额为规范十进制、不超最高总价。不得从详情页、规格面板或其他数字补值。
|
||||
4. 最终“提交订单”控件由纯 XML observer 只读:记录精确文本、匹配数、启用态和是否存在唯一可点击祖先,但不返回 selector、坐标、节点、祖先句柄或任何可点击对象;observer 不持有 device。
|
||||
5. Gate3Observation 包含规格、数量、确认页金额、最高总价、submit_control_text/match_count/enabled、确认页原始 screenshot_path 和 UTC captured_at。
|
||||
6. 观察完成后 exit_order_confirm_safely 只发送一次 Android Back,并稳定验证 T-106 post-back 正判据;超时/结果不明不重试,SystemUI/锁屏/其他 PDD 页/外部页都不是成功。
|
||||
7. runner 是永久围栏前 dry-run capability,不接受 allow_submit/dry_run=False 开关;composition/import 静态不可达 fence、SubmissionPermit、submit_order_once、payment。
|
||||
8. T-107 不上传证据、不定义 T-205 事件码、不申请围栏、不创建待付款订单。任何失败只停止并等待人工,不自动回 DRAFT/释放/转领。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- fixture 正反例覆盖来源页、唯一导航控件、确认页、规格/数量/金额、最终控件 0/1/2 个、一次返回后置条件与版本漂移。
|
||||
- 动作轨迹证明成功路径仅一个已批准导航点击和一次 Back;导航结果不明与 Back 结果不明均不重试。
|
||||
- Gate3 金额用 Decimal,规格/数量/上限严格;最终控件观察 DTO 不含 selector/坐标/节点/可点击对象。
|
||||
- AST/import closure 证明没有 fence/result/SubmissionPermit/click_permitted/submit_order_once/payment,最终控件 observer 无 device/click。
|
||||
- 真机人工确认闸门三、submit match count=1、一次安全返回、未点击最终提交、未创建订单/进入支付。
|
||||
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 保持 DOING 等人工验收。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T14:04:47Z · ila
|
||||
|
||||
2026-08-04 预研定值:T-107 只消费 T-106 四态证据;确认页导航最多一次,最终提交控件由无 device 的纯 observer 读取且 DTO 不暴露节点/坐标/selector,随后最多一次 Back 并验证返回后置条件。全路径静态不可达提交与付款。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 任何确认页来源判据、导航控件、目标页结构、闸门三节点、最终提交控件和返回后置条件都只能来自
|
||||
T-106 已由人确认的四态真机证据,并与拼多多 8.17.0/goods_id 绑定。不得从前序项目、旧 XML、
|
||||
Activity 名、相似文案、OCR、裸坐标或推理补判据。
|
||||
- `go_to_order_confirm()` 只允许在精确来源页点击 T-106 已证明的唯一导航控件一次;超时或结果不明
|
||||
不重试,不尝试其他购买按钮,不把规格面板中任意“提交订单 ¥...”文本直接当成已批准入口。
|
||||
- 闸门三只从确认页读取颜色、尺码、数量和应付总额;必须严格等于目标规格/数量且金额为规范十进制、
|
||||
不超最高总价。不得用详情页、规格面板或其他位置数字凑齐,不得使用浮点数。
|
||||
- 最终“提交订单”控件只能由不持有 device/click 能力的纯 XML observer 只读。结果可含精确文本、
|
||||
匹配数、启用态和“唯一可点击祖先是否存在”的布尔值,但不得返回 selector、坐标、节点、祖先句柄
|
||||
或任何可点击对象;runner 不得对该控件或祖先调用 click。
|
||||
- 观察完成后只允许一次 Android Back,并必须命中 T-106 固化的稳定 post-back 正判据;超时或结果
|
||||
不明不重试。SystemUI/锁屏、其他 PDD 页面、外部支付/安全验证页或仅“确认页消失”都不是退出成功。
|
||||
- Runner 是永久围栏前 dry-run capability,不接受 `allow_submit`、`dry_run=False` 等开关;构造和
|
||||
import 闭包不得引用 submission fence、SubmissionPermit、`click_permitted`、`submit_order_once()`、
|
||||
结果调和、支付、免密支付、先用后付或任何扣款能力。
|
||||
- T-107 不上传证据、不定义 T-205 事件、不申请围栏、不创建待付款订单。任何失败只停止并等待人工,
|
||||
不自动释放 claim/授权、不回 DRAFT、不转领或重试页面动作。
|
||||
- `needs_device: true`:agent 不得自行标 DONE。只有人确认闸门三、最终控件 match count=1、一次安全
|
||||
返回,且未点击最终提交、未创建订单、未进入支付相关页面,任务才可完成。
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
id: T-205
|
||||
title: purchase attempt、事件与执行摘要 API
|
||||
phase: 2
|
||||
deps: [T-210]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 35
|
||||
context_ref: cd47d0c
|
||||
work_branch: task/t-205-attempt-events
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-205.md
|
||||
- admin/migrations/00007_attempt_events.sql
|
||||
- admin/internal/migrations/migrations_test.go
|
||||
- admin/internal/attempts/**
|
||||
- admin/internal/taskclaim/**
|
||||
- admin/internal/server/**
|
||||
- admin/cmd/server/**
|
||||
- admin/README.md
|
||||
- docs/api.md
|
||||
- docs/04-architecture.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=35 synced=2026-08-04T14:41:47Z sha256=870653415736bd7756b058467f0d6ea0114a78dba1c97ed62115067e07c32e8a -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-302 负责创建并租赁唯一 purchase attempt,T-103 提供已取证的规格选择与闸门一能力。后续单趟执行需要把每个安全步骤以最小结构化事件追加到采购服务,并在围栏前失败时形成不可覆盖的执行摘要。T-205 只记录既有 claim/attempt 的事实和安全失败;不创建 attempt、不释放授权、不建立提交围栏、不点击或付款。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006、F-007。
|
||||
- 用户故事:US-003、US-004、US-005。
|
||||
- 依赖:T-210;T-107/T-204/T-302/T-301 由 T-210 传递满足。
|
||||
- 后续消费者:T-206 状态视图、T-307 客户端 attempt sink、T-305 围栏前 dry-run、T-208 提交围栏。
|
||||
- API:`POST /api/v1/tasks/{id}/events`、`POST /api/v1/purchase-attempts/{aid}/fail`。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 新增 `00007_attempt_events.sql`,保存 append-only attempt events、批次幂等请求和必要执行摘要;事件以 attempt 内单调序号、事件 id/批次 id、固定 step/outcome/reason、服务端接收时间及允许的最小字段组成。数据库约束禁止更新/覆盖旧序号、跨 task/attempt 或围栏后继续写。
|
||||
2. 复用 T-302 的统一 claim validator:每个请求都绑定当前设备 Bearer、session、task、attempt、generation、claim token 和未过期租约;认证在 body 前,业务事务在线性化位置再次验证设备 ACTIVE、claim/authorization/attempt 归属与状态。旧 generation/token、跨设备/会话、租约或授权过期均不能写事件。
|
||||
3. 事件 API 只接受有界批次和严格 JSON。每个事件的 step/outcome/reason 来自版本化白名单,不接收自由页面文本、XML、地址、手机号、路径、token、选择器、坐标或动作脚本。金额为规范正十进制字符串,数量为正整数;未知字段、额外 JSON、序号缺口/倒退/重复异载荷全部拒绝。
|
||||
4. 同一 batch id 同载荷在响应丢失/服务重启后重放原结果;同键异载荷冲突。一个事务内校验整批连续性、步骤状态机和 summary 兼容性后全批追加,任一事件非法则零写入。
|
||||
5. 三条成功步骤固定为:`GATE_1_PASSED` 只携带 color、size、unit_price 和 `SKU_PANEL_GATE_1` asset id;`GATE_2_PASSED` 只携带 color、size、quantity、unit_price 和 `SKU_PANEL_GATE_2` asset id;`GATE_3_PASSED` 只携带 color、size、quantity、confirm_amount、submit_control_text、submit_control_match_count、submit_control_enabled 和 `ORDER_CONFIRM_GATE_3` asset id。每个 asset 必须属于同一 task/attempt/device/generation 且 kind 精确匹配;缺失、错 kind、跨归属或证据未明确成功时整批零写入。
|
||||
6. 第一个明确的执行开始事件只允许把 attempt `CLAIMED→ORDERING`、task `CLAIMED→ORDERING`;不得隐式续租。后续事件只在 ORDERING 中追加。规格、两次规格面板价格、数量读回、确认页金额仅由对应固定成功 step 写入 summary,不能由客户端直接覆盖已有摘要。
|
||||
7. 围栏前 fail API 只接受固定 reason:`DEVICE_UNAVAILABLE`、`APP_VERSION_MISMATCH`、`PRODUCT_OPEN_FAILED`、`SKU_PANEL_UNAVAILABLE`、`SKU_SELECTION_MISMATCH`、`GATE_1_PRICE_INVALID`、`QUANTITY_MISMATCH`、`GATE_2_PRICE_INVALID`、`ORDER_CONFIRM_UNAVAILABLE`、`GATE_3_MISMATCH`、`SUBMIT_CONTROL_NOT_UNIQUE`、`CLAIM_NOT_CURRENT`、`AUTHORIZATION_EXPIRED`、`EVIDENCE_UPLOAD_UNRESOLVED`、`PAGE_RESULT_UNKNOWN`、`STEP_TIMEOUT`、`DRY_RUN_COMPLETED_BEFORE_FENCE`,以及已记录的最后安全步骤。幂等地把当前 attempt 置 FAILED、task 置 NEEDS_MANUAL,并写终止事件/finished_at。authorization 和 claim 保持占用,等待 T-207 人工检查后显式关闭;失败请求不自动回 DRAFT、不释放、不转领。`EXTERNAL_PAYMENT_HANDOFF`、`SECURITY_CHALLENGE` 与 `UNKNOWN` 是围栏后 result observation,不得作为围栏前失败码。
|
||||
8. 已失败、已围栏、存在 order_submission、claim/授权/租约不再有效时拒绝新事件或新的失败结果。围栏相关状态只能由 T-208 创建,T-205 不读取或返回 `click_permitted`。
|
||||
9. 原始截图继续走 T-204/T-306;事件只引用服务端已有的受控 evidence asset id(如需要),不得上传文件或枚举本地证据目录。完整 XML 永不上传。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- Migration 覆盖升级/重开、外键/唯一/append-only 约束、既有事实与安全降级保护。
|
||||
- 覆盖严格 HTTP/JSON、body 前认证、claim/session/generation/token/lease/authorization 校验和跨设备拒绝。
|
||||
- 覆盖批次原子性、序号连续、并发追加、相同 key 重放、异载荷冲突、服务重启恢复。
|
||||
- 覆盖步骤状态机及 summary 单向写入;闸门金额保持十进制字符串,冲突值不得覆盖。
|
||||
- 覆盖 `CLAIMED→ORDERING` 唯一转换、固定失败到 NEEDS_MANUAL、重复失败幂等、失败后无事件、授权/claim 不释放。
|
||||
- 静态检查确认没有 selector/coordinate/free script、claim 创建/续租、围栏、提交订单点击、付款或自动重试能力。
|
||||
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文校验与 diff-check 全部通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T13:44:42Z · ila
|
||||
|
||||
2026-08-04 与 T-302 恢复契约定值:第一个执行开始事件在同一事务把 attempt/task 从 CLAIMED 转为 ORDERING,并把 task.version 恰好递增一次;后续事件不再递增。T-302 同会话恢复只接受 ORDERING 且当前版本等于领取快照版本+1,其他版本漂移转人工。
|
||||
|
||||
### 2026-08-04T14:39:03Z · ila
|
||||
|
||||
2026-08-04 契约收紧:T-205 改为依赖 T-210、迁移 00007;冻结 GATE_1/2/3_PASSED 的完整字段与对应 evidence kind,并冻结围栏前失败码和 DRY_RUN_COMPLETED_BEFORE_FENCE。围栏后 observation 不得伪装成围栏前失败。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- T-205 只向 T-302 已创建且由当前设备/会话持有的 attempt 追加固定结构事件和摘要;不得创建
|
||||
purchase attempt、领取任务、续租、签发或释放授权。所有权、generation、claim token、租约、
|
||||
authorization 与状态任一不匹配时零业务写入。
|
||||
- 事件必须 append-only、attempt 内严格连续且批次全有或全无;重放只能返回原结果。任何旧序号、
|
||||
缺口、异载荷、跨 attempt、跨设备或并发冲突不得覆盖、跳过或重排既有事实。
|
||||
- 只接收版本化白名单中的 step/outcome/reason 及该步骤必需的规范金额、正整数数量和受控引用。
|
||||
不接收自由页面文本、完整 XML、地址、手机号、本机路径、token、动作脚本、选择器或坐标。
|
||||
- 三条成功步骤只能是 `GATE_1_PASSED`、`GATE_2_PASSED`、`GATE_3_PASSED`,且分别引用同一
|
||||
task/attempt/device/generation 下精确 kind 为 `SKU_PANEL_GATE_1`、`SKU_PANEL_GATE_2`、
|
||||
`ORDER_CONFIRM_GATE_3` 的已成功 asset。缺证据、错 kind、跨归属或上传结果不明时零事件写入。
|
||||
- 实际规格、闸门一/二单价、数量读回和确认页金额只能由对应固定成功步骤首次写入执行摘要;后续
|
||||
冲突值必须拒绝,不能最后写入者覆盖。闸门三还必须首次固定提交控件精确文案、匹配数和启用态;
|
||||
金额始终是十进制字符串,不用浮点数。
|
||||
- 第一个执行开始事件必须在同一事务中把 attempt/task 从 `CLAIMED` 转为 `ORDERING`,并把 task
|
||||
`version` **恰好递增一次**;后续事件不得继续递增。T-302 的同会话恢复只可把 ORDERING 解释为
|
||||
领取快照版本加一,其他版本漂移必须转人工,不能用“状态是 ORDERING”跳过版本一致性。
|
||||
- 围栏前失败只把 attempt 置 `FAILED`、task 置 `NEEDS_MANUAL` 并留下终止事实;authorization 与
|
||||
claim 保持占用,等待 T-207 人工检查。不得自动回 `DRAFT`、释放、重新授权、转领或重试页面动作。
|
||||
- 围栏前 reason 必须来自任务方案冻结的精确白名单,包含 `DRY_RUN_COMPLETED_BEFORE_FENCE`;
|
||||
`EXTERNAL_PAYMENT_HANDOFF`、`SECURITY_CHALLENGE` 与 `UNKNOWN` 只属于围栏后 result observation,
|
||||
不得伪装成可恢复的围栏前失败。
|
||||
- 不实现或引用 submission fence、`click_permitted`、订单结果、提交订单点击。不得编写支付、免密
|
||||
支付、先用后付或任何扣款控件代码;第一趟试选/围栏前路径不得可达下单函数。
|
||||
- 不在 T-210 三种证据 kind 之外继续扩展截图,不上传 XML/manifest/目录,不接触真机页面判据或
|
||||
client 代码。
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: T-206
|
||||
title: 单趟状态详情与安全主动作
|
||||
phase: 2
|
||||
deps: [T-205]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 45
|
||||
context_ref: cd47d0c
|
||||
work_branch: task/t-206-status-safe-actions
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-206.md
|
||||
- admin/internal/taskdetail/**
|
||||
- admin/internal/server/task_detail.go
|
||||
- admin/internal/server/task_detail_test.go
|
||||
- admin/internal/transport/webui/webui.go
|
||||
- admin/internal/transport/webui/templates/task-detail.html
|
||||
- docs/routes.md
|
||||
- docs/08-interaction-checklist.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=45 synced=2026-08-04T14:41:45Z sha256=357ce372339f0d744849742f5dd2a38e79d6fc3bef0bd4053287f864fb778460 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-204 已有只读详情底座,T-205 将提供 attempt 事件、执行摘要和围栏前失败事实。当前详情仍不能可靠回答“目前在哪一步、是否已建立围栏、唯一安全下一步是什么”。T-206 只扩展只读详情和安全主动作推导;不实现重置、调和、付款或设备动作。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-004、F-007、F-011、F-017 的状态展示部分。
|
||||
- 用户故事:US-002、US-004、US-008。
|
||||
- 交互:IX-004、IX-009、IX-010、IX-011 的只读状态和下一步提示。
|
||||
- 依赖:T-205;由其传递 T-204、T-210、T-302 及事件/证据事实。
|
||||
- 后续消费者:T-207 围栏前人工恢复、T-208 提交围栏与调和。
|
||||
- 本任务不新增管理或设备写接口。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 在同一个只读事务中加载任务/版本、当前及历史 authorization、T-302 claim 的非秘密摘要、attempt、T-205 append-only 时间线/执行摘要、三闸门 evidence 摘要和 order_submission/调和摘要。设备只展示非秘密名称、generation 和租约状态,不返回 token、nonce 或 session。
|
||||
2. 增加纯函数式 SafePrimaryAction 推导。任何 order_submission 的存在都优先于 task.status;其次处理 RECONCILIATION_REQUIRED、WAITING_PAYMENT,再处理围栏前状态。事实互相矛盾时进入 SAFETY_HOLD,不显示写动作。
|
||||
3. DRAFT 只引导返回列表勾选,不新增逐行开始按钮;PENDING/CLAIMED/ORDERING 只读展示授权、领取和步骤进度;过期授权只提示等待人工关闭,不能表述为可安全重领。
|
||||
4. NEEDS_MANUAL 且无 submission 时显示固定失败码、最后安全步骤和“查看围栏前恢复条件”,本任务只做页内导航;不提前渲染 T-207 的 POST 表单。
|
||||
5. 只要存在 submission,即使 task 仍错误地显示 ORDERING/NEEDS_MANUAL,也必须显示围栏锁定和同一 submission;不显示重置、取消、重新授权或再次提交。
|
||||
6. RECONCILIATION_REQUIRED 只引导查看同一提交;WAITING_PAYMENT 明确“订单已创建,系统尚未付款”并只引导核对证据;标记付款属于 T-402。
|
||||
7. Gate1/Gate2/Gate3 只按服务端固定 kind 和数据库事实展示,不解析图片、不自行计算或推断闸门通过。未知状态、缺少关联记录和归属冲突均 fail closed。
|
||||
8. 完整页和抽屉继续共享同一详情模型与模板,保持返回、焦点、滚动、键盘、响应式及 no-store 证据边界。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 覆盖 DRAFT、PENDING、CLAIMED、ORDERING、NEEDS_MANUAL、WAITING_PAYMENT、RECONCILIATION_REQUIRED、终态、空态和 SAFETY_HOLD 的完整矩阵。
|
||||
- ORDERING/NEEDS_MANUAL 与 order_submission 同时存在时必须显示围栏锁定;绝不出现围栏前恢复动作。
|
||||
- 过期 lease/authorization 只显示事实,不把它推导成手机已停止、可释放或可转领。
|
||||
- 页面不出现旧两趟、中途确认、自动付款、重新提交或普通重试术语;DRAFT 不增加逐行开始采购。
|
||||
- 完整页和抽屉内容一致,匿名请求在查询前拒绝;页面不泄露 token、nonce、session、SQL 或路径。
|
||||
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只读取并展示 T-204/T-205/T-302 已保存的任务、授权、claim、attempt、事件、证据和
|
||||
submission 事实;不得新增管理或设备写接口,不得在读取详情时隐式续租、关闭、释放或改变状态。
|
||||
- 任意 `order_submission` 的存在都必须压倒 `task.status` 和围栏前提示。即使任务错误地仍为
|
||||
`ORDERING` / `NEEDS_MANUAL`,也只能展示同一 submission 的围栏/调和路径,绝不显示重置、取消、
|
||||
重新授权、重新领取或再次提交。
|
||||
- 租约或授权过期只是一项只读事实,不证明手机已经停止,不得把它推导成可释放、可转领或可安全
|
||||
重试。事实缺失、归属不完整或互相冲突时进入 `SAFETY_HOLD`,不猜测、不渲染写动作。
|
||||
- T-206 的安全主动作只允许返回列表、页内定位或查看既有事实;不得提前实现 T-207 重置/取消、
|
||||
T-208 围栏/调和或 T-402 标记付款,不得增加逐行“开始采购”按钮或中途确认。
|
||||
- Gate1/Gate2/Gate3 只按服务端固定 kind、事件和数据库记录展示;不解析图片、不自行认定页面、
|
||||
不重新计算为可提交许可。截图仍只供内部审计,不替代实时三闸门。
|
||||
- 不接触真机页面判据、client 代码、`submit_order_once()` 或任何支付、免密支付、先用后付和扣款能力。
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
id: T-207
|
||||
title: 授权有效期与围栏前人工恢复
|
||||
phase: 2
|
||||
deps: [T-206]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 46
|
||||
context_ref: cd47d0c
|
||||
work_branch: task/t-207-prefence-recovery
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-207.md
|
||||
- admin/internal/taskrecovery/**
|
||||
- admin/internal/taskdetail/**
|
||||
- admin/internal/server/task_recovery.go
|
||||
- admin/internal/server/task_recovery_test.go
|
||||
- admin/internal/server/router.go
|
||||
- admin/internal/transport/webui/webui.go
|
||||
- admin/internal/transport/webui/templates/task-detail.html
|
||||
- admin/cmd/server/**
|
||||
- admin/README.md
|
||||
- docs/api.md
|
||||
- docs/routes.md
|
||||
- docs/08-interaction-checklist.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=46 synced=2026-08-04T14:44:03Z sha256=550593f5197409b814d6c2c2bca81d2d21c65bfc62abd1a3f20b107d837fb9e8 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-205 的围栏前失败只把 attempt 置 FAILED、task 置 NEEDS_MANUAL,并刻意保留 authorization 与 claim;T-302 也明确租约丢失不等于手机停止。T-207 提供管理员人工检查后的围栏前恢复,使旧授权永久关闭后任务回到 DRAFT 或 CANCELED,同时保证任何围栏事实都永远不可释放。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-010、F-011、F-017 的围栏前恢复部分。
|
||||
- 用户故事:US-005、US-008。
|
||||
- 交互:IX-009、IX-010;T-206 负责状态与安全主动作,本任务接通被允许的写操作。
|
||||
- 依赖:T-206;由其传递 T-205/T-302 的 claim、attempt 和详情事实。
|
||||
- API:POST /tasks/{id}/reset-to-draft、POST /tasks/{id}/cancel。
|
||||
- 后续消费者:T-208 submission fence 与恢复/围栏竞态测试。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 管理写请求只接受有效管理员 session + CSRF 和有界严格表单;设备 Bearer 无权调用。请求绑定 expected_task_version、authorization_id、可选 expected_attempt_id、可选 expected_claim_generation、必要时的 confirmed_device_stopped 和安全 return_to。
|
||||
2. 定义围栏前恢复资格:PENDING 且无 attempt/claim/submission 可关闭;NEEDS_MANUAL 且 T-205 attempt 已明确 FAILED、无 submission 可重置/取消;租约过期或设备撤销但 attempt 曾进入 CLAIMED/ORDERING 时,只有管理员明确确认设备已安全停止才可继续。活跃租约、仍在 ORDERING 且无明确停止事实时拒绝。
|
||||
3. authorization/attempt 已 FENCED 或任何关联 order_submission 存在时永久拒绝,不受 task.status、租约、授权过期、人工确认或调和终态影响。数据归属缺失或互相冲突时 fail closed。
|
||||
4. 在一个有界 SQLite 写事务中先取得写入线性化位置,再重新读取 task、authorization、attempt、claim 和 submission。首先检查 submission;随后精确比较版本、authorization、attempt 和 generation,最后校验恢复资格。
|
||||
5. 成功只做状态关闭,不删除事实:过期 authorization 转 EXPIRED,其余围栏前关闭转 ABANDONED;CLAIMED/ORDERING attempt 可安全关闭为 ABANDONED,已 FAILED 保持 FAILED;claim 留存并只写 closed_at,恢复原因由 authorization/attempt/task 的固定终态组合表达,不新增自由文本字段;task 转 DRAFT 或 CANCELED、version 恰好加一。
|
||||
6. 本任务不新增 migration。利用旧 authorization/attempt/claim 终止足迹和 task 版本形成自然幂等:相同旧版本与旧授权的相同终态请求稳定返回原成功;一旦出现新版本/新授权,旧请求必须冲突,不能关闭新授权。
|
||||
7. 重置成功不创建新授权。重新开始必须回到列表,由管理员重新点击“开始采购(只创建待付款订单)”并通过 T-203 产生新版本、新授权。
|
||||
8. T-206 详情仅在服务端计算确定可恢复时渲染 POST 表单。重置是高影响动作,明确说明旧授权永久关闭和必须重新开始;取消为独立后果,不把两者混为同一动作。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 覆盖无领取 PENDING、明确 FAILED、租约过期但未证明停止、人工停止确认、活跃 ORDERING 及归属冲突。
|
||||
- 覆盖 expires_at 边界相等、旧 generation、task/version 漂移、不同 authorization/attempt 和设备撤销。
|
||||
- 覆盖响应丢失、服务重启、同请求重放、同键异终态、reset/cancel 并发和新授权后旧请求重放。
|
||||
- 覆盖恢复与模拟围栏插入并发:只要任一 order_submission 已存在或先提交,恢复必须零释放;已 MANUAL_RESOLVED 也仍拒绝。
|
||||
- 成功后 authorization、claim、attempt、events、evidence 全部保留;只改变获准状态,不删除审计事实。
|
||||
- 管理/设备身份隔离、CSRF、请求大小/字段、错误不泄露均有测试。
|
||||
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 只有有效管理员 session + CSRF 的显式人工动作可以执行围栏前重置或取消;设备 Bearer、后台过期
|
||||
扫描、租约超时、设备撤销、停止轮询和进程重启都不得自动关闭授权或把任务送回 `DRAFT`。
|
||||
- 租约过期或设备撤销不证明手机已停止。曾进入 `CLAIMED` / `ORDERING` 且没有 T-205 明确失败事实
|
||||
的 attempt,必须由管理员明确确认设备已安全停止;活跃执行或事实不完整时一律拒绝恢复。
|
||||
- 事务必须先取得写入线性化位置,并把任何关联 `order_submission` 的存在作为最高优先级硬拒绝。
|
||||
authorization/attempt 已 `FENCED`,或 submission 已 `FENCED`、`SUBMITTED`、
|
||||
`RECONCILIATION_REQUIRED`、`MANUAL_RESOLVED`,均不得释放、取消、重置或重新授权。
|
||||
- 恢复只能关闭既有围栏前状态并保留全部审计事实;不得删除 authorization、claim、attempt、event、
|
||||
evidence 或 submission。重置不得创建新授权,重新开始必须由管理员重新执行 T-203。
|
||||
- 自然幂等必须绑定旧 task version、旧 authorization、预期 attempt/generation 和精确终态;不能仅因
|
||||
当前已是 `DRAFT` / `CANCELED` 就返回成功。出现新版本或新授权后,旧请求必须冲突,不能影响新事实。
|
||||
- 本任务不建立、查询为许可或模拟 submission fence,不实现设备 result、人工 submission 调和、
|
||||
真机动作、点击“提交订单”或任何支付、免密支付、先用后付和扣款能力。
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
id: T-208
|
||||
title: 三闸门提交围栏与结果调和 API
|
||||
phase: 2
|
||||
deps: [T-207, T-210]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 47
|
||||
context_ref: cd47d0c
|
||||
work_branch: task/t-208-submission-fence
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-208.md
|
||||
- admin/migrations/00008_submission_fence.sql
|
||||
- admin/internal/migrations/migrations_test.go
|
||||
- admin/internal/domain/submission.go
|
||||
- admin/internal/domain/submission_test.go
|
||||
- admin/internal/submission/**
|
||||
- admin/internal/taskclaim/**
|
||||
- admin/internal/taskdetail/**
|
||||
- admin/internal/server/submission_fence.go
|
||||
- admin/internal/server/submission_fence_test.go
|
||||
- admin/internal/server/router.go
|
||||
- admin/internal/transport/webui/webui.go
|
||||
- admin/internal/transport/webui/templates/task-detail.html
|
||||
- admin/cmd/server/**
|
||||
- admin/README.md
|
||||
- docs/api.md
|
||||
- docs/04-architecture.md
|
||||
- docs/routes.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=47 synced=2026-08-04T14:41:55Z sha256=a1109c837b096ecebfbda2200aa3c95c84b31ac2ff8e52404acb95060f148b3b -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-205 将保存同一 attempt 的 append-only 三闸门事件和执行摘要,T-210 将开放 Gate1/Gate2/Gate3 三种固定证据资产,T-207 只允许围栏前人工恢复。真实点击“提交订单”前仍缺少服务端唯一提交围栏、首次一次性许可、围栏响应不明处理、点击后一次性结果以及同一 submission 的人工调和。T-208 只实现采购服务端围栏与调和;不连接真机、不点击提交订单、更不付款。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006、F-007、F-011、F-017。
|
||||
- 用户故事:US-004、US-005、US-008。
|
||||
- 交互:IX-009、IX-011;围栏由后台申请,Web 只显示和调和同一 submission。
|
||||
- 依赖:T-207、T-210;显式保留 T-210 作为三证据类型安全门禁。T-207 传递 T-205/T-302,T-210 传递 T-107/T-204/T-302。
|
||||
- API:POST /api/v1/purchase-attempts/{aid}/submission-fence、POST /api/v1/order-submissions/{sid}/result、POST /order-submissions/{sid}/reconcile。
|
||||
- 后续消费者:T-401 单趟真实提交。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 新增 `00008_submission_fence.sql`,保存 fence request 幂等事实、唯一 order_submission proof、三个 gate evidence 外键、一次性 result request、append-only 人工调和记录,以及冻结的 device/session/claim generation/claim token hash 归属。authorization 与 attempt 各最多一个 submission;降级遇到新事实原子拒绝。
|
||||
2. 设备认证必须在读取 body 前完成;严格 JSON 请求绑定 task/version、authorization、attempt、session、generation、claim token、最后事件序号、规格、两次单价、数量、确认页金额、精确 submit_control_text/match_count 和三个 evidence asset id,不接受未知字段、自由页面文本、XML、路径、selector、坐标或动作脚本。
|
||||
3. `operation=CREATE_ONCE` 才可能创建围栏。事务必须先取得 SQLite 写线性化位置并再次确认设备 ACTIVE,再复用 T-302 的 current claim validator:设备/session/task/authorization/attempt/generation/token 全部一致,claim lease 与 authorization 严格未过期,task/attempt 为 ORDERING,authorization 为 CLAIMED。当前 validator 不得为结果上报放宽。
|
||||
4. 围栏事务逐字段比对请求、T-205 append-only 事件、purchase attempt summary 与 T-210 evidence assets。三个资产必须精确为 SKU_PANEL_GATE_1、SKU_PANEL_GATE_2、ORDER_CONFIRM_GATE_3,属于同一 task/attempt/device;服务端只验证 kind/归属/hash 事实,不解析图片内容。
|
||||
5. 服务端重新执行三闸门:颜色/尺码精确等于授权;Gate1/Gate2 为规范十进制正金额且完全相等;Gate1 × 授权数量不超 total_price_cap;数量精确相等;Gate3 规格/数量一致且确认页总额不超上限;submit_control_text 精确等于“提交订单”,可点击祖先计数精确为一。任一缺失、歧义、顺序错误或冲突都零围栏、零许可。
|
||||
6. 首次 CREATE_ONCE 在一个事务中创建唯一 submission,并把 authorization CLAIMED→FENCED、attempt ORDERING→FENCED,使 task 永久不可重领。只有这次首次明确成功响应可含 `click_permitted=true`;许可不是数据库中可重取的开关,也不能由业务代码手工构造。
|
||||
7. 同 fence_key 同规范 proof 的任何重放都返回同一 submission_id,但必须 `click_permitted=false`、`reconciliation_required=true`;同 key 异 proof、不同 key 命中同 authorization/attempt、并发失败或服务重启都绝不再次许可。
|
||||
8. `operation=LOOKUP_ONLY` 只用于围栏响应不明后的同键调和:永远不创建 submission、永远不许可点击。找到原围栏时返回同一 submission 并保持/转入调和;找不到时明确无围栏事实,但恢复路径仍不得重新申请首次点击许可。
|
||||
9. 围栏后的 result 不再要求活跃租约,也不得放宽 current claim validator;新增独立 fenced ownership validator,只按 order_submission 冻结的 device/session/generation/token hash 验证原设备归属。它不能创建、续租、释放或转领 claim。
|
||||
10. 每个 submission 只接受一条一次性 result 事实。SUBMITTED 转 WAITING_PAYMENT 并永久消费授权;EXTERNAL_PAYMENT_HANDOFF、SECURITY_CHALLENGE、UNKNOWN 转 RECONCILIATION_REQUIRED 并保持围栏。相同 result_key 同载荷稳定重放,不同 key/observation 不得覆盖。没有 retry observation。
|
||||
11. 本任务从 result wire 移除旧 `evidence_asset_id`。首次真实提交后的页面尚未经本项目真机取证,不新增 SUBMISSION_RESULT kind;UNKNOWN 允许无截图。绝不把 Gate3 图冒充结果图,绝不上传外部支付页、支付凭据或完整 XML。后续结果截图必须另立取证和 evidence kind 任务。
|
||||
12. 管理调和只处理指定 submission,结论固定为 CONFIRMED_CREATED、CONFIRMED_NOT_CREATED_OR_CANNOT_COMPLETE、STILL_UNKNOWN;记录 reviewer、固定结论、服务端时间和幂等键,不触发设备动作。前两种形成最终人工结论后 authorization 永久 CONSUMED;STILL_UNKNOWN 保持围栏和待调和。
|
||||
13. T-206 详情显示同一 submission 的 proof、首次许可事实、result 和调和记录。任何围栏后页面都没有重置、取消、重新授权、重新领取、再次提交或普通重试入口;WAITING_PAYMENT 仍明确系统尚未付款。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 两设备、同设备、同/异 key 并发中最多一个首次响应拥有 `click_permitted=true`;事务回滚时零 submission/状态变化。
|
||||
- 覆盖首次成功、同键重放、响应截断、服务重启、异载荷/异 key 冲突;所有非首次路径 click_permitted 都为 false。
|
||||
- LOOKUP_ONLY 找到/未找到、并发和重启都零创建、零许可;围栏响应不明不通过新 key 或恢复模式获得许可。
|
||||
- 覆盖错误/缺失/跨 task/attempt/device 的三类 evidence、事件缺口/乱序、summary 冲突,以及规格、数量、金额、上限、提交文本和 match count 全部负例。
|
||||
- 覆盖 current claim validator 的 lease/authorization 边界;静态和运行测试证明 fenced ownership validator 只验证冻结归属,不能被用于领取、续租、围栏创建或释放。
|
||||
- 覆盖 fence 与 T-207 reset/cancel 并发;任一 submission 一旦存在,所有 authorization 状态和人工结论下都永久拒绝释放。
|
||||
- 覆盖四种 result、同键重放、异 observation 冲突、UNKNOWN 无截图,以及三种人工调和结论;任何结果均不开放第二次点击。
|
||||
- Migration 覆盖升级/重开、唯一/FK/trigger、已有安全事实、降级拒绝和 foreign_key_check。
|
||||
- 静态检查没有 client/PDD selector、通用点击、submit_order_once、结果截图 kind、支付、免密支付、先用后付或扣款能力。
|
||||
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- `CREATE_ONCE` 建立围栏时必须复用 T-302 的 **current claim validator**,严格要求当前设备、session、
|
||||
task、authorization、attempt、generation、claim token、未过期 lease/authorization 与当前状态
|
||||
全部一致。不得为了让围栏或结果流程跑通而放宽、复活或绕过该 validator。
|
||||
- 围栏后的 result 必须使用独立 **fenced ownership validator**,只验证 order_submission 已冻结的
|
||||
device/session/generation/token hash 归属。它不得被用于领取、续租、创建第二个围栏、释放、转领,
|
||||
也不得反向削弱 current claim validator。
|
||||
- 只有首次 `CREATE_ONCE` 原子创建唯一 submission 的明确成功响应可以包含
|
||||
`click_permitted=true`。同 key 重放、异 key 冲突、并发输家、服务重启、响应不明恢复及任何
|
||||
`LOOKUP_ONLY` 都必须 `click_permitted=false`;许可不得作为可重取数据库开关或手工构造对象。
|
||||
- `LOOKUP_ONLY` 永远零创建、零许可。围栏响应不明时不得换 key、再次申请首次许可或凭页面猜测点击;
|
||||
找到原围栏只能调和同一 submission,找不到也只能按围栏前安全失败处置。
|
||||
- 围栏事务必须逐字段比对请求、T-205 append-only events/summary 和 T-210 三类 evidence 资产,
|
||||
并在服务端重算三闸门与精确唯一“提交订单”控件。缺失、歧义、跨 task/attempt/device、价格/规格/
|
||||
数量冲突或事件乱序均零围栏、零许可。
|
||||
- 本任务不新增 `SUBMISSION_RESULT` 或其他结果截图 kind,并从 result wire 移除旧
|
||||
`evidence_asset_id`。首次真实提交后的页面尚未完成本项目真机取证;`UNKNOWN` 允许无截图,不得把
|
||||
Gate3 图片冒充结果图,外部支付页、支付凭据和完整 XML 永不上传。
|
||||
- 任一 submission 建立后,authorization、attempt 和 submission 永久不可释放、重置、取消、重新
|
||||
授权、重新领取或再次点击。result 或人工调和只能收紧到 `WAITING_PAYMENT` / `FAILED` / 保持
|
||||
`RECONCILIATION_REQUIRED`,绝不能回到围栏前状态。
|
||||
- 本任务只实现服务端围栏、一次性结果和同一 submission 调和;不修改 client/PDD 页面判据,不连接
|
||||
真机,不实现 `submit_order_once()`,不点击“提交订单”,不编写支付、免密支付、先用后付或扣款能力。
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: T-210
|
||||
title: 扩展闸门二与闸门三证据类型
|
||||
phase: 2
|
||||
deps: [T-107, T-204, T-302]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 43
|
||||
context_ref: cd47d0c
|
||||
work_branch: task/t-210-gate-evidence-kinds
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-210.md
|
||||
- admin/migrations/00006_gate_evidence_kinds.sql
|
||||
- admin/internal/migrations/migrations_test.go
|
||||
- admin/internal/evidence/**
|
||||
- admin/internal/storage/evidence/**
|
||||
- admin/internal/server/evidence_test.go
|
||||
- docs/api.md
|
||||
- docs/04-architecture.md
|
||||
- docs/06-tasks.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=43 synced=2026-08-04T14:39:13Z sha256=db95cfcdb109c264b188336cbf1625411d3457baf8525532613aac7e7487c3fd -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-204 当前数据库和 API 只允许 SKU_PANEL_GATE_1;T-107 将以真机证据批准闸门二规格面板和闸门三确认页截图。T-210 在不改变上传身份、大小、隐私或幂等边界的前提下,安全扩展两个固定证据类型,为 T-305 提供完整三闸门审计。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-007、F-013、F-017 的证据部分。
|
||||
- 依赖:T-107、T-204、T-302。
|
||||
- 后续消费者:T-205、T-305、T-208。
|
||||
- 只修改采购服务证据 schema/validation,不修改 client 或页面判据。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 新增 00006 迁移,把 evidence kind 白名单精确扩展为 SKU_PANEL_GATE_1、SKU_PANEL_GATE_2、ORDER_CONFIRM_GATE_3;保留所有旧 gate1 资产和审计身份,不把 kind 变成任意字符串。
|
||||
2. SQLite 表重建/约束迁移必须在事务内保持 foreign_keys=ON,迁移后运行 foreign_key_check;保留 T-302 claim ownership trigger、唯一键、索引、已有外键和所有列约束。不得通过关闭 FK 或丢弃历史行绕过。
|
||||
3. 降级只在没有 gate2/gate3 资产时允许;存在新事实必须原子拒绝,不能静默把新 kind 改名为 gate1 或删除记录。
|
||||
4. 领域白名单、multipart handler、store 与返回 DTO 只增加这两个常量;设备认证仍先于 Content-Type/body,首次写入仍要求当前设备未关闭 claim,closed claim 只允许旧 key replay。
|
||||
5. 三个 kind 均只接受单个显式 INTERNAL_RAW PNG,沿用 10 MiB、像素、SHA-256、原子文件发布、固定字段和不上传 XML/path 的边界。
|
||||
6. kind 与页面事实固定映射:Gate1/Gate2 只能规格面板,Gate3 只能订单确认页。服务端不解析图片或推断页面;真实性由 T-107/T-305 事件顺序和人工验收保证。
|
||||
7. 不增加支付页、待付款页、任意截图或自定义 kind,不新增页面自动化、事件写入、围栏、结果或付款能力。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 迁移覆盖升级/重开/保留 gate1、全部约束/trigger/index/FK、错 kind、down 无事实成功和有新事实原子拒绝。
|
||||
- HTTP/store 覆盖三种精确 kind、大小写/相似/未知拒绝、设备 A/B 隔离、closed claim replay/new key 拒绝。
|
||||
- 原始 PNG 可含页面显示地址/手机号;仍不解析为字段,不接收 XML/manifest/path/支付页/支付凭据。
|
||||
- 静态检查没有 client/PDD、event/fail/fence/result、提交订单或付款能力。
|
||||
- Go full/race/vet/build、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T14:06:35Z · ila
|
||||
|
||||
2026-08-04 从 T-305 拆出服务端证据类型扩展:只在 T-107 取证批准后增加固定 Gate2/Gate3 kind,保留 Gate1 与 T-302 ownership/replay 边界;不让 T-306 或 dry-run 临时改 schema。
|
||||
|
||||
### 2026-08-04T14:39:05Z · ila
|
||||
|
||||
2026-08-04 依赖审计反转 T-210/T-205:三种 evidence kind 必须先进入真实 SQLite schema,T-205 才能对 Gate1/2/3 成功事件做真实正向与错 kind/跨归属测试。T-210 改为 00006,依赖 T-107/T-204/T-302。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 证据类型只允许精确的 `SKU_PANEL_GATE_1`、`SKU_PANEL_GATE_2`、
|
||||
`ORDER_CONFIRM_GATE_3`;不得接受自由 kind、别名、大小写变体或客户端自定义值。
|
||||
- 升级必须保留既有闸门一 asset、哈希、attempt/task/authorization/generation 归属、append-only
|
||||
约束与 T-302 claim 所有权触发器。迁移期间保持 `foreign_keys=ON`,完成后必须通过
|
||||
`foreign_key_check`;不得删除、重建为空或改写既有证据事实。
|
||||
- 降级仅在库中不存在新增 kind 的事实时允许;一旦已有闸门二或闸门三证据必须拒绝降级,不能静默
|
||||
丢弃、改名或降为闸门一。
|
||||
- 三种证据继续只接受 T-204 已定值的 PNG、大小/分辨率/哈希/幂等与 `INTERNAL_RAW` 边界;不得
|
||||
扩展文件类型、上传 XML/manifest/目录、提取或结构化地址与手机号。
|
||||
- 本任务只扩展服务端证据 allowlist 与契约,不修改 client/PDD 页面判据,不实现事件、围栏、结果、
|
||||
提交订单点击或任何付款能力。
|
||||
+6
-2
@@ -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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
id: T-302
|
||||
title: 已授权任务原子领取与租约(F-005)
|
||||
phase: 3
|
||||
deps: [T-301, T-203]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 34
|
||||
context_ref: 2331882
|
||||
work_branch: task/t-302-atomic-claim
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-302.md
|
||||
- admin/migrations/00005_task_claims.sql
|
||||
- admin/internal/migrations/migrations_test.go
|
||||
- admin/internal/taskclaim/**
|
||||
- admin/internal/config/**
|
||||
- admin/internal/storage/evidence/**
|
||||
- admin/internal/taskdetail/store_test.go
|
||||
- admin/internal/server/**
|
||||
- admin/cmd/server/**
|
||||
- admin/README.md
|
||||
- docs/api.md
|
||||
- docs/04-architecture.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=34 synced=2026-08-04T14:33:59Z sha256=85cf1ed0ce7ba219accfaee3bcbb39c9f1f584541446ea37df846db9ac63fa1e -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-203 已把管理员明确授权的任务原子转为 PENDING,T-301 已提供可撤销的真实设备身份,但采购工具还不能安全领取任务。T-302 建立服务端原子领取、幂等重放和租约续期边界,使同一授权只能产生一条可恢复 attempt;不实现客户端轮询、页面执行、事件、提交围栏或结果上报。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-005。
|
||||
- 用户故事:US-003、US-005、US-007。
|
||||
- 交互:为 IX-007、IX-008 提供服务端契约;本任务无新增管理页面。
|
||||
- 依赖:T-301、T-203;后续消费者:T-303、T-205、T-208、T-306。
|
||||
- API:`POST /api/v1/tasks/claim-next`、`POST /api/v1/tasks/{id}/lease/renew`。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 新增 `00005_task_claims.sql`。用独立 claim、claim request、lease renewal 表绑定 task、authorization、attempt、设备、session、generation、随机 nonce、token hash 和租约;同一授权最多一个 attempt,同一设备最多一个未关闭 claim。迁移遇到无法推断归属的既有 attempt/submission/evidence 事实时拒绝升级,已有领取事实时拒绝降级。
|
||||
2. Claim token 使用独立 `CMBUYER_CLAIM_TOKEN_SECRET`(64 位小写十六进制,解码为 32 字节),不复用管理员 session 或设备 token。每条 claim 生成 32 字节随机 nonce,用带版本域分隔的 HMAC-SHA256 绑定 device/task/authorization/attempt/generation/nonce;返回 64 位小写十六进制 token,数据库只保存 nonce 和 token SHA-256。服务重启后可重建同一 token;secret 不匹配已有 claim 时启动失败闭合。
|
||||
3. 新增显式 `CMBUYER_CLAIM_LEASE_TTL`,必须为正且严格短于授权 TTL。领取和续租只使用服务端 UTC 时间;边界相等视为已过期,没有宽限或隐式续租。
|
||||
4. `claim-next` JSON 只接受规范 UUIDv4 的 `session_id`、`claim_request_id`;设备 id 只来自 T-301 认证主体。严格校验 Content-Type、UTF-8、大小、未知字段和额外 JSON。设备认证仍先于请求体读取。
|
||||
5. Claim/renew 的有界 SQLite 事务必须先取得写入线性化位置,并以条件更新确认设备仍为 ACTIVE(RowsAffected 必须为 1);只有在此之后才可读取或重放 request、返回 EMPTY/冲突、选择候选或续租。领取再稳定选择最早的 `PENDING + ACTIVE + 未过期 + task/version/规格/数量/总价快照一致` 授权,创建 attempt/claim/request,并原子执行 authorization `ACTIVE→CLAIMED`、task `PENDING→CLAIMED` 且 version+1。任一步行数或约束不符全部回滚;并发设备只能一个成功。SQLite 无行锁,不得用先 SELECT 或先返回 replay 代替该写入线性化。
|
||||
6. 同一 claim request 同载荷在响应丢失和服务重启后返回原结果与同一 claim token;无候选也持久化 EMPTY 并稳定重放。同一设备已有未关闭 claim 时,同 session 且租约有效重放原 attempt;不同 session、租约过期或状态异常固定返回需人工处理,不转领、不创建第二个 attempt。
|
||||
7. 续租请求绑定 renew_request_id、session、attempt、generation、claim token 和 expected_lease_expires_at。只允许原设备/会话/attempt,当前租约和授权都严格未过期,且 expected 值精确匹配;新到期时间为 `min(server_now + lease_ttl, authorization.expires_at)`。同键同载荷稳定重放,异载荷冲突;续租不改变 token、generation、任务版本或业务状态,过期租约不能复活。
|
||||
8. 设备撤销与领取并发必须在线性化位置再次查 ACTIVE:撤销先提交则领取/续租失败,领取先提交后撤销不自动释放 claim。撤销、租约过期、停止轮询均不能证明手机已停止,另一设备不得自动接管;后续 T-207 负责人工安全恢复。
|
||||
9. 用统一 claim 所有权校验收紧 T-204 证据存储:设备只能向自己当前 attempt 上传截图;不得因本任务扩大截图 kind、文件类型或隐私边界。
|
||||
10. 本任务不新增 heartbeat/events/fail/fence/result,不实现 client HTTP、真机选择器、下单函数、提交订单点击或任何付款动作。claim token 永远不是提交许可,响应不得包含自由动作脚本、坐标、选择器或 `click_permitted`。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- Migration 覆盖升级/重开、外键/唯一/partial index、既有事实拒绝升级和有领取事实拒绝降级。
|
||||
- 覆盖领取 eligibility、快照一致、稳定排序、事务回滚、两设备并发唯一、同设备单开放 claim、成功/EMPTY/冲突幂等及服务重启重放。
|
||||
- 覆盖 HMAC 域隔离、nonce 随机、数据库无明文、错误 secret 启动失败、跨设备/attempt/generation/token 拒绝和常量时间比较。
|
||||
- 覆盖续租 CAS、授权到期封顶、无宽限、过期不复活、乱序/并发/同键异载荷,以及续租不改变业务状态。
|
||||
- 覆盖撤销并发线性化、认证失败 body 零读取、设备 A 不能上传设备 B attempt 证据。
|
||||
- HTTP 错误固定且不泄露 token、SQL、路径或候选任务;静态检查确认没有事件、围栏、提交、付款或页面自动化能力。
|
||||
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文校验与 diff-check 全部通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T13:03:26Z · ila
|
||||
|
||||
2026-08-04 开始 T-302:依赖 T-301、T-203 均已完成,任务定义提交 590c846。实现范围限于服务端原子 claim/renew、可恢复 HMAC claim token、租约 CAS、撤销并发线性化和证据 attempt 所有权收紧;不新增事件、围栏、提交、付款、客户端 HTTP 或真机能力。工作分支 task/t-302-atomic-claim。
|
||||
|
||||
### 2026-08-04T13:10:15Z · ila
|
||||
|
||||
2026-08-04 预实现审计收紧撤销线性化:claim/renew 事务必须先取得 SQLite 写入位置并条件确认设备 ACTIVE,之后才允许 request 重放、EMPTY/冲突返回或业务写入;只 SELECT 或先返回 replay 均不成立。同时补充 docs/current-state.md 为显式 write_path,避免实现后共享文档越界。
|
||||
|
||||
### 2026-08-04T13:11:10Z · ila
|
||||
|
||||
2026-08-04 write_path 更正:上下文门禁发现 T-103 仍为 DOING 且已拥有共享文档 docs/current-state.md,T-302 同时声明会违反唯一写入者规则,因此不纳入、不修改。T-302 完成事实先记录在本任务;待 T-103 释放路径后由项目级文档同步任务统一更新。撤销线性化 P1 收紧不变。
|
||||
|
||||
### 2026-08-04T13:15:18Z · ila
|
||||
|
||||
2026-08-04 预实现审计继续定值:claim 的 CLAIMED/EMPTY/需人工结果与 renew 成功都必须持久化幂等,同键重放不再次 CAS/延长;secret 启动时拒绝与 session 原始值或 key bytes 相同,并拒绝命中任何设备 token hash,所有 claim 逐行重建恒定时复核;证据首次写入要求同设备未关闭 claim,但已成功 upload_key 在 claim 关闭后仍先按原载荷稳定重放。HTTP JSON 上限 4096 bytes;claim 200、EMPTY 204,renew 200;400/413/415 固定错误,401/503 空,409 只用 idempotency_conflict、claim_requires_manual、claim_not_current。
|
||||
|
||||
### 2026-08-04T14:32:06Z · ila
|
||||
|
||||
2026-08-04 完成:实现已授权任务原子 claim/renew、可恢复 HMAC claim token、租约 CAS、设备撤销线性化、attempt/generation 四列数据库约束及 evidence claim owner 收紧。三轮独立审计后修复 ORDERING/CLAIMED 状态版本一致性、generation lineage、双 sql.DB 确定性并发测试和 API UUID/token 示例。主 agent 与终审均通过 focused -count=25、go test、race、vet、build、完整 init(client 112 tests)、上下文和 diff-check;未实现 event/fence/submit/payment。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 只领取 `PENDING` 且存在同版本 `ACTIVE`、未过期、完整快照一致的授权;领取事务必须再次验证
|
||||
设备仍为 `ACTIVE`。任何缺失、畸形、并发冲突或存储异常都失败闭合,不能用应用层先读后写代替
|
||||
数据库条件更新与唯一约束。
|
||||
- 一条授权最多创建一个 attempt;同一设备最多一个未关闭 claim。响应丢失、服务重启、同请求重放、
|
||||
续租或同会话恢复均不得递增 generation、轮换 claim token、领取另一任务或创建第二条 attempt。
|
||||
- Claim token 是 attempt 归属凭据,不是采购授权,更不是提交许可。明文不得进入 SQLite、日志、错误、
|
||||
Git、Vikunja 或测试 fixture;HMAC secret 必须与管理员 session secret、设备 token 分离。错误 secret
|
||||
面对已有 claim 时必须拒绝启动,不能签发替代 token。
|
||||
- Secret 分离必须由启动检查执行:claim secret 的原始配置或解码 key 不得等于 session secret,
|
||||
其 SHA-256 不得命中任何设备 token hash;所有既有 claim(包括以后已关闭的)都必须用当前 secret
|
||||
逐条重建并恒定时复核,不能只检查开放 claim,也不能为通过启动而改写旧 hash。
|
||||
- 租约和授权边界相等即过期,无宽限。过期租约不得续租、复活、自动关闭 attempt、释放授权或转给
|
||||
另一设备;设备撤销、停止轮询和进程退出也不得触发自动接管。安全恢复归 T-207。
|
||||
- 设备身份认证仍必须在解析 Content-Type 或读取 body 前完成;claim/renew 事务的第一条数据库业务
|
||||
语句还必须先取得 SQLite 写入线性化位置并条件确认设备 `ACTIVE`,之后才允许查询或重放 request、
|
||||
返回 EMPTY/冲突或写业务事实。认证、token、session、generation、当前租约或快照任一不匹配均
|
||||
不得产生业务写入。
|
||||
- 本任务只增加 claim/renew,并按 claim 所有权收紧已有证据上传;不得新增或提前实现 heartbeat、event、
|
||||
fail、submission-fence、result、客户端 HTTP 适配、截图 kind 或页面自动化。
|
||||
- 证据首次写入必须属于当前认证设备的未关闭 claim;但相同设备与 `upload_key` 已成功落库的同载荷
|
||||
在 claim 后续关闭后仍须先按 T-204 原结果稳定重放,不能把关闭 claim 变成幂等契约失效。
|
||||
- 不接触拼多多页面判据、规格选择、数量、确认页或真机流程;不编写或引用点击“提交订单”的代码,
|
||||
不编写支付、免密支付、先用后付或任何扣款控件代码。响应不得返回自由动作脚本、坐标、选择器或
|
||||
`click_permitted`。
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
id: T-303
|
||||
title: 客户端 HTTP 任务源、证据 sink 与可恢复本地状态
|
||||
phase: 3
|
||||
deps: [T-002, T-204, T-302]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 36
|
||||
context_ref: bd4c855
|
||||
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
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=36 synced=2026-08-04T13:41:52Z sha256=8de92c2f530442762dddf5c3a7b73115fb9c293bb3838ff01485feee0edade48 -->
|
||||
## 问题 / 背景
|
||||
|
||||
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。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- MVP 设备 Bearer 只允许发往精确 `http://127.0.0.1:8080`;不得接受用户信息、远程 host、其他
|
||||
端口、重定向、系统代理或明文局域网地址。每个 transport 方法最多发送一次请求,重试决策必须
|
||||
留给持有原幂等键的上层恢复流程。
|
||||
- 设备 token 与 claim token 只能以当前 Windows 用户范围 DPAPI 密文进入本地 SQLite;不得以明文
|
||||
进入数据库/WAL、日志、异常、Git、Vikunja 或测试 fixture。DPAPI、SQLite、密文或状态校验失败时
|
||||
必须在零 HTTP 下失败闭合;生产非 Windows 不得降级为明文存储。
|
||||
- Claim/renew/evidence 的幂等键和完整载荷必须先持久化再发请求。超时、断网、503、截断或非法 2xx
|
||||
只能重放同一键同一载荷;不得生成新 key、领取第二条、续租复活、替换截图或覆盖本地 active claim。
|
||||
- 停止轮询、Esc、关闭窗口和进程退出都不 release/abandon 当前 claim,也不自动恢复真机页面动作。
|
||||
已发 claim 的未知结果和 active claim 必须跨重启保留;租约过期交 T-207 人工处理,不自动转领。
|
||||
- T-303 必须向 T-304 暴露唯一的原子 profile/session/recovery API;T-304 不得另建 SQLite、DPAPI
|
||||
或 mutex 状态源。服务端尚无 heartbeat 时,不得把 `claim-next` 当作配置页连接探测,否则“测试连接”
|
||||
会产生 EMPTY 幂等事实甚至领取任务;配置页只能说明凭据将在真实领取时验证。
|
||||
- EvidenceSink 只传调用方显式给出的单个 PNG 和 T-204 固定字段;不得枚举目录、读取或上传 XML、
|
||||
manifest、本机路径、外部支付页或支付凭据,不得自行向 multipart 增加 claim token/session 字段。
|
||||
- Localstate 必须按 `(attempt_id, evidence_kind)` 提供唯一、原子的上传槽:首次 HTTP 前持久化 upload key、
|
||||
完整元数据、文件身份和 hash;结果不明只能恢复同一槽,同一槽成功后继续保留服务端 AssetRef,后续
|
||||
调用只能返回该结果,不能清掉 pending 后生成新 key 或上传第二张。T-306 不得另建 SQLite 绕过它。
|
||||
- 本任务不修改 `app.py` 或实现配置/轮询 UI,不连接真机,不导入 PDD 点击能力。T-304 负责桌面交互,
|
||||
T-306 负责把真实截图接入 sink。
|
||||
- 不伪造尚不存在的 event/fail/fence/result HTTP,不提供假 `HttpResultSink`,不实现或引用
|
||||
`click_permitted`、提交订单点击、支付、免密支付、先用后付或任何扣款控件代码。
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
id: T-304
|
||||
title: 定时轮询、会话边界与采购工具主界面
|
||||
phase: 3
|
||||
deps: [T-303, T-006]
|
||||
status: TODO
|
||||
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/tests/test_app.py
|
||||
- client/tests/polling/**
|
||||
- client/tests/ui/**
|
||||
- docs/api.md
|
||||
- docs/06-tasks.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=37 synced=2026-08-04T13:36:35Z sha256=43abf519936af610d20823dfaf4e55a98529fc0cbb21a8cc3e90f00eb3daa713 -->
|
||||
## 问题 / 背景
|
||||
|
||||
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 使用密码框,保存后立即清空、不回填。没有 heartbeat 时不得调用 claim-next 测试连接,只显示凭据将在首次真实领取时验证。
|
||||
6. 配置承载 device id、ADB 路径、serial/transport、轮询间隔默认 15 秒且范围 5–300、连续失败阈值默认 3 且范围 1–10、HTTP 超时默认 10 秒且范围 1–120、真机步骤超时默认 45 秒且范围 5–300。pending/active claim 时冻结 service/device 身份。
|
||||
7. 轮询协调器状态固定为 STOPPED、STARTING、BLOCKED、RECOVERING、WAITING、CLAIMING、ACTIVE、RECOVERY_REQUIRED。启动必须先取得 T-303 单实例 guard 并加载 recovery snapshot;重启不自动开始,active claim 只显示待安全恢复。
|
||||
8. 停止只设置 `accept_new_claims=false`。WAITING 取消下一计时器;CLAIMING 等待有界结果并先落库;返回 claim 后进入 RECOVERY_REQUIRED。不得中断已发请求、清 pending/active、release/abandon、生成新 key/session 或调用真机返回动作。
|
||||
9. `QTimer` 只调度下一轮;阻塞 I/O 放长期 worker QObject/QThread,worker 不接触 Widget。claim 业务结果必须先交 T-303 持久化,不能因 UI generation 过期而丢弃;迟到的纯视图结果按稳定 request id 丢弃。
|
||||
10. 连续失败只统计 T-303 标记为可用同幂等键安全重放的网络、超时、5xx 或截断;同 pending request 按轮询间隔重放,达到阈值立即停止。401、协议错误、409、DPAPI/SQLite 错误立即转持久 Banner,不进入普通重试。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 纯状态机覆盖开始、EMPTY、等待、停止、请求中停止、claim 落库、无重叠请求、同键恢复、失败阈值、401/403/409/本地错误和 active 阻止新领取。
|
||||
- QtTest 覆盖默认 Tab、固定 Tab 状态、按钮禁用原因、配置验证/Ctrl+S/token 不回显、记录倒序、单击/双击/Enter/按钮、详情切换、Esc 层级与焦点恢复。
|
||||
- compact/medium/wide 不重建模型或丢选择;长文案、100%–200% DPI、浅色/深色/高对比与纯键盘行为可人工复核。
|
||||
- 应用独立启动、切换 Tab、Esc、查看记录与关闭窗口均产生零 claim、零 release、零 ADB;代码不导入 PDD 点击、数量、确认页、围栏、提交或付款能力。
|
||||
- client 全量 unittest、compileall、wheel metadata、根目录完整 init、Vikunja 导出、上下文校验与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T13:35:53Z · ila
|
||||
|
||||
2026-08-04 已按确认原型与 Windows UI/UX 规范落成任务:采用固定双 Tab、稳定 ID 的主从工作区、非模态持久错误、Esc 只退出记录详情、显式配置保存,以及停止仅阻止下一次领取的会话边界。独立运行时未注入单趟执行器,开始轮询必须禁用,确保零真实 claim、零 ADB;依赖 T-303、T-006 完成后再开工。
|
||||
<!-- 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 人工处理。
|
||||
- 配置页不得使用 `claim-next` 充当“测试连接”,因为它会产生 EMPTY 幂等事实甚至领取任务。服务地址
|
||||
只读为精确 `http://127.0.0.1:8080`;token 保存后立即清空输入框,不回填、不入日志或异常文本。
|
||||
- Qt worker 不得直接访问 Widget,UI 回调不得丢弃迟到的 claim 业务结果。连续失败只允许用 T-303
|
||||
已持久化的同一幂等键重放安全网络操作;`retryable` 绝不推导为页面点击、证据替换、下单或提交可重试。
|
||||
- 记录表的双击仅是非破坏性详情入口,并必须有 Enter 和显式按钮等价路径。Esc 只在详情态返回当前任务,
|
||||
不停止轮询、不关闭窗口、不释放 claim、不改变服务端状态;返回后按稳定记录 ID 恢复选择、滚动和焦点。
|
||||
- 不导入或实现 PDD 页面判据、规格选择、数量、确认页、证据上传、事件、失败上报、提交围栏或结果接口;
|
||||
不编写或引用点击“提交订单”的代码,不编写支付、免密支付、先用后付或任何扣款控件代码。
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
id: T-305
|
||||
title: 围栏前单趟 dry-run 编排
|
||||
phase: 3
|
||||
deps: [T-107, T-304, T-306, T-307, T-210]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 44
|
||||
context_ref: c1cee49
|
||||
work_branch: task/t-305-prefence-dry-run
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-305.md
|
||||
- client/src/cmbuyer_client/execution/**
|
||||
- client/src/cmbuyer_client/core/**
|
||||
- client/src/cmbuyer_client/remote/**
|
||||
- client/src/cmbuyer_client/localstate/**
|
||||
- client/src/cmbuyer_client/polling/**
|
||||
- client/src/cmbuyer_client/ui/**
|
||||
- client/src/cmbuyer_client/app.py
|
||||
- client/tests/execution/**
|
||||
- client/tests/core/**
|
||||
- client/tests/remote/**
|
||||
- client/tests/localstate/**
|
||||
- client/tests/polling/**
|
||||
- client/tests/ui/**
|
||||
- client/tests/test_app.py
|
||||
- docs/api.md
|
||||
- docs/04-architecture.md
|
||||
- docs/06-tasks.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=44 synced=2026-08-04T14:13:33Z sha256=01cdc47330b88791356618a90caed5e884666ad1b153ed136e6d3aacf51d8faf -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-103~T-107 分段固化真机页面能力,T-303/T-304 提供本地恢复与桌面执行壳,T-306/T-307 提供三闸门证据上传和 attempt 事件/失败 sink,T-210 扩展完整三闸门证据类型。T-305 只把这些已完成能力组合为围栏前 dry-run,验证领取后同一趟执行可以安全到达最终提交控件观察点,并在任何不可逆动作前停止。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-005、F-006、F-007、F-013、F-017 的围栏前部分。
|
||||
- 用户故事:US-003、US-004、US-005。
|
||||
- 依赖:T-107、T-304、T-306、T-307、T-210。
|
||||
- 后续消费者:T-208 提交围栏与真实单次提交。
|
||||
- 本任务不得调用 submission fence、result 或订单提交能力。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 实现窄 PreFenceDryRunOrchestrator,只依赖已冻结的 claim/renew、attempt event/fail、三闸门 EvidenceSink、T-103/T-105/T-107 页面能力和 T-304 UI 执行外壳;不直接实现 HTTP、PDD selector 或新页面判据。
|
||||
2. 首次领取后把 authorization/task/attempt/generation/claim/版本快照和执行载荷原子写入 T-303 localstate;页面动作前写 action journal。重启只调和 HTTP 与展示人工恢复状态,不自动续跑任何手机点击。
|
||||
3. 同一趟严格执行:规格面板闸门一 → 数量设置与闸门二 → 唯一证据绑定的确认页导航 → 闸门三与最终提交控件只读观察 → 一次安全 Back。任一前置条件、读回、价格或证据失败立即 fail closed。
|
||||
4. 三闸门每次都先把原始 PNG 交 T-306 持久化固定 upload_key/metadata/hash,再用 T-307 追加对应成功事件;事件只能引用服务端确认的 AssetRef。未知上传结果只重放同键同文件,不重做页面动作或替换证据。
|
||||
5. 动作日志使用固定动作类型、attempt/generation/阶段、开始时间与明确后置状态;不保存 selector、坐标、自由页面文本、XML、地址、手机号或 token。点击结果不明时停止并转人工,不以重启或超时自动重试。
|
||||
6. 完成闸门三观察且一次 Back 验证安全返回后,写固定失败终止 reason DRY_RUN_COMPLETED_BEFORE_FENCE,使 attempt/task 进入围栏前人工状态。不得请求或模拟围栏,不得创建 order_submission。
|
||||
7. HTTP 出站仅允许 T-303 transport 固定 loopback API;import 闭包和静态测试禁止 SubmissionPermit、fence/result client、click_permitted、submitter、submit_order_once、支付模块。
|
||||
8. UI 显示当前执行阶段、已确认的最小任务摘要和滚动日志;Esc 只处理详情/当前任务切换,不取消在途请求、不清本地 journal、不释放 claim、不触发真机动作。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 离线组合测试覆盖正常三闸门、每一步失败、价格/规格/数量漂移、证据未知结果、事件未知结果、租约/授权到期、重启和 active claim 漂移。
|
||||
- 证明每个页面动作前已有 journal;不明结果和重启均零自动页面动作,只有同键 HTTP 调和。
|
||||
- 证明 Gate1/Gate2/Gate3 的 PNG、captured_at 与 AssetRef 顺序正确,事件不能先于证据成功,也不能串 attempt/generation。
|
||||
- 正常 dry-run 最终只产生 DRY_RUN_COMPLETED_BEFORE_FENCE,不存在 fence、order_submission、result 或提交点击。
|
||||
- 静态调用图/import 闭包证明不可达 submission fence、SubmissionPermit、click_permitted、submit_order_once 与任何付款能力。
|
||||
- 真机验收必须由人完成,记录设备/Android/拼多多版本、三个证据路径、三闸门读回、最终控件只读观察和一次 Back 后置状态;确认前保持 DOING。
|
||||
- client 全量 unittest、compileall、wheel metadata、完整 init、上下文、Vikunja export 与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T14:11:20Z · ila
|
||||
|
||||
2026-08-04 任务定值:T-305 只做围栏前单趟 dry-run,总装已验收的 T-107/T-304/T-306/T-307/T-210 能力;正常路径最终写 DRY_RUN_COMPLETED_BEFORE_FENCE 并安全返回,不请求围栏、不创建 order_submission、不点击提交订单、不涉及付款。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 只实现 `PreFenceDryRunOrchestrator` 与围栏前 UI 组合;不得在本任务的构造、import 闭包或运行时
|
||||
引入 submission fence/result client、`SubmissionPermit`、`click_permitted`、submitter、
|
||||
`submit_order_once()`、`order_submission` 或任何付款能力。
|
||||
- 本任务不得修改 `client/src/cmbuyer_client/pdd/**` 或新增页面 selector/坐标/判据;只调用依赖任务
|
||||
已完成人工验收且版本绑定的窄能力。未完成验收、版本不匹配或观察不唯一时必须停止,不能猜测。
|
||||
- 同一趟动作顺序固定为闸门一、数量与闸门二、证据绑定的唯一确认页导航、闸门三/最终控件只读观察、
|
||||
一次安全 Back。最终控件不得暴露节点/selector/坐标/句柄,也不得发生点击。
|
||||
- 每个真机动作前必须先写固定 action journal。结果不明、超时、停止、Esc、关闭窗口或重启均不得
|
||||
自动重试页面动作、续跑下一步、清 journal、释放 claim 或转领;重启只允许同键同载荷 HTTP 调和。
|
||||
- 三闸门截图必须先经 T-306 取得服务端确认的 `AssetRef`,再由 T-307 写对应固定事件;不得先写成功
|
||||
事件、替换不明证据、串 attempt/generation,或上传 XML/manifest/目录。
|
||||
- 正常 dry-run 必须以固定 reason `DRY_RUN_COMPLETED_BEFORE_FENCE` 在围栏前安全终止;不得请求、
|
||||
模拟或伪造围栏,不得创建待付款订单。本任务不编写支付、免密支付、先用后付或任何扣款代码。
|
||||
- `needs_device: true` 的最终验收只能由人完成。离线验证通过后仍保持 `DOING`,直到人确认三闸门
|
||||
证据、最终提交控件仅被观察、一次 Back 安全返回且没有创建订单。
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: T-306
|
||||
title: 规格面板原始截图与可靠证据上传
|
||||
phase: 3
|
||||
deps: [T-104, T-303]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 38
|
||||
context_ref: 41e54e3
|
||||
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-04T13:48:05Z sha256=5bcea505093335085e44e062064d01d95e5c64a70d0d8faf85e1ea10852d7fbb -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-104 将在规格选择、闸门一读价、原始截图和严格安全退出全部成功后返回显式 PNG 产物;T-303 提供受设备认证的 HttpEvidenceSink 与可崩溃恢复的唯一上传槽。T-306 只把这张规格面板原始截图可靠上传为内部审计证据,不连接 UI/轮询、不扩展页面能力或服务端证据类型。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能: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 或 admin。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 定义窄 EvidencePublisher,只接受 T-104 成功结果中的显式 screenshot_path 与 UTC captured_at,以及当前 T-303 active claim;不得接收目录、manifest 或任意文件选择。
|
||||
2. 上传元数据固定为 kind=SKU_PANEL_GATE_1、privacy_tier=INTERNAL_RAW;task/attempt 只来自 active claim。post-exit 商品详情截图没有获批 kind,不上传。
|
||||
3. 调用 T-303 的 prepare-or-resume evidence 原子槽:按 (attempt_id, kind) 唯一,首次 HTTP 前已保存 upload_key、完整元数据、文件 identity/大小/SHA-256;本任务不得另建 SQLite。
|
||||
4. 每次调用最多发送一次 HTTP。网络错误、超时、503、截断或非法响应只保留同一槽;进程重启后仅重放同一 key、相同字节和元数据,不在内部循环重试。
|
||||
5. 201 首次成功和 200 稳定重放都必须严格核对 AssetRef。成功后槽继续保存 AssetRef;重复调用只返回原结果,不能清 pending 后生成新 key 或上传第二张。
|
||||
6. 原文件缺失或 identity/大小/hash 变化时零 HTTP、保留恢复事实并转人工;不得改用别的截图、重新截图或枚举目录找替代品。
|
||||
7. 首次上传前 active claim 的 task/attempt/device/session/generation 必须一致;pending 永远绑定原 claim。关闭 claim 后只允许原 key 的可能成功重放,不允许新上传槽。
|
||||
8. 停止轮询只阻止下一领取,不取消在途上传、不清 pending、不删除原图、不 release/abandon。强制退出后只恢复 HTTP,不恢复任何真机动作。
|
||||
9. 原始 PNG 可以包含页面已显示的地址和手机号,不遮罩、不裁剪、不 OCR,也不把它们解析为字段、日志或错误;完整 XML、manifest、本机路径、原文件名、外部支付页和支付凭据永不上传。
|
||||
10. multipart 使用固定安全文件名,只发 T-204 已冻结字段,不增加 claim token/session/path,不扩展 SKU_PANEL_GATE_2 或确认页 kind。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- T-104 成功产物只调用一次 sink;安全退出失败、claim 不一致、无 active claim或文件变化均零 HTTP。
|
||||
- 首次传输前幂等槽已持久化;结果不明和重启后 key/bytes/metadata 原样重放;重复成功返回同一 AssetRef。
|
||||
- 停止/关闭不取消在途请求、不清 pending、不触发 ADB;拒绝目录、XML、manifest、JPEG、post-exit 截图和未批准 kind。
|
||||
- 原始 PNG 字节保持不变,含地址/手机号不触发遮罩;日志/异常不含 token、页面正文、路径或原文件名。
|
||||
- 静态检查没有目录枚举、uiautomator2、PDD 点击、数量、确认页、围栏、提交订单或付款能力。
|
||||
- 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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 唯一输入是 T-104 在规格选择、闸门一读价、规格面板原始截图和严格安全退出全部成功后返回的
|
||||
显式 `screenshot_path` 与 UTC `captured_at`。不得枚举目录、读取 manifest、使用文件选择器,
|
||||
也不得把 post-exit 商品详情截图或其他文件冒充规格面板证据。
|
||||
- 只发布一个 `kind=SKU_PANEL_GATE_1`、`privacy_tier=INTERNAL_RAW` 的原始 PNG;task/attempt 只来自
|
||||
当前 active claim。不得增加 gate2/确认页 kind,不得修改 admin schema/API,也不得自行向 multipart
|
||||
增加 claim token、session、路径、原文件名或其他字段。
|
||||
- 上传幂等与恢复只使用 T-303 的 `(attempt_id, evidence_kind)` 唯一原子槽。本任务不得另建 SQLite、
|
||||
生成第二套 upload key 或清除成功 AssetRef;结果不明只能以原 key、原字节、原元数据恢复。
|
||||
- 每次调用最多一次 HTTP,不在内部循环重试。文件缺失或 identity/大小/hash 变化时必须零 HTTP 并
|
||||
转人工;不得重新截图、替换文件或在目录中寻找“相似”截图。
|
||||
- 停止轮询、Esc、关闭窗口和进程退出不取消在途上传、不清 pending、不删除原图、不 release/abandon
|
||||
claim,也不触发任何 ADB/PDD 动作。重启只可恢复同一 HTTP 请求,不自动恢复设备页面操作。
|
||||
- 原始 PNG 可包含页面已显示的地址和手机号,不遮罩、不裁剪、不 OCR;不得把地址或手机号解析为
|
||||
业务字段、日志或异常文本。完整 XML、manifest、本机路径、页面全文、外部支付页和支付凭据不上传。
|
||||
- 不导入或实现 uiautomator2、PDD 页面判据、通用 click、规格/数量/确认页动作、事件、失败上报、
|
||||
提交围栏或结果接口;不编写或引用点击“提交订单”的代码,不编写支付或任何扣款能力。
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: T-307
|
||||
title: 客户端 attempt 事件与失败 sink
|
||||
phase: 3
|
||||
deps: [T-205, T-303]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 39
|
||||
context_ref: 35d7ce1
|
||||
work_branch: task/t-307-attempt-sink
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-307.md
|
||||
- client/src/cmbuyer_client/attempts/**
|
||||
- client/src/cmbuyer_client/core/**
|
||||
- client/src/cmbuyer_client/remote/**
|
||||
- client/src/cmbuyer_client/localstate/**
|
||||
- client/tests/attempts/**
|
||||
- client/tests/core/**
|
||||
- client/tests/remote/**
|
||||
- client/tests/localstate/**
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=39 synced=2026-08-04T13:50:06Z sha256=3f13f517446bbcab0a4415eaf2232a990053d35fad510ef296fe38b075282ee0 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-205 将冻结 attempt events/fail 的严格服务端 wire,T-303 提供安全 HTTP 传输原则、DPAPI/SQLite 与恢复快照,但刻意不伪造完整 ResultSink。T-307 补齐客户端围栏前 HttpAttemptSink 和事件/失败幂等状态,使 T-305 只组合已完成能力;不连接真机、UI、证据、围栏或结果接口。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006、F-007。
|
||||
- 用户故事:US-003、US-004、US-005。
|
||||
- 依赖:T-205、T-303。
|
||||
- 后续消费者:T-305。
|
||||
- API:只消费 T-205 的 events 与 fail;不实现 fence/result。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 定义窄 AttemptSink 协议:append_events 与 fail_attempt。模型只含 T-205 固定 step/outcome/reason、规范十进制金额、正整数和受控 AssetRef;不接收自由页面文本、XML、路径、token、selector、坐标或动作脚本。
|
||||
2. HttpAttemptSink 复用 T-303 直连 transport 规则:精确 loopback、禁代理/重定向、有界请求/响应、严格 Content-Type/UTF-8/重复 key/未知字段;每次方法最多一个 HTTP 请求,不隐藏重试。
|
||||
3. 扩展 T-303 localstate,而不创建第二个数据库:event batch 与 fail 在首次 HTTP 前原子保存幂等 key、active claim 绑定、完整载荷与预期序号/状态;结果不明和重启只能重放原键原载荷。
|
||||
4. event 成功必须严格核对服务端批次结果并持久化已确认序号/摘要;同批重复只返回原结果。异载荷、序号漂移、服务端摘要漂移或 active claim 不一致转协议冲突,不覆盖本地事实。
|
||||
5. fail 仅消费 T-205 固定 reason 与 last_safe_step;成功后原子保存终态响应。结果不明保留同 key,不能创建第二个 fail 或把任务本地假设为已释放。
|
||||
6. 401、403、409、协议错误、本地 DPAPI/SQLite 错误按 T-303 分类停止;网络/超时/5xx 只标记同键可恢复。retryable 永不表示页面动作、证据替换、围栏或提交可重试。
|
||||
7. 停止轮询/关闭窗口不取消在途请求、不清 pending、不 release/abandon claim。重启可调和 HTTP,但绝不自动恢复真机点击。
|
||||
8. 构造与 import 闭包中没有 fence client、ResultSink、SubmissionPermit、click_permitted、submitter、PDD 或 payment 能力。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 覆盖 event/fail 成功、严格 schema、同键同载荷重放、异载荷冲突、响应截断/漂移、401/403/409/503。
|
||||
- 证明首次网络前已持久化;崩溃与重启后键/载荷/claim 绑定完全一致,active claim 漂移时零 HTTP。
|
||||
- 并发 append 序列化且不跳号;fail 与未决 event 的顺序失败闭合;成功结果不可覆盖。
|
||||
- SQLite/WAL、日志、异常不含 claim/device token、页面全文、XML、本机路径或 selector。
|
||||
- 静态检查没有 evidence 文件读取、uiautomator2/PDD、release/abandon、fence/result、提交订单或付款能力。
|
||||
- client 全量 unittest、compileall、wheel metadata、完整 init、Vikunja 导出、上下文校验与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T13:49:39Z · ila
|
||||
|
||||
2026-08-04 从 T-305 预研拆出:只实现 events/fail 的客户端窄 sink 与本地幂等恢复,避免在 dry-run 集成任务中临时扩展 T-303;不实现完整 ResultSink、fence/result 或真机/UI。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 只实现 T-205 已冻结的 `append_events` 与 `fail_attempt` 窄客户端契约;不得提供完整 ResultSink,
|
||||
不得访问 submission fence、result、`click_permitted`、SubmissionPermit 或提交许可。
|
||||
- 事件只含版本化固定 step/outcome/reason、规范十进制金额、正整数和受控 AssetRef。不得接收或发送
|
||||
自由页面文本、完整 XML、地址、手机号、本机路径、token、selector、坐标或动作脚本。
|
||||
- HTTP 必须复用 T-303 精确 loopback、禁代理/重定向、严格解析与每方法一次传输边界。本任务不得
|
||||
建第二个 transport 或隐藏重试循环;`retryable` 只允许同幂等键 HTTP 恢复,不能推导页面动作可重试。
|
||||
- Event batch 与 fail 的幂等键、active-claim 绑定和完整载荷必须先进入 T-303 同一 localstate 事务再
|
||||
发送。结果不明、重启和停止只能重放原键原载荷;不得另建 SQLite、换 key、跳序号或覆盖已确认摘要。
|
||||
- 停止轮询、Esc、关闭窗口和进程退出不取消在途请求、不清 pending、不 release/abandon claim,
|
||||
也不触发 ADB/PDD 动作。重启只可调和同一 attempt 的 HTTP 事实,不自动恢复任何真机点击。
|
||||
- 不读取或上传 evidence 文件,不导入 uiautomator2/PDD,不实现规格、数量、确认页、围栏或结果动作;
|
||||
不编写或引用点击“提交订单”的代码,不编写支付、免密支付、先用后付或任何扣款能力。
|
||||
Reference in New Issue
Block a user