feat(admin): add atomic task claim leases

This commit is contained in:
QiuSW
2026-08-04 22:33:16 +08:00
parent 526af1eb31
commit 5f060f60ee
20 changed files with 2923 additions and 49 deletions
+4
View File
@@ -13,6 +13,8 @@
| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 | | `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 | | `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
| `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 | | `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 |
| `CMBUYER_CLAIM_TOKEN_SECRET` | claim token 专用 32 字节密钥的 64 位小写十六进制;不得复用 session 或设备 token。 |
| `CMBUYER_CLAIM_LEASE_TTL` | 正 Go duration,且必须严格短于 `CMBUYER_AUTHORIZATION_TTL`。 |
示例仅展示变量名,不提供可运行凭据: 示例仅展示变量名,不提供可运行凭据:
@@ -26,6 +28,8 @@ $env:CMBUYER_AUTHORIZATION_TTL = '10m'
$env:CMBUYER_MAX_TASK_QUANTITY = '99' $env:CMBUYER_MAX_TASK_QUANTITY = '99'
$env:CMBUYER_MAX_TOTAL_PRICE = '999.99' $env:CMBUYER_MAX_TOTAL_PRICE = '999.99'
$env:CMBUYER_EVIDENCE_DIR = '<内部截图绝对目录>' $env:CMBUYER_EVIDENCE_DIR = '<内部截图绝对目录>'
$env:CMBUYER_CLAIM_TOKEN_SECRET = '<64 位小写十六进制随机值>'
$env:CMBUYER_CLAIM_LEASE_TTL = '1m'
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
go run ./cmd/server go run ./cmd/server
``` ```
+6
View File
@@ -11,6 +11,7 @@ import (
"cmbuyer/admin/internal/server" "cmbuyer/admin/internal/server"
evidencestorage "cmbuyer/admin/internal/storage/evidence" evidencestorage "cmbuyer/admin/internal/storage/evidence"
"cmbuyer/admin/internal/storage/sqlite" "cmbuyer/admin/internal/storage/sqlite"
"cmbuyer/admin/internal/taskclaim"
"cmbuyer/admin/internal/taskdetail" "cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/tasks"
) )
@@ -52,6 +53,10 @@ func run() error {
if err != nil { if err != nil {
return err return err
} }
claimStore, err := taskclaim.NewStore(database, configuration.ClaimTokenSecret, configuration.ClaimLeaseTTL)
if err != nil {
return err
}
router, err := server.NewRouter(server.Options{ router, err := server.NewRouter(server.Options{
AdminUsername: configuration.AdminUsername, AdminUsername: configuration.AdminUsername,
@@ -61,6 +66,7 @@ func run() error {
TaskDetails: detailStore, TaskDetails: detailStore,
Evidence: evidenceStore, Evidence: evidenceStore,
DeviceAuthenticator: deviceAuthenticator, DeviceAuthenticator: deviceAuthenticator,
TaskClaims: claimStore,
}) })
if err != nil { if err != nil {
return err return err
+29
View File
@@ -2,6 +2,8 @@
package config package config
import ( import (
"bytes"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -23,6 +25,8 @@ const (
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY" maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE" maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR" evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
claimTokenSecretEnv = "CMBUYER_CLAIM_TOKEN_SECRET"
claimLeaseTTLEnv = "CMBUYER_CLAIM_LEASE_TTL"
minimumSecretLength = 32 minimumSecretLength = 32
) )
@@ -37,6 +41,8 @@ type Config struct {
MaxTaskQuantity int MaxTaskQuantity int
MaxTotalPrice string MaxTotalPrice string
EvidenceDirectory string EvidenceDirectory string
ClaimTokenSecret []byte
ClaimLeaseTTL time.Duration
} }
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。 // LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
@@ -112,6 +118,27 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) { if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv) return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
} }
claimSecretText, err := required(lookup, claimTokenSecretEnv)
if err != nil {
return Config{}, err
}
claimSecret, err := hex.DecodeString(claimSecretText)
if err != nil || len(claimSecret) != 32 || hex.EncodeToString(claimSecret) != claimSecretText {
return Config{}, fmt.Errorf("%s must be exactly 64 lowercase hexadecimal characters", claimTokenSecretEnv)
}
// Claim ownership, admin sessions and device authentication are separate security domains.
// Reject both identical configuration text and identical effective key bytes.
if claimSecretText == secret || bytes.Equal(claimSecret, []byte(secret)) {
return Config{}, fmt.Errorf("%s must be isolated from %s", claimTokenSecretEnv, sessionSecretEnv)
}
claimTTLText, err := required(lookup, claimLeaseTTLEnv)
if err != nil {
return Config{}, err
}
claimTTL, err := time.ParseDuration(claimTTLText)
if err != nil || claimTTL <= 0 || claimTTL >= ttl {
return Config{}, fmt.Errorf("%s must be positive and shorter than %s", claimLeaseTTLEnv, authorizationTTLEnv)
}
return Config{ return Config{
AdminUsername: username, AdminUsername: username,
@@ -121,6 +148,8 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
DatabaseSource: databaseSource, DatabaseSource: databaseSource,
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice, AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
EvidenceDirectory: evidenceDirectory, EvidenceDirectory: evidenceDirectory,
ClaimTokenSecret: claimSecret,
ClaimLeaseTTL: claimTTL,
}, nil }, nil
} }
+12 -1
View File
@@ -3,6 +3,7 @@ package config_test
import ( import (
"strings" "strings"
"testing" "testing"
"time"
"cmbuyer/admin/internal/config" "cmbuyer/admin/internal/config"
@@ -25,13 +26,15 @@ func TestLoad(t *testing.T) {
"CMBUYER_MAX_TASK_QUANTITY": "99", "CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99", "CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(), "CMBUYER_EVIDENCE_DIR": t.TempDir(),
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
"CMBUYER_CLAIM_LEASE_TTL": "1m",
} }
got, err := config.Load(lookup(values)) got, err := config.Load(lookup(values))
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
if got.AdminUsername != "admin" || !got.CookieSecure { if got.AdminUsername != "admin" || !got.CookieSecure || len(got.ClaimTokenSecret) != 32 || got.ClaimLeaseTTL != time.Minute {
t.Fatalf("Load returned unexpected public configuration: %#v", got) t.Fatalf("Load returned unexpected public configuration: %#v", got)
} }
} }
@@ -51,6 +54,8 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
"CMBUYER_MAX_TASK_QUANTITY": "99", "CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99", "CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(), "CMBUYER_EVIDENCE_DIR": t.TempDir(),
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
"CMBUYER_CLAIM_LEASE_TTL": "1m",
} }
tests := []struct { tests := []struct {
@@ -68,6 +73,12 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
{"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"}, {"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"},
{"missing evidence directory", func(values map[string]string) { delete(values, "CMBUYER_EVIDENCE_DIR") }, "CMBUYER_EVIDENCE_DIR"}, {"missing evidence directory", func(values map[string]string) { delete(values, "CMBUYER_EVIDENCE_DIR") }, "CMBUYER_EVIDENCE_DIR"},
{"relative evidence directory", func(values map[string]string) { values["CMBUYER_EVIDENCE_DIR"] = "evidence" }, "CMBUYER_EVIDENCE_DIR"}, {"relative evidence directory", func(values map[string]string) { values["CMBUYER_EVIDENCE_DIR"] = "evidence" }, "CMBUYER_EVIDENCE_DIR"},
{"invalid claim secret", func(values map[string]string) { values["CMBUYER_CLAIM_TOKEN_SECRET"] = strings.Repeat("A", 64) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
{"claim secret same raw session secret", func(values map[string]string) {
values["CMBUYER_SESSION_SECRET"] = values["CMBUYER_CLAIM_TOKEN_SECRET"]
}, "CMBUYER_CLAIM_TOKEN_SECRET"},
{"claim secret same decoded session secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = strings.Repeat("\xab", 32) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
{"invalid claim lease ttl", func(values map[string]string) { values["CMBUYER_CLAIM_LEASE_TTL"] = "10m" }, "CMBUYER_CLAIM_LEASE_TTL"},
} }
for _, test := range tests { for _, test := range tests {
+157 -12
View File
@@ -26,7 +26,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
if err := migrations.Up(context, database, directory); err != nil { if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("apply migrations: %v", err) t.Fatalf("apply migrations: %v", err)
} }
assertVersion(t, database, 4) assertVersion(t, database, 5)
assertTableExists(t, database, "tasks", true) assertTableExists(t, database, "tasks", true)
assertTableExists(t, database, "spec_trials", false) assertTableExists(t, database, "spec_trials", false)
assertTableExists(t, database, "order_authorizations", true) assertTableExists(t, database, "order_authorizations", true)
@@ -34,12 +34,20 @@ func TestUpDownAndIdempotence(t *testing.T) {
assertTableExists(t, database, "order_submissions", true) assertTableExists(t, database, "order_submissions", true)
assertTableExists(t, database, "evidence_assets", true) assertTableExists(t, database, "evidence_assets", true)
assertTableExists(t, database, "device_credentials", true) assertTableExists(t, database, "device_credentials", true)
assertTableExists(t, database, "purchase_attempt_claims", true)
assertTableExists(t, database, "single_pass_upgrade_guard", false) assertTableExists(t, database, "single_pass_upgrade_guard", false)
if err := migrations.Up(context, database, directory); err != nil { if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply migrations: %v", err) t.Fatalf("reapply migrations: %v", err)
} }
assertVersion(t, database, 5)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back task claim migration: %v", err)
}
assertVersion(t, database, 4) assertVersion(t, database, 4)
assertTableExists(t, database, "purchase_attempt_claims", false)
assertTableExists(t, database, "device_credentials", true)
if err := migrations.Down(context, database, directory); err != nil { if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back device credential migration: %v", err) t.Fatalf("roll back device credential migration: %v", err)
@@ -65,7 +73,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
if err := migrations.Up(context, database, directory); err != nil { if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply v2 after rollback: %v", err) t.Fatalf("reapply v2 after rollback: %v", err)
} }
assertVersion(t, database, 4) assertVersion(t, database, 5)
} }
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) { func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
@@ -84,7 +92,7 @@ func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("upgrade v1 draft: %v", err) t.Fatalf("upgrade v1 draft: %v", err)
} }
assertVersion(t, database, 4) assertVersion(t, database, 5)
var got struct { var got struct {
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
quantity, version int quantity, version int
@@ -235,9 +243,7 @@ func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) { func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
database := openTestDatabase(t) database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { migrateToV3(t, database)
t.Fatalf("apply migrations: %v", err)
}
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT") insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one") insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1) insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
@@ -271,9 +277,6 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
}) })
} }
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("roll back empty device credential migration: %v", err)
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil { if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("evidence-bearing schema downgraded successfully") t.Fatal("evidence-bearing schema downgraded successfully")
} }
@@ -287,9 +290,7 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) { func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
database := openTestDatabase(t) database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { migrateToV4(t, database)
t.Fatalf("apply migrations: %v", err)
}
deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301" deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
hash := make([]byte, 32) hash := make([]byte, 32)
for index := range hash { for index := range hash {
@@ -354,6 +355,132 @@ func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
} }
} }
func TestTaskClaimMigrationGuardsOwnershipConstraintsAndDowngradeFacts(t *testing.T) {
t.Run("upgrade rejects unmappable execution facts atomically", func(t *testing.T) {
database := openTestDatabase(t)
migrateToV4(t, database)
insertV2Task(t, database, "legacy-task", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "legacy-auth", "legacy-task", 1, "legacy-start")
insertV2Attempt(t, database, "legacy-attempt", "legacy-task", "legacy-auth", 1)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("v5 upgrade accepted an attempt without device/session ownership")
}
assertVersion(t, database, 4)
assertTableExists(t, database, "purchase_attempt_claims", false)
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&count); err != nil || count != 1 {
t.Fatalf("legacy attempt after rejected upgrade = %d, err %v", count, err)
}
})
t.Run("schema binds authorization device session generation and token", func(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
deviceA := "13c9f507-7473-4fa6-8d71-8786c34c6301"
deviceB := "23c9f507-7473-4fa6-8d71-8786c34c6301"
sessionA := "33c9f507-7473-4fa6-8d71-8786c34c6301"
sessionB := "43c9f507-7473-4fa6-8d71-8786c34c6301"
taskA := "53c9f507-7473-4fa6-8d71-8786c34c6301"
authA := "63c9f507-7473-4fa6-8d71-8786c34c6301"
attemptA := "73c9f507-7473-4fa6-8d71-8786c34c6301"
taskB := "83c9f507-7473-4fa6-8d71-8786c34c6301"
authB := "93c9f507-7473-4fa6-8d71-8786c34c6301"
attemptB := "a3c9f507-7473-4fa6-8d71-8786c34c6301"
tokenA := make([]byte, 32)
for index := range tokenA {
tokenA[index] = byte(index + 1)
}
for index, device := range []string{deviceA, deviceB} {
hash := make([]byte, 32)
hash[0] = byte(index + 100)
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, device, "device "+strconv.Itoa(index), hash, migrationTime); err != nil {
t.Fatalf("insert device: %v", err)
}
}
insertV2Task(t, database, taskA, "MANUAL", "DRAFT")
insertV2Authorization(t, database, authA, taskA, 1, "start-a")
insertV2Attempt(t, database, attemptA, taskA, authA, 1)
insertClaim := `INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, ?, ?, 2, 'task', 1, 'goods', 'white', 'XL', 1, '1.00',
'2026-08-04T01:00:00Z', ?, ?, '2026-08-04T00:05:00Z', ?, NULL)`
if _, err := database.Exec(insertClaim, attemptA, taskA, authA, deviceA, sessionA, 1, make([]byte, 32), tokenA, migrationTime); err != nil {
t.Fatalf("insert valid claim: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempts
(id,task_id,authorization_id,claim_generation,status,started_at)
VALUES ('b3c9f507-7473-4fa6-8d71-8786c34c6301', ?, ?, 2, 'CLAIMED', ?)`, taskA, authA, migrationTime); err == nil {
t.Fatal("second attempt for one authorization succeeded")
}
insertV2Task(t, database, taskB, "MANUAL", "DRAFT")
insertV2Authorization(t, database, authB, taskB, 1, "start-b")
insertV2Attempt(t, database, attemptB, taskB, authB, 1)
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceB, sessionB, 2, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
t.Fatal("claim with generation different from its attempt succeeded")
}
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceA, sessionB, 1, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
t.Fatal("second open claim for one device succeeded")
}
claimRequest := `INSERT INTO task_claim_requests
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
VALUES (?, ?, ?, 'CLAIMED', ?, '2026-08-04T00:05:00Z', NULL, ?)`
if _, err := database.Exec(claimRequest, "c3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionB, attemptA, migrationTime); err == nil {
t.Fatal("claim request with another session succeeded")
}
if _, err := database.Exec(claimRequest, "d3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionA, attemptA, migrationTime); err != nil {
t.Fatalf("insert bound claim request: %v", err)
}
renewal := `INSERT INTO purchase_attempt_lease_renewals
(renew_request_id,task_id,attempt_id,device_id,session_id,claim_generation,
claim_token_sha256,expected_lease_expires_at,lease_expires_at,created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, '2026-08-04T00:05:00Z', '2026-08-04T00:06:00Z', ?)`
if _, err := database.Exec(renewal, "e3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 2, tokenA, migrationTime); err == nil {
t.Fatal("renewal with another generation succeeded")
}
wrongHash := append([]byte(nil), tokenA...)
wrongHash[0] ^= 0xff
if _, err := database.Exec(renewal, "f3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 1, wrongHash, migrationTime); err == nil {
t.Fatal("renewal with another token hash succeeded")
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("claim-bearing schema downgraded successfully")
}
assertVersion(t, database, 5)
assertTableExists(t, database, "purchase_attempt_claims", true)
})
t.Run("empty request alone blocks downgrade", func(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatal(err)
}
device := "13c9f507-7473-4fa6-8d71-8786c34c6301"
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'device', ?, 'ACTIVE', ?, NULL)`, device, make([]byte, 32), migrationTime); err != nil {
t.Fatal(err)
}
if _, err := database.Exec(`INSERT INTO task_claim_requests
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
VALUES ('23c9f507-7473-4fa6-8d71-8786c34c6301', ?,
'33c9f507-7473-4fa6-8d71-8786c34c6301', 'EMPTY', NULL, NULL, NULL, ?)`, device, migrationTime); err != nil {
t.Fatal(err)
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("EMPTY request was silently dropped by downgrade")
}
assertVersion(t, database, 5)
})
}
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) { func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -416,6 +543,24 @@ func migrateToV2(t *testing.T, database *sql.DB) {
assertVersion(t, database, 2) assertVersion(t, database, 2)
} }
func migrateToV3(t *testing.T, database *sql.DB) {
t.Helper()
migrateToV2(t, database)
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v3: %v", err)
}
assertVersion(t, database, 3)
}
func migrateToV4(t *testing.T, database *sql.DB) {
t.Helper()
migrateToV3(t, database)
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v4: %v", err)
}
assertVersion(t, database, 4)
}
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) { func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
t.Helper() t.Helper()
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil { if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil {
+27
View File
@@ -134,6 +134,7 @@ func TestRealDeviceCredentialIdentityIsolationAndMixedCredentials(t *testing.T)
if err != nil { if err != nil {
t.Fatalf("issue credential: %v", err) t.Fatalf("issue credential: %v", err)
} }
insertEvidenceClaim(t, database, issued.DeviceID)
authenticator, err := deviceauth.NewSQLiteAuthenticator(database) authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil { if err != nil {
t.Fatalf("new authenticator: %v", err) t.Fatalf("new authenticator: %v", err)
@@ -353,6 +354,8 @@ func newEvidenceRouter(t *testing.T, authenticator deviceauth.Authenticator) (ht
t.Fatalf("migrate database: %v", err) t.Fatalf("migrate database: %v", err)
} }
insertEvidenceAttempt(t, database) insertEvidenceAttempt(t, database)
insertEvidenceClaimDevice(t, database, evidenceDeviceID)
insertEvidenceClaim(t, database, evidenceDeviceID)
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets")) store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil { if err != nil {
t.Fatalf("new evidence store: %v", err) t.Fatalf("new evidence store: %v", err)
@@ -384,6 +387,30 @@ func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
} }
} }
func insertEvidenceClaimDevice(t *testing.T, database *sql.DB, deviceID string) {
t.Helper()
digest := sha256.Sum256([]byte("fake evidence device"))
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'fake evidence device', ?, 'ACTIVE', '2026-08-04T00:00:00Z', NULL)`, deviceID, digest[:]); err != nil {
t.Fatalf("insert evidence device: %v", err)
}
}
func insertEvidenceClaim(t *testing.T, database *sql.DB, deviceID string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, '23c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
1, '123', 'black', 'M', 1, '1.00', '2026-08-04T00:00:00Z', ?, ?,
'2026-08-04T02:00:00Z', '2026-08-04T00:00:00Z', NULL)`, evidenceAttemptID,
evidenceTaskID, evidenceAuthID, deviceID, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32)); err != nil {
t.Fatalf("insert evidence claim: %v", err)
}
}
func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder { func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder {
t.Helper() t.Helper()
request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra) request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra)
+5 -1
View File
@@ -16,6 +16,7 @@ import (
"cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/deviceauth" "cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence" "cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/taskclaim"
"cmbuyer/admin/internal/taskdetail" "cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/tasks"
"cmbuyer/admin/internal/transport/webui" "cmbuyer/admin/internal/transport/webui"
@@ -36,11 +37,12 @@ type Options struct {
TaskDetails taskdetail.Store TaskDetails taskdetail.Store
Evidence evidence.Store Evidence evidence.Store
DeviceAuthenticator deviceauth.Authenticator DeviceAuthenticator deviceauth.Authenticator
TaskClaims taskclaim.Service
} }
// NewRouter 返回当前服务范围内的完整 HTTP 路由。 // NewRouter 返回当前服务范围内的完整 HTTP 路由。
func NewRouter(options Options) (*gin.Engine, error) { func NewRouter(options Options) (*gin.Engine, error) {
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil { if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil || options.TaskClaims == nil {
return nil, errors.New("server authentication options are incomplete") return nil, errors.New("server authentication options are incomplete")
} }
@@ -57,6 +59,8 @@ func NewRouter(options Options) (*gin.Engine, error) {
router.POST("/tasks", createTask(options)) router.POST("/tasks", createTask(options))
router.POST("/tasks/start-purchases", startPurchases(options)) router.POST("/tasks/start-purchases", startPurchases(options))
router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(options)) router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(options))
router.POST("/api/v1/tasks/claim-next", claimNext(options))
router.POST("/api/v1/tasks/:id/lease/renew", renewLease(options))
router.GET("/evidence/:asset_id", readEvidence(options)) router.GET("/evidence/:asset_id", readEvidence(options))
router.GET("/static/tasks.js", func(context *gin.Context) { router.GET("/static/tasks.js", func(context *gin.Context) {
context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript()) context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript())
+16
View File
@@ -15,6 +15,7 @@ import (
"cmbuyer/admin/internal/deviceauth" "cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence" "cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/server" "cmbuyer/admin/internal/server"
"cmbuyer/admin/internal/taskclaim"
"cmbuyer/admin/internal/taskdetail" "cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/tasks"
@@ -493,6 +494,10 @@ func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Man
} }
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) { func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) {
return newRouterWithClaimService(t, store, details, evidenceStore, deviceAuthenticator, emptyTaskClaimService{})
}
func newRouterWithClaimService(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator, claims taskclaim.Service) (*gin.Engine, *auth.Manager) {
t.Helper() t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost) hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
@@ -508,6 +513,7 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
TaskDetails: details, TaskDetails: details,
Evidence: evidenceStore, Evidence: evidenceStore,
DeviceAuthenticator: deviceAuthenticator, DeviceAuthenticator: deviceAuthenticator,
TaskClaims: claims,
}) })
if err != nil { if err != nil {
t.Fatalf("NewRouter: %v", err) t.Fatalf("NewRouter: %v", err)
@@ -517,6 +523,16 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
type emptyDetailStore struct{} type emptyDetailStore struct{}
type emptyTaskClaimService struct{}
func (emptyTaskClaimService) ClaimNext(context.Context, string, taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
return taskclaim.ClaimResponse{}, false, nil
}
func (emptyTaskClaimService) Renew(context.Context, string, taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
return taskclaim.RenewResponse{}, taskclaim.ErrNotCurrent
}
func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) { func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
return taskdetail.Detail{}, taskdetail.ErrNotFound return taskdetail.Detail{}, taskdetail.ErrNotFound
} }
+164
View File
@@ -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})
}
+166
View File
@@ -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
}
+8 -3
View File
@@ -185,11 +185,16 @@ func (store *Store) Commit(ctx context.Context, principal deviceauth.Principal,
return existing, true, nil return existing, true, nil
} }
var attemptCount int var ownedClaimCount int
if err := transaction.QueryRowContext(ctx, "SELECT COUNT(*) FROM purchase_attempts WHERE task_id = ? AND id = ?", metadata.TaskID, metadata.AttemptID).Scan(&attemptCount); err != nil { if err := transaction.QueryRowContext(ctx, `SELECT COUNT(*) FROM purchase_attempt_claims
WHERE task_id = ? AND attempt_id = ? AND claimed_by_device_id = ? AND closed_at IS NULL`,
metadata.TaskID, metadata.AttemptID, principal.ID).Scan(&ownedClaimCount); err != nil {
return core.Asset{}, false, err return core.Asset{}, false, err
} }
if attemptCount != 1 { // Evidence is auditable only when the authenticated device owns the current attempt. The
// idempotent asset lookup above deliberately remains first so closing a claim later cannot
// destroy stable replay of an already committed screenshot.
if ownedClaimCount != 1 {
return core.Asset{}, false, core.ErrInvalid return core.Asset{}, false, core.ErrInvalid
} }
@@ -82,6 +82,41 @@ func TestStageCommitReplayAndOpen(t *testing.T) {
} }
} }
func TestCommitRequiresCurrentClaimOwnerButClosedClaimKeepsHistoricalReplay(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
metadata := testMetadata(sha256Hex(pngBytes))
stage := func() core.StagedFile {
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
return staged
}
otherDevice := deviceauth.Principal{ID: "73c9f507-7473-4fa6-8d71-8786c34c6301"}
if _, _, err := store.Commit(context.Background(), otherDevice, metadata, stage()); !errors.Is(err, core.ErrInvalid) {
t.Fatalf("device B upload to device A attempt error = %v", err)
}
principal := deviceauth.Principal{ID: testDeviceID}
asset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
if err != nil || replayed {
t.Fatalf("owner first Commit = replayed %v, err %v", replayed, err)
}
if _, err := database.Exec("UPDATE purchase_attempt_claims SET closed_at='2026-08-04T03:00:00Z' WHERE attempt_id=?", testAttemptID); err != nil {
t.Fatalf("close claim: %v", err)
}
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
if err != nil || !replayed || replayedAsset.ID != asset.ID {
t.Fatalf("closed claim historical replay = %#v replayed %v err %v", replayedAsset, replayed, err)
}
newMetadata := metadata
newMetadata.UploadKey = "83c9f507-7473-4fa6-8d71-8786c34c6301"
if _, _, err := store.Commit(context.Background(), principal, newMetadata, stage()); !errors.Is(err, core.ErrInvalid) {
t.Fatalf("closed claim new upload error = %v", err)
}
}
func TestConcurrentReplayCreatesOneAsset(t *testing.T) { func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
database, store := newTestStore(t) database, store := newTestStore(t)
insertAttemptFixture(t, database) insertAttemptFixture(t, database)
@@ -494,6 +529,12 @@ func newTestStore(t *testing.T) (*sql.DB, *Store) {
func insertAttemptFixture(t *testing.T, database *sql.DB) { func insertAttemptFixture(t *testing.T, database *sql.DB) {
t.Helper() t.Helper()
timestamp := "2026-08-04T00:00:00Z" timestamp := "2026-08-04T00:00:00Z"
tokenHash := sha256.Sum256([]byte("evidence-device-token"))
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'evidence device', ?, 'ACTIVE', ?, NULL)`, testDeviceID, tokenHash[:], timestamp); err != nil {
t.Fatalf("insert device: %v", err)
}
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, testTaskID, timestamp, timestamp); err != nil { if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, testTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err) t.Fatalf("insert task: %v", err)
} }
@@ -503,6 +544,15 @@ func insertAttemptFixture(t *testing.T, database *sql.DB) {
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, testAttemptID, testTaskID, testAuthID, timestamp); err != nil { if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, testAttemptID, testTaskID, testAuthID, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err) t.Fatalf("insert attempt: %v", err)
} }
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, '63c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
1, '123', 'black', 'M', 1, '1.00', ?, ?, ?, '2026-08-04T02:00:00Z', ?, NULL)`,
testAttemptID, testTaskID, testAuthID, testDeviceID, timestamp, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), timestamp); err != nil {
t.Fatalf("insert claim: %v", err)
}
} }
func testMetadata(hash string) core.UploadMetadata { func testMetadata(hash string) core.UploadMetadata {
+830
View File
@@ -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
}
+895
View File
@@ -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, &notNull, &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")
}
}
+52
View File
@@ -0,0 +1,52 @@
package taskclaim
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/binary"
"encoding/hex"
"hash"
)
const tokenDomain = "cmbuyer/task-claim-token/v1\x00"
func deriveToken(secret []byte, deviceID, taskID, authorizationID, attemptID string, generation int, nonce []byte) []byte {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(tokenDomain))
writeTokenField(mac, deviceID)
writeTokenField(mac, taskID)
writeTokenField(mac, authorizationID)
writeTokenField(mac, attemptID)
var number [8]byte
binary.BigEndian.PutUint64(number[:], uint64(generation))
_, _ = mac.Write(number[:])
writeTokenBytes(mac, nonce)
return mac.Sum(nil)
}
func writeTokenField(writer hash.Hash, value string) { writeTokenBytes(writer, []byte(value)) }
func writeTokenBytes(writer hash.Hash, value []byte) {
var size [4]byte
binary.BigEndian.PutUint32(size[:], uint32(len(value)))
_, _ = writer.Write(size[:])
_, _ = writer.Write(value)
}
func tokenHash(token []byte) []byte {
sum := sha256.Sum256(token)
return sum[:]
}
func matchingHash(left, right []byte) bool {
return len(left) == sha256.Size && len(right) == sha256.Size && subtle.ConstantTimeCompare(left, right) == 1
}
func decodeToken(value string) ([]byte, bool) {
if len(value) != sha256.Size*2 {
return nil, false
}
decoded, err := hex.DecodeString(value)
return decoded, err == nil && hex.EncodeToString(decoded) == value
}
+74
View File
@@ -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)
}
+19 -4
View File
@@ -14,14 +14,20 @@ import (
) )
const ( const (
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301" detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301" detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301" detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
detailDevice = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
) )
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) { func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
database := openDetailDatabase(t) database := openDetailDatabase(t)
timestamp := "2026-08-04T00:00:00Z" timestamp := "2026-08-04T00:00:00Z"
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'detail test device', zeroblob(32), 'ACTIVE', ?, NULL)`, detailDevice, timestamp); err != nil {
t.Fatalf("insert device: %v", err)
}
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'shirt', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 3, ?, ?)`, detailTask, timestamp, timestamp); err != nil { if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'shirt', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 3, ?, ?)`, detailTask, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err) t.Fatalf("insert task: %v", err)
} }
@@ -31,8 +37,17 @@ func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, detailTry, detailTask, detailAuth, timestamp); err != nil { if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, detailTry, detailTask, detailAuth, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err) t.Fatalf("insert attempt: %v", err)
} }
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, 'f3c9f507-7473-4fa6-8d71-8786c34c6301', 1, 3, 'shirt',
2, '123', 'black', 'M', 2, '30.00', ?, zeroblob(32), zeroblob(32),
'2026-08-04T00:05:00Z', ?, NULL)`, detailTry, detailTask, detailAuth, detailDevice, timestamp, timestamp); err != nil {
t.Fatalf("insert claim: %v", err)
}
hash := strings.Repeat("a", 64) hash := strings.Repeat("a", 64)
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, 'device', ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", timestamp, timestamp); err != nil { if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, ?, ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", detailDevice, timestamp, timestamp); err != nil {
t.Fatalf("insert evidence: %v", err) t.Fatalf("insert evidence: %v", err)
} }
store, err := NewSQLiteStore(database) store, err := NewSQLiteStore(database)
+272
View File
@@ -0,0 +1,272 @@
-- +goose Up
-- v4 中的 attempt、submission 或证据没有设备/session/租约归属,不能安全猜测成 claim。
-- 在同一迁移事务中拒绝这类数据库,避免补出虚假的所有权审计链。
CREATE TABLE task_claim_upgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO task_claim_upgrade_guard (valid)
SELECT CASE WHEN
(SELECT COUNT(*) FROM purchase_attempts) = 0
AND (SELECT COUNT(*) FROM order_submissions) = 0
AND (SELECT COUNT(*) FROM evidence_assets) = 0
THEN 1 ELSE 0 END;
DROP TABLE task_claim_upgrade_guard;
-- 该唯一索引把“一条授权只能产生一个 attempt”下沉到数据库;应用层检查不能替代它。
CREATE UNIQUE INDEX purchase_attempts_one_per_authorization_idx
ON purchase_attempts (authorization_id);
-- claim_generation 是 attempt lineage 的组成部分,不能只在应用层比较。
CREATE UNIQUE INDEX purchase_attempts_claim_lineage_idx
ON purchase_attempts (task_id, authorization_id, id, claim_generation);
CREATE TABLE purchase_attempt_claims (
attempt_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
authorization_id TEXT NOT NULL UNIQUE,
claimed_by_device_id TEXT NOT NULL,
session_id TEXT NOT NULL CHECK (
length(session_id) = 36
AND substr(session_id, 9, 1) = '-'
AND substr(session_id, 14, 1) = '-'
AND substr(session_id, 19, 1) = '-'
AND substr(session_id, 24, 1) = '-'
AND length(replace(session_id, '-', '')) = 32
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(session_id, 15, 1) = '4'
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
),
claim_generation INTEGER NOT NULL CHECK (
typeof(claim_generation) = 'integer' AND claim_generation > 0
),
task_version INTEGER NOT NULL CHECK (
typeof(task_version) = 'integer' AND task_version > 0
),
task_title TEXT NOT NULL CHECK (trim(task_title) <> ''),
authorization_task_version INTEGER NOT NULL CHECK (
typeof(authorization_task_version) = 'integer' AND authorization_task_version > 0
),
goods_id TEXT NOT NULL CHECK (trim(goods_id) <> ''),
sku_color TEXT NOT NULL CHECK (trim(sku_color) <> ''),
sku_size TEXT NOT NULL CHECK (trim(sku_size) <> ''),
quantity INTEGER NOT NULL CHECK (typeof(quantity) = 'integer' AND quantity > 0),
total_price_cap TEXT NOT NULL CHECK (trim(total_price_cap) <> ''),
authorization_expires_at TEXT NOT NULL CHECK (
authorization_expires_at = trim(authorization_expires_at)
AND length(authorization_expires_at) >= 20
AND substr(authorization_expires_at, 11, 1) = 'T'
AND substr(authorization_expires_at, -1, 1) = 'Z'
AND julianday(authorization_expires_at) IS NOT NULL
),
claim_nonce BLOB NOT NULL CHECK (
typeof(claim_nonce) = 'blob' AND length(claim_nonce) = 32
),
claim_token_sha256 BLOB NOT NULL CHECK (
typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32
),
lease_expires_at TEXT NOT NULL CHECK (
lease_expires_at = trim(lease_expires_at)
AND length(lease_expires_at) >= 20
AND substr(lease_expires_at, 11, 1) = 'T'
AND substr(lease_expires_at, -1, 1) = 'Z'
AND julianday(lease_expires_at) IS NOT NULL
),
claimed_at TEXT NOT NULL CHECK (
claimed_at = trim(claimed_at)
AND length(claimed_at) >= 20
AND substr(claimed_at, 11, 1) = 'T'
AND substr(claimed_at, -1, 1) = 'Z'
AND julianday(claimed_at) IS NOT NULL
),
closed_at TEXT CHECK (
closed_at IS NULL OR (
closed_at = trim(closed_at)
AND length(closed_at) >= 20
AND substr(closed_at, 11, 1) = 'T'
AND substr(closed_at, -1, 1) = 'Z'
AND julianday(closed_at) IS NOT NULL
AND julianday(closed_at) >= julianday(claimed_at)
)
),
UNIQUE (task_id, attempt_id),
UNIQUE (attempt_id, claimed_by_device_id, session_id),
UNIQUE (
task_id, attempt_id, claimed_by_device_id, session_id,
claim_generation, claim_token_sha256
),
UNIQUE (task_id, authorization_id, attempt_id),
FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation)
REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation),
FOREIGN KEY (claimed_by_device_id) REFERENCES device_credentials(device_id)
);
-- 过期、撤销或停轮询都不会自动关闭 claim;partial unique 因而阻止另一条开放归属。
CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx
ON purchase_attempt_claims (claimed_by_device_id)
WHERE closed_at IS NULL;
CREATE TABLE task_claim_requests (
claim_request_id TEXT PRIMARY KEY CHECK (
length(claim_request_id) = 36
AND substr(claim_request_id, 9, 1) = '-'
AND substr(claim_request_id, 14, 1) = '-'
AND substr(claim_request_id, 19, 1) = '-'
AND substr(claim_request_id, 24, 1) = '-'
AND length(replace(claim_request_id, '-', '')) = 32
AND replace(claim_request_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(claim_request_id, 15, 1) = '4'
AND substr(claim_request_id, 20, 1) IN ('8', '9', 'a', 'b')
),
device_id TEXT NOT NULL,
session_id TEXT NOT NULL CHECK (
length(session_id) = 36
AND substr(session_id, 9, 1) = '-'
AND substr(session_id, 14, 1) = '-'
AND substr(session_id, 19, 1) = '-'
AND substr(session_id, 24, 1) = '-'
AND length(replace(session_id, '-', '')) = 32
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(session_id, 15, 1) = '4'
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
),
outcome TEXT NOT NULL CHECK (outcome IN ('CLAIMED', 'EMPTY', 'BLOCKED')),
attempt_id TEXT,
response_lease_expires_at TEXT CHECK (
response_lease_expires_at IS NULL OR (
response_lease_expires_at = trim(response_lease_expires_at)
AND length(response_lease_expires_at) >= 20
AND substr(response_lease_expires_at, 11, 1) = 'T'
AND substr(response_lease_expires_at, -1, 1) = 'Z'
AND julianday(response_lease_expires_at) IS NOT NULL
)
),
error_code TEXT CHECK (error_code IS NULL OR error_code = 'manual_recovery_required'),
created_at TEXT NOT NULL CHECK (
created_at = trim(created_at)
AND length(created_at) >= 20
AND substr(created_at, 11, 1) = 'T'
AND substr(created_at, -1, 1) = 'Z'
AND julianday(created_at) IS NOT NULL
),
CHECK (
(outcome = 'CLAIMED' AND attempt_id IS NOT NULL AND response_lease_expires_at IS NOT NULL AND error_code IS NULL)
OR (outcome = 'EMPTY' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code IS NULL)
OR (outcome = 'BLOCKED' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code = 'manual_recovery_required')
),
FOREIGN KEY (device_id) REFERENCES device_credentials(device_id),
-- EMPTY/BLOCKED 行的 attempt_id 为 NULL,SQLite 会跳过复合 FK;CLAIMED 行则必须
-- 同时匹配原 claim 的设备和 session,不能由应用 bug 写成跨设备重放。
FOREIGN KEY (attempt_id, device_id, session_id)
REFERENCES purchase_attempt_claims(attempt_id, claimed_by_device_id, session_id)
);
CREATE TABLE purchase_attempt_lease_renewals (
renew_request_id TEXT PRIMARY KEY CHECK (
length(renew_request_id) = 36
AND substr(renew_request_id, 9, 1) = '-'
AND substr(renew_request_id, 14, 1) = '-'
AND substr(renew_request_id, 19, 1) = '-'
AND substr(renew_request_id, 24, 1) = '-'
AND length(replace(renew_request_id, '-', '')) = 32
AND replace(renew_request_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(renew_request_id, 15, 1) = '4'
AND substr(renew_request_id, 20, 1) IN ('8', '9', 'a', 'b')
),
task_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
device_id TEXT NOT NULL,
session_id TEXT NOT NULL CHECK (
length(session_id) = 36
AND substr(session_id, 9, 1) = '-'
AND substr(session_id, 14, 1) = '-'
AND substr(session_id, 19, 1) = '-'
AND substr(session_id, 24, 1) = '-'
AND length(replace(session_id, '-', '')) = 32
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(session_id, 15, 1) = '4'
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
),
claim_generation INTEGER NOT NULL CHECK (
typeof(claim_generation) = 'integer' AND claim_generation > 0
),
claim_token_sha256 BLOB NOT NULL CHECK (
typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32
),
expected_lease_expires_at TEXT NOT NULL CHECK (
expected_lease_expires_at = trim(expected_lease_expires_at)
AND length(expected_lease_expires_at) >= 20
AND substr(expected_lease_expires_at, 11, 1) = 'T'
AND substr(expected_lease_expires_at, -1, 1) = 'Z'
AND julianday(expected_lease_expires_at) IS NOT NULL
),
lease_expires_at TEXT NOT NULL CHECK (
lease_expires_at = trim(lease_expires_at)
AND length(lease_expires_at) >= 20
AND substr(lease_expires_at, 11, 1) = 'T'
AND substr(lease_expires_at, -1, 1) = 'Z'
AND julianday(lease_expires_at) IS NOT NULL
),
created_at TEXT NOT NULL CHECK (
created_at = trim(created_at)
AND length(created_at) >= 20
AND substr(created_at, 11, 1) = 'T'
AND substr(created_at, -1, 1) = 'Z'
AND julianday(created_at) IS NOT NULL
),
FOREIGN KEY (
task_id, attempt_id, device_id, session_id,
claim_generation, claim_token_sha256
) REFERENCES purchase_attempt_claims(
task_id, attempt_id, claimed_by_device_id, session_id,
claim_generation, claim_token_sha256
)
);
CREATE INDEX order_authorizations_claim_candidate_idx
ON order_authorizations (status, created_at, id);
-- 首次证据写入必须属于认证设备当前未关闭的 claim。历史资产的幂等重放不触发 INSERT,
-- 因而未来人工关闭 claim 后仍可稳定返回原资产。
-- +goose StatementBegin
CREATE TRIGGER evidence_assets_claim_owner_insert
BEFORE INSERT ON evidence_assets
FOR EACH ROW
WHEN NOT EXISTS (
SELECT 1 FROM purchase_attempt_claims AS claims
WHERE claims.task_id = NEW.task_id
AND claims.attempt_id = NEW.attempt_id
AND claims.claimed_by_device_id = NEW.uploaded_by_device_id
AND claims.closed_at IS NULL
)
BEGIN
SELECT RAISE(ABORT, 'evidence claim ownership required');
END;
-- +goose StatementEnd
-- +goose Down
-- 请求、续租、attempt、submission 和证据都是领取或下游审计事实,回滚不得静默删除。
CREATE TABLE task_claim_downgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO task_claim_downgrade_guard (valid)
SELECT CASE WHEN
(SELECT COUNT(*) FROM task_claim_requests) = 0
AND (SELECT COUNT(*) FROM purchase_attempt_lease_renewals) = 0
AND (SELECT COUNT(*) FROM purchase_attempt_claims) = 0
AND (SELECT COUNT(*) FROM purchase_attempts) = 0
AND (SELECT COUNT(*) FROM order_submissions) = 0
AND (SELECT COUNT(*) FROM evidence_assets) = 0
THEN 1 ELSE 0 END;
DROP TABLE task_claim_downgrade_guard;
DROP TRIGGER evidence_assets_claim_owner_insert;
DROP INDEX order_authorizations_claim_candidate_idx;
DROP TABLE purchase_attempt_lease_renewals;
DROP TABLE task_claim_requests;
DROP INDEX purchase_attempt_claims_one_open_per_device_idx;
DROP TABLE purchase_attempt_claims;
DROP INDEX purchase_attempts_claim_lineage_idx;
DROP INDEX purchase_attempts_one_per_authorization_idx;
+51 -1
View File
@@ -229,9 +229,42 @@ CREATE TABLE purchase_attempts (
failure_code TEXT, failure_code TEXT,
started_at TEXT NOT NULL, started_at TEXT NOT NULL,
finished_at TEXT, finished_at TEXT,
UNIQUE (task_id, claim_generation) UNIQUE (task_id, claim_generation),
UNIQUE (task_id, authorization_id, id, claim_generation)
); );
-- attempt 的设备/session 所有权与可恢复租约;token 明文永不入库
CREATE TABLE purchase_attempt_claims (
attempt_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
authorization_id TEXT NOT NULL UNIQUE,
claimed_by_device_id TEXT NOT NULL REFERENCES device_credentials(device_id),
session_id TEXT NOT NULL,
claim_generation INTEGER NOT NULL,
task_version INTEGER NOT NULL,
task_title TEXT NOT NULL,
authorization_task_version INTEGER NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL,
total_price_cap TEXT NOT NULL,
authorization_expires_at TEXT NOT NULL,
claim_nonce BLOB NOT NULL, -- 32 字节随机 nonce
claim_token_sha256 BLOB NOT NULL, -- 32 字节 hash,不是 token 明文
lease_expires_at TEXT NOT NULL,
claimed_at TEXT NOT NULL,
closed_at TEXT,
FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation)
REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation)
);
CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx
ON purchase_attempt_claims (claimed_by_device_id) WHERE closed_at IS NULL;
-- claim-next 的 CLAIMED / EMPTY / BLOCKED 与 renew CAS 都持久化,保证跨重启幂等。
-- 复合外键同时绑定 attempt、设备、session、generation 与 token hash,应用 bug 不能跨归属写事实。
-- 真机真实点击前建立;一份授权最多一条 -- 真机真实点击前建立;一份授权最多一条
CREATE TABLE order_submissions ( CREATE TABLE order_submissions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -327,6 +360,20 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
### 5.3 授权、租约与恢复 ### 5.3 授权、租约与恢复
- claim/renew 使用独立 32 字节 HMAC secret;配置为 64 位小写十六进制,不得等于 session secret 或
任一设备 token。每条 claim 以版本域分隔 HMAC 绑定 device/task/authorization/attempt/generation/
32 字节随机 nonce,SQLite 只保存 nonce 和 token SHA-256。启动会重建并恒定时核对所有 open/closed
claim;secret 错误时失败闭合,不轮换 token。
- claim 与 renew 的 SQLite 事务必须先通过条件 no-op UPDATE 取得写入线性化位置并复核设备 ACTIVE,
才能读取或重放 request。撤销先提交则请求失败;claim/renew 先取得写位置则该事务可完成,随后撤销
不会自动释放已建立的 claim。
- 同一授权最多一个 attempt,同一设备最多一个未关闭 claim。EMPTY、人工恢复冲突和续租响应都持久化;
同幂等键只能重放原结果,不能因候选变化、续租或服务重启递增 generation、轮换 token 或延长第二次。
- claim 行同时冻结成功响应所需的 task 标题/版本以及 authorization 版本、规格、数量、总价上限和到期
时间。旧 request 跨重启只从该快照重建;源 task/authorization 后续漂移不能改变历史响应,并会让新
恢复或续租失败闭合。进入 `ORDERING` 的同 attempt 仅接受 task version 恰好比 claim 快照加一。
- 租约 TTL 显式配置为正且严格短于授权 TTL;新到期时间不得超过授权到期。租约与授权边界相等即过期,
没有宽限;过期、撤销、停止轮询和进程退出均不关闭 claim、不释放授权、不允许另一设备接管。
- 授权带 `expires_at`,只有围栏前可转 `EXPIRED` / `ABANDONED`;任务回到 `DRAFT`,必须重新点击 - 授权带 `expires_at`,只有围栏前可转 `EXPIRED` / `ABANDONED`;任务回到 `DRAFT`,必须重新点击
开始采购。旧授权永不复活。 开始采购。旧授权永不复活。
- 设备租约丢失不等于授权可安全重用。只有服务端确认该 attempt 未建立围栏,才能关闭 attempt 并 - 设备租约丢失不等于授权可安全重用。只有服务端确认该 attempt 未建立围栏,才能关闭 attempt 并
@@ -353,6 +400,9 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
PNG 魔数、完整解码、字节数、尺寸和调用方声明的 SHA-256。 PNG 魔数、完整解码、字节数、尺寸和调用方声明的 SHA-256。
- 上传 handler 必须先通过逐请求 SQLite 设备认证并再次校验规范 principal,再解析 Content-Type 或 - 上传 handler 必须先通过逐请求 SQLite 设备认证并再次校验规范 principal,再解析 Content-Type 或
读取 body。空库、无效或已撤销凭据拒绝,认证存储故障返回 503;不把管理员 session 当设备身份。 读取 body。空库、无效或已撤销凭据拒绝,认证存储故障返回 503;不把管理员 session 当设备身份。
- 首次 evidence INSERT 必须由 store 校验和 SQLite trigger 双重证明 `(task, attempt, authenticated device)`
对应未关闭 claim。历史同设备/upload key 重放先于该检查,因此人工关闭 claim 不会破坏已提交资产的
幂等读取;关闭后禁止新 upload key,且不为此增加 token/session 字段或放宽截图 kind。
- 文件写入显式配置的私有证据根目录:同目录随机临时文件 → 流式 hash → 校验 → `fsync` → 原子 - 文件写入显式配置的私有证据根目录:同目录随机临时文件 → 流式 hash → 校验 → `fsync` → 原子
rename 到 SHA-256 内容地址 → 最后事务写数据库。数据库永远不指向半文件或缺失文件。 rename 到 SHA-256 内容地址 → 最后事务写数据库。数据库永远不指向半文件或缺失文件。
- SQLite 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除 - SQLite 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除
+86 -27
View File
@@ -48,7 +48,7 @@
"code": "version_conflict", "code": "version_conflict",
"message": "任务已变化,请刷新后重选", "message": "任务已变化,请刷新后重选",
"retryable": false, "retryable": false,
"request_id": "018f..." "request_id": "c3c9f507-7473-4fa6-8d71-8786c34c6301"
} }
} }
``` ```
@@ -85,7 +85,7 @@
```json ```json
{ {
"create_key": "018f...", "create_key": "d3c9f507-7473-4fa6-8d71-8786c34c6301",
"title": "纯棉短袖", "title": "纯棉短袖",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375", "product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
"sku_color": "黑色CHA(纯棉)", "sku_color": "黑色CHA(纯棉)",
@@ -103,10 +103,10 @@
```json ```json
{ {
"start_key": "018f...", "start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"tasks": [ "tasks": [
{"task_id": "018f-task-1", "expected_task_version": 1}, {"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1},
{"task_id": "018f-task-2", "expected_task_version": 1} {"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1}
] ]
} }
``` ```
@@ -125,11 +125,11 @@
```json ```json
{ {
"start_key": "018f...", "start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"authorized_count": 2, "authorized_count": 2,
"tasks": [ "tasks": [
{"task_id": "018f-task-1", "task_version": 2, "authorization_id": "018f-auth-1"}, {"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "a3c9f507-7473-4fa6-8d71-8786c34c6301"},
{"task_id": "018f-task-2", "task_version": 2, "authorization_id": "018f-auth-2"} {"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "b3c9f507-7473-4fa6-8d71-8786c34c6301"}
], ],
"payment_automated": false "payment_automated": false
} }
@@ -160,7 +160,7 @@
```json ```json
{ {
"device_id": "desk-01", "device_id": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
"client_version": "0.1.0", "client_version": "0.1.0",
"adb_serial": "192.168.0.173:5555", "adb_serial": "192.168.0.173:5555",
"android_release": "16", "android_release": "16",
@@ -173,12 +173,22 @@
### `POST /api/v1/tasks/claim-next` ### `POST /api/v1/tasks/claim-next`
请求携带 `device_id`、`session_id`、`claim_request_id`。领取与授权绑定且具租约: 设备 id 只来自已经认证的 `X-CMBuyer-Device-ID`,不得放进 JSON。请求体上限 4096 字节,只接受
以下两个字段;二者都必须是规范小写 UUIDv4,未知字段、重复字段和额外 JSON 均拒绝:
```json
{
"session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_request_id": "33c9f507-7473-4fa6-8d71-8786c34c6301"
}
```
成功领取或同一会话恢复返回 `200`:
```json ```json
{ {
"task": { "task": {
"id": "018f-task", "id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"version": 3, "version": 3,
"title": "纯棉短袖", "title": "纯棉短袖",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375", "product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
@@ -189,24 +199,71 @@
"max_total_price": "30.00" "max_total_price": "30.00"
}, },
"authorization": { "authorization": {
"id": "018f-auth", "id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
"task_version": 2, "task_version": 2,
"expires_at": "2026-08-04T10:00:00Z" "expires_at": "2026-08-04T10:00:00Z"
}, },
"attempt": { "attempt": {
"id": "018f-attempt", "id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_token": "opaque-single-claim-token", "claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"claim_generation": 1, "claim_generation": 1,
"lease_expires_at": "2026-08-04T09:05:00Z" "lease_expires_at": "2026-08-04T09:05:00Z"
} }
} }
``` ```
- 只返回有 `ACTIVE` 授权的 `PENDING`;服务端在一个事务中转为 `CLAIMED` 并创建 attempt。 - 设备认证发生在 Content-Type 解析和 body 读取前。事务的第一条数据库业务语句取得 SQLite 写入
- 同一 `claim_request_id` 同载荷重放同一结果;并发设备只有一个成功。 位置并再次条件确认设备仍为 `ACTIVE`;之后才允许查幂等记录、候选或返回 EMPTY/冲突。
- 一个设备有未结束领取时优先重放该领取,不能悄悄领第二条。 - 只返回 `PENDING + ACTIVE + 严格未过期` 且 task/version/规格/数量/总价快照完全一致的最早授权;
服务端在同一事务中创建唯一 attempt/claim/request,并转为 `CLAIMED`。
- 同一 `claim_request_id` 同设备、同 session 稳定重放原结果;同键异载荷返回
`409 {"error":"idempotency_conflict"}`。没有候选返回空 `204`,且 EMPTY 也持久化稳定重放。
- claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、
数量、金额、版本或到期时间漂移。源快照不一致时,新恢复/续租失败闭合。
- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端
首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约
过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。
- `claim_token` 是 32 字节 HMAC 的 64 位小写十六进制表示,只证明一个 attempt 的归属,不是提交许可。
SQLite 仅保存随机 nonce 与 token SHA-256;同一 secret 重启后重建相同 token,错误 secret 拒绝启动。
- 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。 - 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。
### `POST /api/v1/tasks/{id}/lease/renew`
请求体同样限 4096 字节并执行严格 JSON 校验:
```json
{
"renew_request_id": "43c9f507-7473-4fa6-8d71-8786c34c6301",
"session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_generation": 1,
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"expected_lease_expires_at": "2026-08-04T09:05:00Z"
}
```
成功返回 `200`;响应不回显 token:
```json
{
"task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:06:00Z"
}
```
- 原设备/session/attempt/generation/token 必须同时匹配,当前租约与授权都必须严格晚于服务端 UTC
当前时间,`expected_lease_expires_at` 必须逐字等于数据库当前值;边界相等即过期且不能复活。
- 新到期时间是 `min(server_now + CMBUYER_CLAIM_LEASE_TTL, authorization.expires_at)`。续租不改变
token、generation、任务版本或业务状态。
- 成功续租先持久化 request 与响应;同一 `renew_request_id` 同载荷只重放旧响应,不再次 CAS 或延长。
同键异载荷返回 `idempotency_conflict`;非当前 claim 固定返回 `claim_not_current`,两者均为 `409`。
claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限为
`413 {"error":"request_too_large"}`,Content-Type 错误为
`415 {"error":"unsupported_media_type"}`;设备认证/事务内撤销为无诊断 `401`,存储故障为无诊断 `503`。
### 事件与证据 ### 事件与证据
事件只包含固定 `step` / `outcome` / `reason_code` 和非敏感摘要。禁止把完整 XML、地址、手机号、 事件只包含固定 `step` / `outcome` / `reason_code` 和非敏感摘要。禁止把完整 XML、地址、手机号、
@@ -234,8 +291,10 @@
- 恰好一个带 `Content-Type: image/png` 的显式文件;除上述六个元数据字段外,未知或重复字段均拒绝。 - 恰好一个带 `Content-Type: image/png` 的显式文件;除上述六个元数据字段外,未知或重复字段均拒绝。
- 单文件最多 10 MiB、单边最多 8192 px、总像素最多 16,777,216;服务端校验 PNG 魔数、完整解码、 - 单文件最多 10 MiB、单边最多 8192 px、总像素最多 16,777,216;服务端校验 PNG 魔数、完整解码、
字节数、尺寸与调用方声明的 64 位小写 SHA-256。 字节数、尺寸与调用方声明的 64 位小写 SHA-256。
- `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task。认证必须先于 Content-Type 解析和请求体读取。 - `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task,且首次写入必须存在由认证设备持有的
- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;任务、attempt、截图或元数据变化返回 `409`。 未关闭 claim;设备 A 不能向设备 B 的 attempt 上传。认证必须先于 Content-Type 解析和请求体读取。
- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;即使 claim 后续由人工关闭,已成功资产仍先
重放历史结果。关闭后不得用新 upload key 写新证据;任务、attempt、截图或元数据变化返回 `409`。
- 首次成功返回 `201`,幂等重放返回 `200`。响应只含资产 id、关联 id、kind/tier、hash、字节数、 - 首次成功返回 `201`,幂等重放返回 `200`。响应只含资产 id、关联 id、kind/tier、hash、字节数、
MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。 MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。
- 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、 - 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、
@@ -247,11 +306,11 @@
```json ```json
{ {
"fence_key": "018f-fence-request", "fence_key": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
"task_id": "018f-task", "task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"expected_task_version": 3, "expected_task_version": 3,
"authorization_id": "018f-auth", "authorization_id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_token": "opaque-single-claim-token", "claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"selected_color": "黑色CHA(纯棉)", "selected_color": "黑色CHA(纯棉)",
"selected_size": "M(建议100-115)", "selected_size": "M(建议100-115)",
"gate1_unit_price": "12.88", "gate1_unit_price": "12.88",
@@ -271,7 +330,7 @@
```json ```json
{ {
"submission_id": "018f-submission", "submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
"status": "FENCED", "status": "FENCED",
"click_permitted": true, "click_permitted": true,
"submit_text": "提交订单" "submit_text": "提交订单"
@@ -288,10 +347,10 @@
```json ```json
{ {
"result_key": "018f-result", "result_key": "03c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "018f-attempt", "attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"observation": "SUBMITTED", "observation": "SUBMITTED",
"evidence_asset_id": "018f-asset" "evidence_asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301"
} }
``` ```