diff --git a/README.md b/README.md index 4ab1bc5..18f245b 100644 --- a/README.md +++ b/README.md @@ -84,5 +84,5 @@ go run ./cmd/api - [当前实现状态](docs/current-state.md) - [完整文档导航](docs/README.md) -Android Phase 0/1 和后端 T-201 至 T-204 已完成。真实状态以 +Android Phase 0/1 和后端 T-201 至 T-205 已完成。真实状态以 [`docs/current-state.md`](docs/current-state.md) 为准。 diff --git a/backend-api/README.md b/backend-api/README.md index 0391ea5..467ffeb 100644 --- a/backend-api/README.md +++ b/backend-api/README.md @@ -3,6 +3,8 @@ Go 1.23.0、Gin 1.11.0 和 SQLite 构成的单进程采购任务服务。当前提供参考图上传与 规范化、任务创建/列表/详情/取消 API、服务端渲染管理页面、健康检查和显式数据库 迁移,并使用 ADMIN 服务端会话和 BUYER + 预授权设备联合身份隔离管理与执行接口。 +App 设备接口已支持 readiness heartbeat、原子领取、安全幂等重放、参考图授权、 +start/运行续租/release 和取消安全确认。 ## 环境 @@ -19,6 +21,9 @@ Go 1.23.0、Gin 1.11.0 和 SQLite 构成的单进程采购任务服务。当前 | `CMROUBAO_ASSET_DIR` | `var/assets` | 规范化参考图片的受控本地目录 | | `CMROUBAO_TLS_CERT_FILE` | 无 | TLS certificate;必须和 private key 同时设置 | | `CMROUBAO_TLS_KEY_FILE` | 无 | TLS private key;非 loopback 监听必须设置 | +| `CMROUBAO_CLAIM_LEASE` | `10m` | CLAIMED 租约;允许 `1m` 至 `30m` | +| `CMROUBAO_RUNNING_LEASE` | `90s` | RUNNING/等待确认租约;允许 `30s` 至 `10m` | +| `CMROUBAO_READINESS_TTL` | `2m` | 设备就绪 heartbeat 新鲜度;允许 `30s` 至 `10m` | 不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。 @@ -60,7 +65,7 @@ API 启动前会检查全部 migration 已应用;发现 pending migration 会 - `GET /tasks`:任务列表、搜索和状态筛选。 - `GET /tasks/new`:上传参考图并创建任务。 -- `GET /tasks/{id}`:查看原始约束和任务状态,待领取任务可取消。 +- `GET /tasks/{id}`:查看原始约束和任务状态;未执行任务立即取消,执行中请求安全停止。 - `GET/POST /login`、`POST /logout`:建立或撤销 8 小时 ADMIN 会话。 未登录管理页面会跳转 `/login`,未授权管理 API 返回 `401`。Cookie 认证的管理 API @@ -68,6 +73,11 @@ API 启动前会检查全部 migration 已应用;发现 pending migration 会 Cookie 与 BUYER Bearer token 不能互换。两类登录在凭证校验前按来源地址独立限流: 5 分钟最多 10 次,超限返回 `429` 和 `Retry-After`,成功后清零。 +设备任务路由为 `/api/v1/devices/heartbeat`、`/api/v1/tasks/claim-next` 及 task-scoped +`reference-image/start/heartbeat/release/cancel-ack`。除设备 heartbeat 外,任务读取 +和迁移都受当前用户、设备、claim generation、`X-Claim-Token` 与服务端租约约束; +原 claim token 只由 App 生成和保存,服务端数据库只存 SHA-256。 + 默认 loopback 可使用 HTTP 开发。局域网监听必须同时设置 certificate/private key, 服务直接使用 TLS 启动,不会降级为明文。完整 API 合约见 [`../docs/api.md`](../docs/api.md)。 diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go index dd58716..f38489b 100644 --- a/backend-api/cmd/api/main.go +++ b/backend-api/cmd/api/main.go @@ -169,10 +169,30 @@ func buildRouter( if err != nil { return nil, err } + lifecycle, err := usecase.NewLifecycleService( + store, + clock, + ids, + cfg.ClaimLease, + cfg.RunningLease, + cfg.ReadinessTTL, + ) + if err != nil { + return nil, err + } passwords, err := password.NewBcrypt(12) if err != nil { return nil, err } + registerDeviceRoutes, err := httpapi.NewDeviceRouteRegistrar( + httpapi.DeviceServices{ + Lifecycle: lifecycle, + Assets: assets, + }, + ) + if err != nil { + return nil, err + } auth, err := usecase.NewAuthService( store, passwords, @@ -247,8 +267,10 @@ func buildRouter( authWebHandler.RegisterPublic(routes) return registerPublicAuth(routes) }, - RegisterAdminRoutes: registerProtectedRoutes, - AdminSessions: auth, + RegisterAdminRoutes: registerProtectedRoutes, + RegisterDeviceRoutes: registerDeviceRoutes, + AdminSessions: auth, + DeviceAccess: auth, LogEvent: func(event string) { log.Print(event) }, diff --git a/backend-api/cmd/api/main_test.go b/backend-api/cmd/api/main_test.go index edeffd3..a8e9997 100644 --- a/backend-api/cmd/api/main_test.go +++ b/backend-api/cmd/api/main_test.go @@ -137,6 +137,9 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) { } router, err := buildRouter(ctx, config.Config{ AssetDirectory: filepath.Join(t.TempDir(), "assets"), + ClaimLease: 10 * time.Minute, + RunningLease: 90 * time.Second, + ReadinessTTL: 2 * time.Minute, }, db) if err != nil { t.Fatalf("buildRouter() error = %v", err) diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go index 3f0a040..7ea0bca 100644 --- a/backend-api/internal/config/config.go +++ b/backend-api/internal/config/config.go @@ -15,10 +15,16 @@ const ( AssetDirectoryEnvironment = "CMROUBAO_ASSET_DIR" TLSCertificateEnvironment = "CMROUBAO_TLS_CERT_FILE" TLSPrivateKeyEnvironment = "CMROUBAO_TLS_KEY_FILE" + ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE" + RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE" + ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL" defaultHTTPAddress = "127.0.0.1:8080" defaultDatabasePath = "var/cmroubao.db" defaultAssetDirectory = "var/assets" + defaultClaimLease = 10 * time.Minute + defaultRunningLease = 90 * time.Second + defaultReadinessTTL = 2 * time.Minute ) type LookupEnvironment func(string) (string, bool) @@ -35,6 +41,9 @@ type Config struct { IdleTimeout time.Duration ShutdownTimeout time.Duration MaxHeaderBytes int + ClaimLease time.Duration + RunningLease time.Duration + ReadinessTTL time.Duration } func Load(lookup LookupEnvironment) (Config, error) { @@ -98,6 +107,36 @@ func Load(lookup LookupEnvironment) (Config, error) { " must use loopback unless TLS is configured", ) } + claimLease, err := durationEnvironment( + lookup, + ClaimLeaseEnvironment, + defaultClaimLease, + time.Minute, + 30*time.Minute, + ) + if err != nil { + return Config{}, err + } + runningLease, err := durationEnvironment( + lookup, + RunningLeaseEnvironment, + defaultRunningLease, + 30*time.Second, + 10*time.Minute, + ) + if err != nil { + return Config{}, err + } + readinessTTL, err := durationEnvironment( + lookup, + ReadinessTTLEnvironment, + defaultReadinessTTL, + 30*time.Second, + 10*time.Minute, + ) + if err != nil { + return Config{}, err + } return Config{ HTTPAddress: httpAddress, @@ -111,9 +150,34 @@ func Load(lookup LookupEnvironment) (Config, error) { IdleTimeout: 60 * time.Second, ShutdownTimeout: 10 * time.Second, MaxHeaderBytes: 1 << 20, + ClaimLease: claimLease, + RunningLease: runningLease, + ReadinessTTL: readinessTTL, }, nil } +func durationEnvironment( + lookup LookupEnvironment, + name string, + defaultValue time.Duration, + minimum time.Duration, + maximum time.Duration, +) (time.Duration, error) { + value, exists := lookup(name) + if !exists { + return defaultValue, nil + } + value = strings.TrimSpace(value) + duration, err := time.ParseDuration(value) + if err != nil || duration < minimum || duration > maximum { + return 0, errors.New( + name + " must be a duration between " + + minimum.String() + " and " + maximum.String(), + ) + } + return duration, nil +} + func cleanOptionalPath(value string) string { if value == "" { return "" diff --git a/backend-api/internal/config/config_test.go b/backend-api/internal/config/config_test.go index c33c5b4..6d82c79 100644 --- a/backend-api/internal/config/config_test.go +++ b/backend-api/internal/config/config_test.go @@ -29,6 +29,16 @@ func TestLoadUsesSafeDefaults(t *testing.T) { cfg.MaxHeaderBytes <= 0 { t.Fatal("server safety limits must all be positive") } + if cfg.ClaimLease != 10*time.Minute || + cfg.RunningLease != 90*time.Second || + cfg.ReadinessTTL != 2*time.Minute { + t.Fatalf( + "lifecycle durations = %s / %s / %s", + cfg.ClaimLease, + cfg.RunningLease, + cfg.ReadinessTTL, + ) + } if cfg.ShutdownTimeout > 30*time.Second { t.Fatalf("ShutdownTimeout = %s", cfg.ShutdownTimeout) } @@ -41,6 +51,9 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) { AssetDirectoryEnvironment: "tmp/assets", TLSCertificateEnvironment: "tmp/server.crt", TLSPrivateKeyEnvironment: "tmp/server.key", + ClaimLeaseEnvironment: "15m", + RunningLeaseEnvironment: "2m", + ReadinessTTLEnvironment: "3m", } cfg, err := Load(mapEnvironment(values)) @@ -65,6 +78,16 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) { cfg.TLSPrivateKey, ) } + if cfg.ClaimLease != 15*time.Minute || + cfg.RunningLease != 2*time.Minute || + cfg.ReadinessTTL != 3*time.Minute { + t.Fatalf( + "lifecycle durations = %s / %s / %s", + cfg.ClaimLease, + cfg.RunningLease, + cfg.ReadinessTTL, + ) + } } func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) { @@ -145,6 +168,24 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) { TLSPrivateKeyEnvironment: " ", }, }, + { + name: "claim lease below minimum", + values: map[string]string{ + ClaimLeaseEnvironment: "59s", + }, + }, + { + name: "running lease above maximum", + values: map[string]string{ + RunningLeaseEnvironment: "11m", + }, + }, + { + name: "invalid readiness TTL", + values: map[string]string{ + ReadinessTTLEnvironment: "soon", + }, + }, } for _, test := range tests { diff --git a/backend-api/internal/domain/auth.go b/backend-api/internal/domain/auth.go index d13368e..c016169 100644 --- a/backend-api/internal/domain/auth.go +++ b/backend-api/internal/domain/auth.go @@ -32,17 +32,20 @@ type User struct { } type Device struct { - ID string - Name string - TokenHash string - BoundUserID *string - AppVersion *string - AndroidVersion *string - PDDVersion *string - LastSeenAt *time.Time - IsEnabled bool - CreatedAt time.Time - UpdatedAt time.Time + ID string + Name string + TokenHash string + BoundUserID *string + AppVersion *string + AndroidVersion *string + PDDVersion *string + LastSeenAt *time.Time + ReadinessAt *time.Time + AccessibilityEnabled bool + PDDInstalled bool + IsEnabled bool + CreatedAt time.Time + UpdatedAt time.Time } type AdminSession struct { diff --git a/backend-api/internal/domain/lifecycle_test.go b/backend-api/internal/domain/lifecycle_test.go new file mode 100644 index 0000000..8fd88ad --- /dev/null +++ b/backend-api/internal/domain/lifecycle_test.go @@ -0,0 +1,45 @@ +package domain + +import "testing" + +func TestLifecycleStatusPredicates(t *testing.T) { + statuses := []TaskStatus{ + TaskStatusPending, + TaskStatusClaimed, + TaskStatusRunning, + TaskStatusWaitingConfirmation, + TaskStatusSucceeded, + TaskStatusFailed, + TaskStatusCanceled, + } + for _, status := range statuses { + t.Run(string(status), func(t *testing.T) { + active := status == TaskStatusClaimed || + status == TaskStatusRunning || + status == TaskStatusWaitingConfirmation + if IsActiveTaskStatus(status) != active { + t.Fatalf("IsActiveTaskStatus(%s) = %v", status, !active) + } + if CanStart(status) != (status == TaskStatusClaimed) { + t.Fatalf("CanStart(%s) mismatch", status) + } + heartbeat := status == TaskStatusRunning || + status == TaskStatusWaitingConfirmation + if CanHeartbeat(status) != heartbeat { + t.Fatalf("CanHeartbeat(%s) mismatch", status) + } + if CanRelease(status) != (status == TaskStatusClaimed) { + t.Fatalf("CanRelease(%s) mismatch", status) + } + immediateCancel := status == TaskStatusPending || + status == TaskStatusClaimed + if CanAdminCancelImmediately(status) != immediateCancel { + t.Fatalf("CanAdminCancelImmediately(%s) mismatch", status) + } + if CanRequestCancel(status) != heartbeat || + CanAcknowledgeCancel(status) != heartbeat { + t.Fatalf("cancel predicates for %s mismatch", status) + } + }) + } +} diff --git a/backend-api/internal/domain/task.go b/backend-api/internal/domain/task.go index 10ba420..d12a001 100644 --- a/backend-api/internal/domain/task.go +++ b/backend-api/internal/domain/task.go @@ -33,38 +33,62 @@ const ( ) type PurchaseTask struct { - ID string - CreatorSubject string - CreatedByUserID *string - SourceRef *string - Title string - Description string - SKU string - ImageAssetID string - Quantity int - MaxBudgetCents *int64 - Currency string - Status TaskStatus - Version int64 - CancelReason *string - CanceledAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time + ID string + CreatorSubject string + CreatedByUserID *string + SourceRef *string + Title string + Description string + SKU string + ImageAssetID string + Quantity int + MaxBudgetCents *int64 + Currency string + Status TaskStatus + Version int64 + ClaimedByUserID *string + ClaimedByDeviceID *string + ClaimGeneration int64 + ClaimTokenHash *string + ClaimIssuedAt *time.Time + ClaimExpiresAt *time.Time + CancelReason *string + CancelRequestedAt *time.Time + CancelRequestedByUserID *string + CanceledAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time } type TaskEvent struct { - ID string - TaskID string - ActorUserID *string - Type string - Message string - OccurredAt time.Time + ID string + TaskID string + ActorUserID *string + ActorDeviceID *string + Type string + Message string + OccurredAt time.Time +} + +type TaskExecution struct { + ID string + TaskID string + AttemptNo int64 + ClaimGeneration int64 + UserID string + DeviceID string + CurrentStep string + OrderSubmitted bool + StartedAt time.Time + LastHeartbeatAt *time.Time + FinishedAt *time.Time } type TaskDetail struct { - Task PurchaseTask - Asset Asset - Events []TaskEvent + Task PurchaseTask + Asset Asset + Execution *TaskExecution + Events []TaskEvent } type TaskValidationError struct { @@ -192,7 +216,45 @@ func FormatOptionalCNY(cents *int64) *string { } func CanCancel(status TaskStatus) bool { - return status == TaskStatusPending + return CanAdminCancelImmediately(status) || CanRequestCancel(status) +} + +func IsActiveTaskStatus(status TaskStatus) bool { + switch status { + case TaskStatusClaimed, + TaskStatusRunning, + TaskStatusWaitingConfirmation: + return true + default: + return false + } +} + +func CanStart(status TaskStatus) bool { + return status == TaskStatusClaimed +} + +func CanHeartbeat(status TaskStatus) bool { + return status == TaskStatusRunning || + status == TaskStatusWaitingConfirmation +} + +func CanRelease(status TaskStatus) bool { + return status == TaskStatusClaimed +} + +func CanAdminCancelImmediately(status TaskStatus) bool { + return status == TaskStatusPending || + status == TaskStatusClaimed +} + +func CanRequestCancel(status TaskStatus) bool { + return status == TaskStatusRunning || + status == TaskStatusWaitingConfirmation +} + +func CanAcknowledgeCancel(status TaskStatus) bool { + return CanRequestCancel(status) } func IsValidTaskStatus(status TaskStatus) bool { diff --git a/backend-api/internal/domain/task_test.go b/backend-api/internal/domain/task_test.go index bdc0602..6396775 100644 --- a/backend-api/internal/domain/task_test.go +++ b/backend-api/internal/domain/task_test.go @@ -107,11 +107,8 @@ func TestParseOptionalCNYRejectsInvalidAndOverflow(t *testing.T) { } } -func TestCanCancelOnlyPending(t *testing.T) { +func TestCanCancelOnlyNonTerminalTask(t *testing.T) { for _, status := range []TaskStatus{ - TaskStatusClaimed, - TaskStatusRunning, - TaskStatusWaitingConfirmation, TaskStatusSucceeded, TaskStatusFailed, TaskStatusCanceled, @@ -120,8 +117,15 @@ func TestCanCancelOnlyPending(t *testing.T) { t.Fatalf("CanCancel(%s) = true", status) } } - if !CanCancel(TaskStatusPending) { - t.Fatal("CanCancel(PENDING) = false") + for _, status := range []TaskStatus{ + TaskStatusPending, + TaskStatusClaimed, + TaskStatusRunning, + TaskStatusWaitingConfirmation, + } { + if !CanCancel(status) { + t.Fatalf("CanCancel(%s) = false", status) + } } } diff --git a/backend-api/internal/platform/migration/claims_migration_test.go b/backend-api/internal/platform/migration/claims_migration_test.go new file mode 100644 index 0000000..7c010c6 --- /dev/null +++ b/backend-api/internal/platform/migration/claims_migration_test.go @@ -0,0 +1,544 @@ +package migration + +import ( + "context" + "database/sql" + "path/filepath" + "strings" + "testing" + + "cmroubao/backend-api/internal/platform/database" +) + +const ( + claimsUserID = "00000000-0000-4000-8000-000000000401" + claimsDeviceID = "00000000-0000-4000-8000-000000000402" + claimsAssetID = "00000000-0000-4000-8000-000000000403" + claimsTaskID = "00000000-0000-4000-8000-000000000404" + claimsEventID = "00000000-0000-4000-8000-000000000405" + claimsExecutionID = "00000000-0000-4000-8000-000000000406" + claimsSecondAsset = "00000000-0000-4000-8000-000000000407" + claimsSecondTask = "00000000-0000-4000-8000-000000000408" + claimsNewEventID = "00000000-0000-4000-8000-000000000409" + claimsSecondExec = "00000000-0000-4000-8000-000000000410" + claimsTimestamp = "2026-07-26T12:00:00Z" + claimsExpiry = "2026-07-26T12:10:00Z" + claimsCreator = "local-admin" + claimsRequestSHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + claimsDeviceSecret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { + db, runner := openClaimsMigrationDatabase(t) + ctx := context.Background() + + if applied, err := runner.Up(ctx); err != nil { + t.Fatalf("initial Up() error = %v", err) + } else if applied != 4 { + t.Fatalf("initial Up() applied = %d, want 4", applied) + } + if err := runner.Down(ctx); err != nil { + t.Fatalf("initial Down(v4) error = %v", err) + } + + seedClaimsHistoricalFixture(t, db) + + if applied, err := runner.Up(ctx); err != nil { + t.Fatalf("Up(v4) over historical data error = %v", err) + } else if applied != 1 { + t.Fatalf("Up(v4) applied = %d, want 1", applied) + } + assertClaimsHistory(t, db, true) + + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v4) with compatible history error = %v", err) + } + assertClaimsHistory(t, db, false) + + if applied, err := runner.Up(ctx); err != nil { + t.Fatalf("final Up(v4) error = %v", err) + } else if applied != 1 { + t.Fatalf("final Up(v4) applied = %d, want 1", applied) + } + assertClaimsHistory(t, db, true) +} + +func TestClaimsMigrationEnforcesLifecycleConstraints(t *testing.T) { + db, runner := openClaimsMigrationDatabase(t) + if _, err := runner.Up(context.Background()); err != nil { + t.Fatalf("Up() error = %v", err) + } + seedClaimsHistoricalFixture(t, db) + insertClaimsTask( + t, + db, + claimsSecondAsset, + claimsSecondTask, + "second-source", + ) + + assertClaimsStatementRejected( + t, + db, + `UPDATE devices SET accessibility_enabled = 2 WHERE id = ?`, + claimsDeviceID, + ) + assertClaimsStatementRejected( + t, + db, + `UPDATE devices SET pdd_installed = -1 WHERE id = ?`, + claimsDeviceID, + ) + assertClaimsStatementRejected( + t, + db, + `UPDATE purchase_tasks SET claim_token_hash = ? WHERE id = ?`, + strings.Repeat("A", 64), + claimsTaskID, + ) + + if _, err := db.Exec( + `UPDATE purchase_tasks + SET status = 'CLAIMED', + claimed_by_user_id = ?, + claimed_by_device_id = ?, + claim_generation = 1, + claim_token_hash = ?, + claim_issued_at = ?, + claim_expires_at = ? + WHERE id = ?`, + claimsUserID, + claimsDeviceID, + claimsRequestSHA, + claimsTimestamp, + claimsExpiry, + claimsTaskID, + ); err != nil { + t.Fatalf("set first active claim: %v", err) + } + assertClaimsStatementRejected( + t, + db, + `UPDATE purchase_tasks + SET status = 'CLAIMED', + claimed_by_user_id = ?, + claimed_by_device_id = ?, + claim_generation = 1, + claim_token_hash = ?, + claim_issued_at = ?, + claim_expires_at = ? + WHERE id = ?`, + claimsUserID, + claimsDeviceID, + claimsRequestSHA, + claimsTimestamp, + claimsExpiry, + claimsSecondTask, + ) + + assertClaimsStatementRejected( + t, + db, + `INSERT INTO task_executions ( + id, task_id, attempt_no, claim_generation, user_id, device_id, + current_step, last_heartbeat_at, order_submitted, started_at + ) VALUES (?, ?, 1, 1, ?, ?, 'STARTED', ?, 1, ?)`, + claimsExecutionID, + claimsTaskID, + claimsUserID, + claimsDeviceID, + claimsTimestamp, + claimsTimestamp, + ) + if _, err := db.Exec( + `INSERT INTO task_executions ( + id, task_id, attempt_no, claim_generation, user_id, device_id, + current_step, last_heartbeat_at, order_submitted, started_at + ) VALUES (?, ?, 1, 1, ?, ?, 'STARTED', ?, 0, ?)`, + claimsExecutionID, + claimsTaskID, + claimsUserID, + claimsDeviceID, + claimsTimestamp, + claimsTimestamp, + ); err != nil { + t.Fatalf("insert valid execution: %v", err) + } + assertClaimsStatementRejected( + t, + db, + `INSERT INTO task_executions ( + id, task_id, attempt_no, claim_generation, user_id, device_id, + current_step, last_heartbeat_at, order_submitted, started_at + ) VALUES (?, ?, 1, 1, ?, ?, ?, ?, 0, ?)`, + claimsSecondExec, + claimsSecondTask, + claimsUserID, + claimsDeviceID, + strings.Repeat("A", 65), + claimsTimestamp, + claimsTimestamp, + ) + + if _, err := db.Exec( + `INSERT INTO lifecycle_requests ( + user_id, device_id, operation, idempotency_key, + request_sha256, result_kind, created_at + ) VALUES (?, ?, 'CLAIM_NEXT', 'no-task', ?, 'NO_TASK', ?)`, + claimsUserID, + claimsDeviceID, + claimsRequestSHA, + claimsTimestamp, + ); err != nil { + t.Fatalf("insert valid NO_TASK replay: %v", err) + } + assertClaimsStatementRejected( + t, + db, + `INSERT INTO lifecycle_requests ( + user_id, device_id, operation, idempotency_key, + request_sha256, result_kind, created_at + ) VALUES (?, ?, 'START', 'invalid-no-task', ?, 'NO_TASK', ?)`, + claimsUserID, + claimsDeviceID, + claimsRequestSHA, + claimsTimestamp, + ) + if _, err := db.Exec( + `INSERT INTO lifecycle_requests ( + user_id, device_id, operation, idempotency_key, + request_sha256, result_kind, task_id, claim_generation, + execution_id, created_at + ) VALUES (?, ?, 'START', 'valid-start', ?, 'EXECUTION', ?, 1, ?, ?)`, + claimsUserID, + claimsDeviceID, + claimsRequestSHA, + claimsTaskID, + claimsExecutionID, + claimsTimestamp, + ); err != nil { + t.Fatalf("insert valid START replay: %v", err) + } + if _, err := db.Exec( + `INSERT INTO lifecycle_requests ( + user_id, device_id, operation, idempotency_key, + request_sha256, result_kind, task_id, claim_generation, created_at + ) VALUES (?, ?, 'RELEASE', 'valid-release', ?, 'TASK', ?, 1, ?)`, + claimsUserID, + claimsDeviceID, + claimsRequestSHA, + claimsTaskID, + claimsTimestamp, + ); err != nil { + t.Fatalf("insert valid RELEASE replay: %v", err) + } + if _, err := db.Exec( + `INSERT INTO lifecycle_requests ( + user_id, device_id, operation, idempotency_key, + request_sha256, result_kind, task_id, claim_generation, + execution_id, created_at + ) VALUES (?, ?, 'CANCEL_ACK', 'valid-cancel-ack', ?, 'TASK', ?, 1, ?, ?)`, + claimsUserID, + claimsDeviceID, + claimsRequestSHA, + claimsTaskID, + claimsExecutionID, + claimsTimestamp, + ); err != nil { + t.Fatalf("insert valid CANCEL_ACK replay: %v", err) + } + assertClaimsStatementRejected( + t, + db, + `INSERT INTO lifecycle_requests ( + user_id, device_id, operation, idempotency_key, + request_sha256, result_kind, task_id, claim_generation, created_at + ) VALUES (?, ?, 'CANCEL_ACK', 'cancel-ack-without-execution', ?, + 'TASK', ?, 1, ?)`, + claimsUserID, + claimsDeviceID, + claimsRequestSHA, + claimsTaskID, + claimsTimestamp, + ) + assertClaimsStatementRejected( + t, + db, + `INSERT INTO task_events ( + id, task_id, event_type, message, occurred_at + ) VALUES (?, ?, 'TASK_UNKNOWN', 'unknown', ?)`, + claimsNewEventID, + claimsTaskID, + claimsTimestamp, + ) +} + +func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) { + db, runner := openClaimsMigrationDatabase(t) + ctx := context.Background() + if _, err := runner.Up(ctx); err != nil { + t.Fatalf("Up() error = %v", err) + } + seedClaimsHistoricalFixture(t, db) + if _, err := db.Exec( + `INSERT INTO task_events ( + id, task_id, event_type, message, occurred_at, + actor_user_id, actor_device_id + ) VALUES (?, ?, 'TASK_CLAIMED', 'claimed', ?, ?, ?)`, + claimsNewEventID, + claimsTaskID, + claimsTimestamp, + claimsUserID, + claimsDeviceID, + ); err != nil { + t.Fatalf("insert v4 audit event: %v", err) + } + + if err := runner.Down(ctx); err == nil { + t.Fatal("Down(v4) succeeded with non-representable audit event") + } + if !claimsColumnExists(t, db, "task_events", "actor_device_id") { + t.Fatal("failed Down(v4) partially replaced task_events") + } + if !claimsTableExists(t, db, "task_executions") { + t.Fatal("failed Down(v4) partially removed task_executions") + } + var count int + if err := db.QueryRow( + "SELECT COUNT(*) FROM task_events WHERE id = ?", + claimsNewEventID, + ).Scan(&count); err != nil { + t.Fatalf("query retained audit event: %v", err) + } + if count != 1 { + t.Fatalf("retained audit event count = %d, want 1", count) + } +} + +func openClaimsMigrationDatabase( + t *testing.T, +) (*sql.DB, *Runner) { + t.Helper() + db, err := database.Open( + context.Background(), + filepath.Join(t.TempDir(), "claims-migration.db"), + ) + if err != nil { + t.Fatalf("database.Open() error = %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + runner, err := New(db) + if err != nil { + t.Fatalf("New() error = %v", err) + } + return db, runner +} + +func seedClaimsHistoricalFixture(t *testing.T, db *sql.DB) { + t.Helper() + statements := []struct { + query string + args []any + }{ + { + `INSERT INTO users ( + id, username, password_hash, role, is_active, created_at, updated_at + ) VALUES (?, 'buyer', 'hash', 'BUYER', 1, ?, ?)`, + []any{claimsUserID, claimsTimestamp, claimsTimestamp}, + }, + { + `INSERT INTO devices ( + id, name, token_hash, bound_user_id, is_enabled, created_at, updated_at + ) VALUES (?, 'historical device', ?, ?, 1, ?, ?)`, + []any{ + claimsDeviceID, + claimsDeviceSecret, + claimsUserID, + claimsTimestamp, + claimsTimestamp, + }, + }, + } + for _, statement := range statements { + if _, err := db.Exec(statement.query, statement.args...); err != nil { + t.Fatalf("seed historical auth data: %v", err) + } + } + insertClaimsTask(t, db, claimsAssetID, claimsTaskID, "historical-source") + if _, err := db.Exec( + `INSERT INTO task_events ( + id, task_id, event_type, message, occurred_at, actor_user_id + ) VALUES (?, ?, 'TASK_CREATED', 'created', ?, ?)`, + claimsEventID, + claimsTaskID, + claimsTimestamp, + claimsUserID, + ); err != nil { + t.Fatalf("seed historical task event: %v", err) + } +} + +func insertClaimsTask( + t *testing.T, + db *sql.DB, + assetID string, + taskID string, + sourceRef string, +) { + t.Helper() + if _, err := db.Exec( + `INSERT INTO assets ( + id, creator_subject, purpose, media_type, size_bytes, + sha256, storage_key, created_at + ) VALUES (?, ?, 'TASK_REFERENCE', 'image/jpeg', 10, ?, ?, ?)`, + assetID, + claimsCreator, + claimsRequestSHA, + "assets/"+assetID+".jpg", + claimsTimestamp, + ); err != nil { + t.Fatalf("insert asset %s: %v", assetID, err) + } + if _, err := db.Exec( + `INSERT INTO purchase_tasks ( + id, creator_subject, source_ref, title, description, sku, + image_asset_id, quantity, currency, status, version, + created_at, updated_at, created_by_user_id + ) VALUES (?, ?, ?, 'title', 'description', 'sku', ?, 1, 'CNY', + 'PENDING', 1, ?, ?, ?)`, + taskID, + claimsCreator, + sourceRef, + assetID, + claimsTimestamp, + claimsTimestamp, + claimsUserID, + ); err != nil { + t.Fatalf("insert task %s: %v", taskID, err) + } +} + +func assertClaimsHistory(t *testing.T, db *sql.DB, v4 bool) { + t.Helper() + var taskCount int + if err := db.QueryRow( + "SELECT COUNT(*) FROM purchase_tasks WHERE id = ?", + claimsTaskID, + ).Scan(&taskCount); err != nil { + t.Fatalf("query historical task: %v", err) + } + if taskCount != 1 { + t.Fatalf("historical task count = %d, want 1", taskCount) + } + var actorUserID sql.NullString + if err := db.QueryRow( + "SELECT actor_user_id FROM task_events WHERE id = ?", + claimsEventID, + ).Scan(&actorUserID); err != nil { + t.Fatalf("query historical event actor: %v", err) + } + if !actorUserID.Valid || actorUserID.String != claimsUserID { + t.Fatalf("historical event actor = %v, want %s", actorUserID, claimsUserID) + } + if claimsColumnExists( + t, + db, + "purchase_tasks", + "claim_generation", + ) != v4 { + t.Fatalf("claim_generation presence does not match v4=%t", v4) + } + if claimsColumnExists( + t, + db, + "task_events", + "actor_device_id", + ) != v4 { + t.Fatalf("actor_device_id presence does not match v4=%t", v4) + } + if claimsTableExists(t, db, "task_executions") != v4 { + t.Fatalf("task_executions presence does not match v4=%t", v4) + } + if v4 { + var generation int + var tokenHash sql.NullString + if err := db.QueryRow( + `SELECT claim_generation, claim_token_hash + FROM purchase_tasks WHERE id = ?`, + claimsTaskID, + ).Scan(&generation, &tokenHash); err != nil { + t.Fatalf("query historical claim defaults: %v", err) + } + if generation != 0 || tokenHash.Valid { + t.Fatalf( + "historical claim defaults generation=%d token=%v", + generation, + tokenHash, + ) + } + } +} + +func assertClaimsStatementRejected( + t *testing.T, + db *sql.DB, + query string, + args ...any, +) { + t.Helper() + if _, err := db.Exec(query, args...); err == nil { + t.Fatalf("statement unexpectedly succeeded: %s", query) + } +} + +func claimsTableExists(t *testing.T, db *sql.DB, name string) bool { + t.Helper() + var count int + if err := db.QueryRow( + `SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = ?`, + name, + ).Scan(&count); err != nil { + t.Fatalf("query table %s: %v", name, err) + } + return count == 1 +} + +func claimsColumnExists( + t *testing.T, + db *sql.DB, + table string, + column string, +) bool { + t.Helper() + rows, err := db.Query("PRAGMA table_info(" + table + ")") + if err != nil { + t.Fatalf("PRAGMA table_info(%s): %v", table, err) + } + defer rows.Close() + for rows.Next() { + var cid int + var name string + var dataType string + var notNull int + var defaultValue any + var primaryKey int + if err := rows.Scan( + &cid, + &name, + &dataType, + ¬Null, + &defaultValue, + &primaryKey, + ); err != nil { + t.Fatalf("scan table_info(%s): %v", table, err) + } + if name == column { + return true + } + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate table_info(%s): %v", table, err) + } + return false +} diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go index fe39cf0..3df07b0 100644 --- a/backend-api/internal/platform/migration/runner_test.go +++ b/backend-api/internal/platform/migration/runner_test.go @@ -27,13 +27,14 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { if err != nil { t.Fatalf("Up() error = %v", err) } - if applied != 3 { - t.Fatalf("Up() applied = %d, want 3", applied) + if applied != 4 { + t.Fatalf("Up() applied = %d, want 4", applied) } assertStatuses(t, runner, map[int64]bool{ 1: true, 2: true, 3: true, + 4: true, }) applied, err = runner.Up(context.Background()) @@ -50,7 +51,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { assertStatuses(t, runner, map[int64]bool{ 1: true, 2: true, - 3: false, + 3: true, + 4: false, }) applied, err = runner.Up(context.Background()) @@ -64,6 +66,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 1: true, 2: true, 3: true, + 4: true, }) } diff --git a/backend-api/internal/repository/sqlite/auth_repository.go b/backend-api/internal/repository/sqlite/auth_repository.go index acb7710..cd18a86 100644 --- a/backend-api/internal/repository/sqlite/auth_repository.go +++ b/backend-api/internal/repository/sqlite/auth_repository.go @@ -476,13 +476,16 @@ func getDeviceByID( var androidVersion sql.NullString var pddVersion sql.NullString var lastSeenAt sql.NullString + var readinessAt sql.NullString var createdAt string var updatedAt string err := queryer.QueryRowContext( ctx, `SELECT id, name, token_hash, bound_user_id, app_version, android_version, - pdd_version, last_seen_at, is_enabled, created_at, updated_at + pdd_version, last_seen_at, readiness_reported_at, + accessibility_enabled, pdd_installed, is_enabled, + created_at, updated_at FROM devices WHERE id = ?`, deviceID, @@ -495,6 +498,9 @@ func getDeviceByID( &androidVersion, &pddVersion, &lastSeenAt, + &readinessAt, + &device.AccessibilityEnabled, + &device.PDDInstalled, &device.IsEnabled, &createdAt, &updatedAt, @@ -524,6 +530,13 @@ func getDeviceByID( } device.LastSeenAt = &parsed } + if readinessAt.Valid { + parsed, err := parseTimestamp(readinessAt.String) + if err != nil { + return domain.Device{}, err + } + device.ReadinessAt = &parsed + } device.CreatedAt, err = parseTimestamp(createdAt) if err != nil { return domain.Device{}, err diff --git a/backend-api/internal/repository/sqlite/auth_repository_test.go b/backend-api/internal/repository/sqlite/auth_repository_test.go index 4cd043e..818e15a 100644 --- a/backend-api/internal/repository/sqlite/auth_repository_test.go +++ b/backend-api/internal/repository/sqlite/auth_repository_test.go @@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("Down(v4) error = %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("Down(v3) error = %v", err) } @@ -399,9 +402,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( t.Fatal("purchase_tasks was lost during auth migration rollback") } if applied, err := runner.Up(context.Background()); err != nil { - t.Fatalf("Up(v3) error = %v", err) - } else if applied != 1 { - t.Fatalf("Up(v3) applied = %d, want 1", applied) + t.Fatalf("Up(v3-v4) error = %v", err) + } else if applied != 2 { + t.Fatalf("Up(v3-v4) applied = %d, want 2", applied) } } diff --git a/backend-api/internal/repository/sqlite/helpers.go b/backend-api/internal/repository/sqlite/helpers.go index e2b3ca0..ee80689 100644 --- a/backend-api/internal/repository/sqlite/helpers.go +++ b/backend-api/internal/repository/sqlite/helpers.go @@ -14,7 +14,7 @@ import ( sqlite3 "github.com/mattn/go-sqlite3" ) -const timestampLayout = time.RFC3339Nano +const storageTimestampLayout = "2006-01-02T15:04:05.000000000Z" type queryRower interface { QueryRowContext(context.Context, string, ...any) *sql.Row @@ -52,7 +52,14 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) { var createdByUserID sql.NullString var sourceRef sql.NullString var maxBudget sql.NullInt64 + var claimedByUserID sql.NullString + var claimedByDeviceID sql.NullString + var claimTokenHash sql.NullString + var claimIssuedAt sql.NullString + var claimExpiresAt sql.NullString var cancelReason sql.NullString + var cancelRequestedAt sql.NullString + var cancelRequestedByUserID sql.NullString var canceledAt sql.NullString var createdAt string var updatedAt string @@ -70,7 +77,15 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) { &task.Currency, &task.Status, &task.Version, + &claimedByUserID, + &claimedByDeviceID, + &task.ClaimGeneration, + &claimTokenHash, + &claimIssuedAt, + &claimExpiresAt, &cancelReason, + &cancelRequestedAt, + &cancelRequestedByUserID, &canceledAt, &createdAt, &updatedAt, @@ -87,15 +102,36 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) { if maxBudget.Valid { task.MaxBudgetCents = &maxBudget.Int64 } + if claimedByUserID.Valid { + task.ClaimedByUserID = &claimedByUserID.String + } + if claimedByDeviceID.Valid { + task.ClaimedByDeviceID = &claimedByDeviceID.String + } + if claimTokenHash.Valid { + task.ClaimTokenHash = &claimTokenHash.String + } + task.ClaimIssuedAt, err = parseNullableTimestamp(claimIssuedAt) + if err != nil { + return domain.PurchaseTask{}, err + } + task.ClaimExpiresAt, err = parseNullableTimestamp(claimExpiresAt) + if err != nil { + return domain.PurchaseTask{}, err + } if cancelReason.Valid { task.CancelReason = &cancelReason.String } - if canceledAt.Valid { - value, err := parseTimestamp(canceledAt.String) - if err != nil { - return domain.PurchaseTask{}, err - } - task.CanceledAt = &value + task.CancelRequestedAt, err = parseNullableTimestamp(cancelRequestedAt) + if err != nil { + return domain.PurchaseTask{}, err + } + if cancelRequestedByUserID.Valid { + task.CancelRequestedByUserID = &cancelRequestedByUserID.String + } + task.CanceledAt, err = parseNullableTimestamp(canceledAt) + if err != nil { + return domain.PurchaseTask{}, err } task.CreatedAt, err = parseTimestamp(createdAt) if err != nil { @@ -108,6 +144,42 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) { return task, nil } +func scanExecution(scanner rowScanner) (domain.TaskExecution, error) { + var execution domain.TaskExecution + var lastHeartbeatAt sql.NullString + var finishedAt sql.NullString + var startedAt string + err := scanner.Scan( + &execution.ID, + &execution.TaskID, + &execution.AttemptNo, + &execution.ClaimGeneration, + &execution.UserID, + &execution.DeviceID, + &execution.CurrentStep, + &execution.OrderSubmitted, + &startedAt, + &lastHeartbeatAt, + &finishedAt, + ) + if err != nil { + return domain.TaskExecution{}, err + } + execution.StartedAt, err = parseTimestamp(startedAt) + if err != nil { + return domain.TaskExecution{}, err + } + execution.LastHeartbeatAt, err = parseNullableTimestamp(lastHeartbeatAt) + if err != nil { + return domain.TaskExecution{}, err + } + execution.FinishedAt, err = parseNullableTimestamp(finishedAt) + if err != nil { + return domain.TaskExecution{}, err + } + return execution, nil +} + func getAssetByID( ctx context.Context, queryer queryRower, @@ -144,7 +216,10 @@ func getTaskByID( `SELECT id, creator_subject, created_by_user_id, source_ref, title, description, sku, image_asset_id, quantity, max_budget_cents, currency, status, - version, cancel_reason, canceled_at, created_at, updated_at + version, claimed_by_user_id, claimed_by_device_id, claim_generation, + claim_token_hash, claim_issued_at, claim_expires_at, cancel_reason, + cancel_requested_at, cancel_requested_by_user_id, canceled_at, + created_at, updated_at FROM purchase_tasks WHERE creator_subject = ? AND id = ?`, creatorSubject, @@ -218,11 +293,11 @@ func insertIdempotency( } func formatTimestamp(value time.Time) string { - return value.UTC().Format(timestampLayout) + return value.UTC().Format(storageTimestampLayout) } func parseTimestamp(value string) (time.Time, error) { - parsed, err := time.Parse(timestampLayout, value) + parsed, err := time.Parse(time.RFC3339Nano, value) if err != nil { return time.Time{}, fmt.Errorf( "%w: invalid stored timestamp", @@ -232,6 +307,17 @@ func parseTimestamp(value string) (time.Time, error) { return parsed.UTC(), nil } +func parseNullableTimestamp(value sql.NullString) (*time.Time, error) { + if !value.Valid { + return nil, nil + } + parsed, err := parseTimestamp(value.String) + if err != nil { + return nil, err + } + return &parsed, nil +} + func nullableString(value *string) any { if value == nil { return nil diff --git a/backend-api/internal/repository/sqlite/lifecycle_repository.go b/backend-api/internal/repository/sqlite/lifecycle_repository.go new file mode 100644 index 0000000..4f01f6d --- /dev/null +++ b/backend-api/internal/repository/sqlite/lifecycle_repository.go @@ -0,0 +1,1247 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "time" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" +) + +const ( + lifecycleOperationClaimNext = "CLAIM_NEXT" + lifecycleOperationStart = "START" + lifecycleOperationRelease = "RELEASE" + lifecycleOperationCancelAck = "CANCEL_ACK" +) + +type lifecycleRequestRecord struct { + RequestHash string + ResultKind string + TaskID *string + ClaimGeneration *int64 + ExecutionID *string +} + +func (s *Store) RecordDeviceHeartbeat( + ctx context.Context, + update usecase.DeviceHeartbeatUpdate, +) (usecase.DeviceHeartbeatRecord, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return usecase.DeviceHeartbeatRecord{}, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + + device, err := getDeviceByID(ctx, tx, update.DeviceID) + if err != nil { + if errors.Is(err, usecase.ErrRepositoryNotFound) { + return usecase.DeviceHeartbeatRecord{}, usecase.ErrClaimInvalid + } + return usecase.DeviceHeartbeatRecord{}, err + } + if !device.IsEnabled || + device.BoundUserID == nil || + *device.BoundUserID != update.UserID { + return usecase.DeviceHeartbeatRecord{}, usecase.ErrClaimInvalid + } + _, err = tx.ExecContext( + ctx, + `UPDATE devices + SET app_version = ?, + android_version = ?, + pdd_version = ?, + last_seen_at = ?, + readiness_reported_at = ?, + accessibility_enabled = ?, + pdd_installed = ?, + updated_at = ? + WHERE id = ?`, + update.AppVersion, + update.AndroidVersion, + update.PDDVersion, + formatTimestamp(update.ReportedAt), + formatTimestamp(update.ReportedAt), + update.AccessibilityEnabled, + update.PDDInstalled, + formatTimestamp(update.ReportedAt), + update.DeviceID, + ) + if err != nil { + return usecase.DeviceHeartbeatRecord{}, repositoryFailure(err) + } + device, err = getDeviceByID(ctx, tx, update.DeviceID) + if err != nil { + return usecase.DeviceHeartbeatRecord{}, err + } + + var activeTaskID string + err = tx.QueryRowContext( + ctx, + `SELECT id + FROM purchase_tasks + WHERE claimed_by_device_id = ? + AND ( + status IN ('RUNNING', 'WAITING_CONFIRMATION') + OR ( + status = 'CLAIMED' + AND claim_expires_at > ? + ) + ) + ORDER BY updated_at DESC, id DESC + LIMIT 1`, + update.DeviceID, + formatTimestamp(update.ReportedAt), + ).Scan(&activeTaskID) + var activeTaskIDPointer *string + if errors.Is(err, sql.ErrNoRows) { + activeTaskIDPointer = nil + } else if err != nil { + return usecase.DeviceHeartbeatRecord{}, repositoryFailure(err) + } else { + activeTaskIDPointer = &activeTaskID + } + if err := tx.Commit(); err != nil { + return usecase.DeviceHeartbeatRecord{}, repositoryFailure(err) + } + return usecase.DeviceHeartbeatRecord{ + Device: device, + ActiveTaskID: activeTaskIDPointer, + }, nil +} + +func (s *Store) ClaimNext( + ctx context.Context, + request usecase.ClaimNextRepositoryRequest, +) (usecase.ClaimNextRepositoryResult, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return usecase.ClaimNextRepositoryResult{}, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + + record, found, err := lookupLifecycleRequest( + ctx, + tx, + request.UserID, + request.DeviceID, + lifecycleOperationClaimNext, + request.IdempotencyKey, + ) + if err != nil { + return usecase.ClaimNextRepositoryResult{}, err + } + if found { + return replayClaimNext(ctx, tx, request, record) + } + if err := ensureDeviceReady(ctx, tx, request); err != nil { + return usecase.ClaimNextRepositoryResult{}, err + } + active, err := hasActiveDeviceTask( + ctx, + tx, + request.DeviceID, + request.Now, + ) + if err != nil { + return usecase.ClaimNextRepositoryResult{}, err + } + if active { + return usecase.ClaimNextRepositoryResult{}, + usecase.ErrDeviceActiveTask + } + + candidate, err := scanTask(tx.QueryRowContext( + ctx, + `SELECT + id, creator_subject, created_by_user_id, source_ref, title, description, sku, + image_asset_id, quantity, max_budget_cents, currency, status, + version, claimed_by_user_id, claimed_by_device_id, claim_generation, + claim_token_hash, claim_issued_at, claim_expires_at, cancel_reason, + cancel_requested_at, cancel_requested_by_user_id, canceled_at, + created_at, updated_at + FROM purchase_tasks + WHERE creator_subject = ? + AND ( + status = 'PENDING' + OR ( + status = 'CLAIMED' + AND claim_expires_at <= ? + ) + ) + ORDER BY + CASE + WHEN status = 'CLAIMED' + AND claimed_by_device_id = ? + THEN 0 + ELSE 1 + END, + created_at ASC, + id ASC + LIMIT 1`, + request.CreatorSubject, + formatTimestamp(request.Now), + request.DeviceID, + )) + if errors.Is(err, sql.ErrNoRows) { + if err := insertLifecycleRequest( + ctx, + tx, + request.UserID, + request.DeviceID, + lifecycleOperationClaimNext, + request.IdempotencyKey, + request.RequestHash, + "NO_TASK", + nil, + nil, + nil, + request.Now, + ); err != nil { + return usecase.ClaimNextRepositoryResult{}, err + } + if err := tx.Commit(); err != nil { + return usecase.ClaimNextRepositoryResult{}, + repositoryFailure(err) + } + return usecase.ClaimNextRepositoryResult{}, nil + } + if err != nil { + return usecase.ClaimNextRepositoryResult{}, repositoryFailure(err) + } + + event := request.Event + event.TaskID = candidate.ID + if candidate.Status == domain.TaskStatusClaimed { + event.Type = "TASK_RECLAIMED" + event.Message = "expired task claim reclaimed" + } + result, err := tx.ExecContext( + ctx, + `UPDATE purchase_tasks + SET status = 'CLAIMED', + version = version + 1, + claimed_by_user_id = ?, + claimed_by_device_id = ?, + claim_generation = claim_generation + 1, + claim_token_hash = ?, + claim_issued_at = ?, + claim_expires_at = ?, + cancel_reason = NULL, + cancel_requested_at = NULL, + cancel_requested_by_user_id = NULL, + canceled_at = NULL, + updated_at = ? + WHERE id = ? + AND version = ? + AND ( + status = 'PENDING' + OR ( + status = 'CLAIMED' + AND claim_expires_at <= ? + ) + )`, + request.UserID, + request.DeviceID, + request.ClaimTokenHash, + formatTimestamp(request.Now), + formatTimestamp(request.ExpiresAt), + formatTimestamp(request.Now), + candidate.ID, + candidate.Version, + formatTimestamp(request.Now), + ) + if err != nil { + if isUniqueConstraint(err, "purchase_tasks.claimed_by_device_id") { + return usecase.ClaimNextRepositoryResult{}, + usecase.ErrDeviceActiveTask + } + return usecase.ClaimNextRepositoryResult{}, repositoryFailure(err) + } + affected, err := result.RowsAffected() + if err != nil { + return usecase.ClaimNextRepositoryResult{}, repositoryFailure(err) + } + if affected != 1 { + return usecase.ClaimNextRepositoryResult{}, + usecase.ErrTaskStateConflict + } + if err := insertTaskEvent(ctx, tx, event); err != nil { + return usecase.ClaimNextRepositoryResult{}, err + } + claimed, err := getTaskByID( + ctx, + tx, + request.CreatorSubject, + candidate.ID, + ) + if err != nil { + return usecase.ClaimNextRepositoryResult{}, err + } + taskID := claimed.ID + generation := claimed.ClaimGeneration + if err := insertLifecycleRequest( + ctx, + tx, + request.UserID, + request.DeviceID, + lifecycleOperationClaimNext, + request.IdempotencyKey, + request.RequestHash, + "TASK", + &taskID, + &generation, + nil, + request.Now, + ); err != nil { + return usecase.ClaimNextRepositoryResult{}, err + } + if err := tx.Commit(); err != nil { + return usecase.ClaimNextRepositoryResult{}, repositoryFailure(err) + } + return usecase.ClaimNextRepositoryResult{Task: &claimed}, nil +} + +func (s *Store) StartTask( + ctx context.Context, + request usecase.StartTaskRepositoryRequest, +) (usecase.StartTaskRepositoryResult, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return usecase.StartTaskRepositoryResult{}, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + + record, found, err := lookupLifecycleRequest( + ctx, + tx, + request.UserID, + request.DeviceID, + lifecycleOperationStart, + request.IdempotencyKey, + ) + if err != nil { + return usecase.StartTaskRepositoryResult{}, err + } + if found { + if record.RequestHash != request.RequestHash { + return usecase.StartTaskRepositoryResult{}, + usecase.ErrIdempotencyConflict + } + if record.ResultKind != "EXECUTION" || + record.TaskID == nil || + record.ExecutionID == nil || + *record.TaskID != request.TaskID { + return usecase.StartTaskRepositoryResult{}, + usecase.ErrRepositoryInvariant + } + task, err := getLifecycleTask(ctx, tx, request.TaskID) + if err != nil { + return usecase.StartTaskRepositoryResult{}, err + } + execution, err := getExecutionByID( + ctx, + tx, + *record.ExecutionID, + ) + if err != nil { + return usecase.StartTaskRepositoryResult{}, err + } + if err := tx.Commit(); err != nil { + return usecase.StartTaskRepositoryResult{}, + repositoryFailure(err) + } + return usecase.StartTaskRepositoryResult{ + Task: task, + Execution: execution, + Replayed: true, + }, nil + } + + task, err := getClaimProtectedTask(ctx, tx, request.TaskID) + if err != nil { + return usecase.StartTaskRepositoryResult{}, err + } + if err := validateClaim( + task, + request.UserID, + request.DeviceID, + request.ClaimGeneration, + request.ClaimTokenHash, + request.Now, + ); err != nil { + return usecase.StartTaskRepositoryResult{}, err + } + if !domain.CanStart(task.Status) { + return usecase.StartTaskRepositoryResult{}, + usecase.ErrTaskStateConflict + } + if task.Version != request.ExpectedVersion { + return usecase.StartTaskRepositoryResult{}, + usecase.ErrTaskVersionConflict + } + err = tx.QueryRowContext( + ctx, + `SELECT COALESCE(MAX(attempt_no), 0) + 1 + FROM task_executions + WHERE task_id = ?`, + request.TaskID, + ).Scan(&request.Execution.AttemptNo) + if err != nil { + return usecase.StartTaskRepositoryResult{}, repositoryFailure(err) + } + request.Execution.LastHeartbeatAt = &request.Now + _, err = tx.ExecContext( + ctx, + `INSERT INTO task_executions ( + id, task_id, attempt_no, claim_generation, user_id, device_id, + current_step, last_heartbeat_at, order_submitted, started_at, + finished_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL)`, + request.Execution.ID, + request.Execution.TaskID, + request.Execution.AttemptNo, + request.Execution.ClaimGeneration, + request.Execution.UserID, + request.Execution.DeviceID, + request.Execution.CurrentStep, + formatTimestamp(request.Now), + formatTimestamp(request.Execution.StartedAt), + ) + if err != nil { + return usecase.StartTaskRepositoryResult{}, repositoryFailure(err) + } + result, err := tx.ExecContext( + ctx, + `UPDATE purchase_tasks + SET status = 'RUNNING', + version = version + 1, + claim_expires_at = ?, + updated_at = ? + WHERE id = ? + AND status = 'CLAIMED' + AND version = ? + AND claimed_by_user_id = ? + AND claimed_by_device_id = ? + AND claim_generation = ? + AND claim_token_hash = ? + AND claim_expires_at > ?`, + formatTimestamp(request.ExpiresAt), + formatTimestamp(request.Now), + request.TaskID, + request.ExpectedVersion, + request.UserID, + request.DeviceID, + request.ClaimGeneration, + request.ClaimTokenHash, + formatTimestamp(request.Now), + ) + if err != nil { + return usecase.StartTaskRepositoryResult{}, repositoryFailure(err) + } + affected, err := result.RowsAffected() + if err != nil { + return usecase.StartTaskRepositoryResult{}, repositoryFailure(err) + } + if affected != 1 { + return usecase.StartTaskRepositoryResult{}, + usecase.ErrTaskVersionConflict + } + if err := insertTaskEvent(ctx, tx, request.Event); err != nil { + return usecase.StartTaskRepositoryResult{}, err + } + executionID := request.Execution.ID + taskID := request.TaskID + generation := request.ClaimGeneration + if err := insertLifecycleRequest( + ctx, + tx, + request.UserID, + request.DeviceID, + lifecycleOperationStart, + request.IdempotencyKey, + request.RequestHash, + "EXECUTION", + &taskID, + &generation, + &executionID, + request.Now, + ); err != nil { + return usecase.StartTaskRepositoryResult{}, err + } + task, err = getLifecycleTask(ctx, tx, request.TaskID) + if err != nil { + return usecase.StartTaskRepositoryResult{}, err + } + if err := tx.Commit(); err != nil { + return usecase.StartTaskRepositoryResult{}, repositoryFailure(err) + } + return usecase.StartTaskRepositoryResult{ + Task: task, + Execution: request.Execution, + }, nil +} + +func (s *Store) HeartbeatTask( + ctx context.Context, + request usecase.TaskHeartbeatRepositoryRequest, +) (usecase.TaskHeartbeatRepositoryResult, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + + task, err := getClaimProtectedTask(ctx, tx, request.TaskID) + if err != nil { + return usecase.TaskHeartbeatRepositoryResult{}, err + } + if err := validateClaim( + task, + request.UserID, + request.DeviceID, + request.ClaimGeneration, + request.ClaimTokenHash, + request.Now, + ); err != nil { + return usecase.TaskHeartbeatRepositoryResult{}, err + } + if !domain.CanHeartbeat(task.Status) { + return usecase.TaskHeartbeatRepositoryResult{}, + usecase.ErrTaskStateConflict + } + execution, err := getExecutionByID(ctx, tx, request.ExecutionID) + if err != nil { + return usecase.TaskHeartbeatRepositoryResult{}, err + } + if execution.TaskID != request.TaskID || + execution.UserID != request.UserID || + execution.DeviceID != request.DeviceID || + execution.ClaimGeneration != request.ClaimGeneration || + execution.FinishedAt != nil { + return usecase.TaskHeartbeatRepositoryResult{}, + usecase.ErrExecutionMismatch + } + expiresAt := *task.ClaimExpiresAt + if request.MinimumExpiry.After(expiresAt) { + expiresAt = request.MinimumExpiry + } + result, err := tx.ExecContext( + ctx, + `UPDATE task_executions + SET current_step = ?, + last_heartbeat_at = ? + WHERE id = ? + AND finished_at IS NULL`, + request.Step, + formatTimestamp(request.Now), + request.ExecutionID, + ) + if err != nil { + return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err) + } + affected, err := result.RowsAffected() + if err != nil { + return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err) + } + if affected != 1 { + return usecase.TaskHeartbeatRepositoryResult{}, + usecase.ErrExecutionMismatch + } + result, err = tx.ExecContext( + ctx, + `UPDATE purchase_tasks + SET version = version + 1, + claim_expires_at = ?, + updated_at = ? + WHERE id = ? + AND status IN ('RUNNING', 'WAITING_CONFIRMATION') + AND claimed_by_user_id = ? + AND claimed_by_device_id = ? + AND claim_generation = ? + AND claim_token_hash = ? + AND claim_expires_at > ?`, + formatTimestamp(expiresAt), + formatTimestamp(request.Now), + request.TaskID, + request.UserID, + request.DeviceID, + request.ClaimGeneration, + request.ClaimTokenHash, + formatTimestamp(request.Now), + ) + if err != nil { + return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err) + } + affected, err = result.RowsAffected() + if err != nil { + return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err) + } + if affected != 1 { + return usecase.TaskHeartbeatRepositoryResult{}, + usecase.ErrClaimExpired + } + _, err = tx.ExecContext( + ctx, + `UPDATE devices + SET last_seen_at = ?, + updated_at = ? + WHERE id = ?`, + formatTimestamp(request.Now), + formatTimestamp(request.Now), + request.DeviceID, + ) + if err != nil { + return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err) + } + task, err = getLifecycleTask(ctx, tx, request.TaskID) + if err != nil { + return usecase.TaskHeartbeatRepositoryResult{}, err + } + execution, err = getExecutionByID(ctx, tx, request.ExecutionID) + if err != nil { + return usecase.TaskHeartbeatRepositoryResult{}, err + } + if err := tx.Commit(); err != nil { + return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err) + } + return usecase.TaskHeartbeatRepositoryResult{ + Task: task, + Execution: execution, + CancelRequested: task.CancelRequestedAt != nil, + }, nil +} + +func (s *Store) GetActiveClaimTask( + ctx context.Context, + request usecase.TaskClaimRepositoryRequest, +) (domain.PurchaseTask, error) { + task, err := getClaimProtectedTask(ctx, s.db, request.TaskID) + if err != nil { + return domain.PurchaseTask{}, err + } + if err := validateClaim( + task, + request.UserID, + request.DeviceID, + request.ClaimGeneration, + request.ClaimTokenHash, + request.Now, + ); err != nil { + return domain.PurchaseTask{}, err + } + if !domain.IsActiveTaskStatus(task.Status) { + return domain.PurchaseTask{}, usecase.ErrTaskStateConflict + } + return task, nil +} + +func (s *Store) ReleaseTask( + ctx context.Context, + request usecase.ReleaseTaskRepositoryRequest, +) (domain.PurchaseTask, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + + record, found, err := lookupLifecycleRequest( + ctx, + tx, + request.UserID, + request.DeviceID, + lifecycleOperationRelease, + request.IdempotencyKey, + ) + if err != nil { + return domain.PurchaseTask{}, false, err + } + if found { + return replayTerminalTransition( + ctx, + tx, + request.RequestHash, + request.TaskID, + request.ClaimGeneration, + domain.TaskStatusPending, + record, + ) + } + task, err := getClaimProtectedTask(ctx, tx, request.TaskID) + if err != nil { + return domain.PurchaseTask{}, false, err + } + if err := validateClaim( + task, + request.UserID, + request.DeviceID, + request.ClaimGeneration, + request.ClaimTokenHash, + request.Now, + ); err != nil { + return domain.PurchaseTask{}, false, err + } + if !domain.CanRelease(task.Status) { + return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict + } + if task.Version != request.ExpectedVersion { + return domain.PurchaseTask{}, false, + usecase.ErrTaskVersionConflict + } + result, err := tx.ExecContext( + ctx, + `UPDATE purchase_tasks + SET status = 'PENDING', + version = version + 1, + claimed_by_user_id = NULL, + claimed_by_device_id = NULL, + claim_token_hash = NULL, + claim_issued_at = NULL, + claim_expires_at = NULL, + updated_at = ? + WHERE id = ? + AND status = 'CLAIMED' + AND version = ? + AND claimed_by_user_id = ? + AND claimed_by_device_id = ? + AND claim_generation = ? + AND claim_token_hash = ? + AND claim_expires_at > ?`, + formatTimestamp(request.Now), + request.TaskID, + request.ExpectedVersion, + request.UserID, + request.DeviceID, + request.ClaimGeneration, + request.ClaimTokenHash, + formatTimestamp(request.Now), + ) + if err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + affected, err := result.RowsAffected() + if err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + if affected != 1 { + return domain.PurchaseTask{}, false, + usecase.ErrTaskVersionConflict + } + if err := insertTaskEvent(ctx, tx, request.Event); err != nil { + return domain.PurchaseTask{}, false, err + } + taskID := request.TaskID + generation := request.ClaimGeneration + if err := insertLifecycleRequest( + ctx, + tx, + request.UserID, + request.DeviceID, + lifecycleOperationRelease, + request.IdempotencyKey, + request.RequestHash, + "TASK", + &taskID, + &generation, + nil, + request.Now, + ); err != nil { + return domain.PurchaseTask{}, false, err + } + task, err = getLifecycleTask(ctx, tx, request.TaskID) + if err != nil { + return domain.PurchaseTask{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + return task, false, nil +} + +func (s *Store) AcknowledgeTaskCancellation( + ctx context.Context, + request usecase.CancelAcknowledgementRepositoryRequest, +) (domain.PurchaseTask, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + + record, found, err := lookupLifecycleRequest( + ctx, + tx, + request.UserID, + request.DeviceID, + lifecycleOperationCancelAck, + request.IdempotencyKey, + ) + if err != nil { + return domain.PurchaseTask{}, false, err + } + if found { + return replayTerminalTransition( + ctx, + tx, + request.RequestHash, + request.TaskID, + request.ClaimGeneration, + domain.TaskStatusCanceled, + record, + ) + } + task, err := getClaimProtectedTask(ctx, tx, request.TaskID) + if err != nil { + return domain.PurchaseTask{}, false, err + } + if err := validateClaim( + task, + request.UserID, + request.DeviceID, + request.ClaimGeneration, + request.ClaimTokenHash, + request.Now, + ); err != nil { + return domain.PurchaseTask{}, false, err + } + if !domain.CanAcknowledgeCancel(task.Status) || + task.CancelRequestedAt == nil { + return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict + } + if task.Version != request.ExpectedVersion { + return domain.PurchaseTask{}, false, + usecase.ErrTaskVersionConflict + } + execution, err := getExecutionByID(ctx, tx, request.ExecutionID) + if err != nil { + return domain.PurchaseTask{}, false, err + } + if execution.TaskID != request.TaskID || + execution.UserID != request.UserID || + execution.DeviceID != request.DeviceID || + execution.ClaimGeneration != request.ClaimGeneration || + execution.FinishedAt != nil { + return domain.PurchaseTask{}, false, + usecase.ErrExecutionMismatch + } + result, err := tx.ExecContext( + ctx, + `UPDATE purchase_tasks + SET status = 'CANCELED', + version = version + 1, + claimed_by_user_id = NULL, + claimed_by_device_id = NULL, + claim_token_hash = NULL, + claim_issued_at = NULL, + claim_expires_at = NULL, + canceled_at = ?, + updated_at = ? + WHERE id = ? + AND status IN ('RUNNING', 'WAITING_CONFIRMATION') + AND version = ? + AND cancel_requested_at IS NOT NULL + AND claimed_by_user_id = ? + AND claimed_by_device_id = ? + AND claim_generation = ? + AND claim_token_hash = ? + AND claim_expires_at > ?`, + formatTimestamp(request.Now), + formatTimestamp(request.Now), + request.TaskID, + request.ExpectedVersion, + request.UserID, + request.DeviceID, + request.ClaimGeneration, + request.ClaimTokenHash, + formatTimestamp(request.Now), + ) + if err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + affected, err := result.RowsAffected() + if err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + if affected != 1 { + return domain.PurchaseTask{}, false, + usecase.ErrTaskVersionConflict + } + result, err = tx.ExecContext( + ctx, + `UPDATE task_executions + SET finished_at = ?, + last_heartbeat_at = ? + WHERE id = ? + AND finished_at IS NULL`, + formatTimestamp(request.Now), + formatTimestamp(request.Now), + request.ExecutionID, + ) + if err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + affected, err = result.RowsAffected() + if err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + if affected != 1 { + return domain.PurchaseTask{}, false, + usecase.ErrExecutionMismatch + } + if err := insertTaskEvent(ctx, tx, request.Event); err != nil { + return domain.PurchaseTask{}, false, err + } + taskID := request.TaskID + generation := request.ClaimGeneration + executionID := request.ExecutionID + if err := insertLifecycleRequest( + ctx, + tx, + request.UserID, + request.DeviceID, + lifecycleOperationCancelAck, + request.IdempotencyKey, + request.RequestHash, + "TASK", + &taskID, + &generation, + &executionID, + request.Now, + ); err != nil { + return domain.PurchaseTask{}, false, err + } + task, err = getLifecycleTask(ctx, tx, request.TaskID) + if err != nil { + return domain.PurchaseTask{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + return task, false, nil +} + +func replayClaimNext( + ctx context.Context, + tx *sql.Tx, + request usecase.ClaimNextRepositoryRequest, + record lifecycleRequestRecord, +) (usecase.ClaimNextRepositoryResult, error) { + if record.RequestHash != request.RequestHash { + return usecase.ClaimNextRepositoryResult{}, + usecase.ErrIdempotencyConflict + } + if record.ResultKind == "NO_TASK" { + if err := tx.Commit(); err != nil { + return usecase.ClaimNextRepositoryResult{}, + repositoryFailure(err) + } + return usecase.ClaimNextRepositoryResult{Replayed: true}, nil + } + if record.ResultKind != "TASK" || + record.TaskID == nil || + record.ClaimGeneration == nil { + return usecase.ClaimNextRepositoryResult{}, + usecase.ErrRepositoryInvariant + } + task, err := getTaskByID( + ctx, + tx, + request.CreatorSubject, + *record.TaskID, + ) + if err != nil { + return usecase.ClaimNextRepositoryResult{}, err + } + if !domain.IsActiveTaskStatus(task.Status) || + task.ClaimedByUserID == nil || + *task.ClaimedByUserID != request.UserID || + task.ClaimedByDeviceID == nil || + *task.ClaimedByDeviceID != request.DeviceID || + task.ClaimGeneration != *record.ClaimGeneration || + task.ClaimTokenHash == nil || + *task.ClaimTokenHash != request.ClaimTokenHash || + task.ClaimExpiresAt == nil || + !task.ClaimExpiresAt.After(request.Now) { + return usecase.ClaimNextRepositoryResult{}, + usecase.ErrClaimReplayExpired + } + if err := tx.Commit(); err != nil { + return usecase.ClaimNextRepositoryResult{}, + repositoryFailure(err) + } + return usecase.ClaimNextRepositoryResult{ + Task: &task, + Replayed: true, + }, nil +} + +func ensureDeviceReady( + ctx context.Context, + tx *sql.Tx, + request usecase.ClaimNextRepositoryRequest, +) error { + device, err := getDeviceByID(ctx, tx, request.DeviceID) + if err != nil { + if errors.Is(err, usecase.ErrRepositoryNotFound) { + return usecase.ErrClaimInvalid + } + return err + } + if !device.IsEnabled || + device.BoundUserID == nil || + *device.BoundUserID != request.UserID { + return usecase.ErrClaimInvalid + } + if device.ReadinessAt == nil || + device.ReadinessAt.Before(request.ReadinessAfter) || + !device.AccessibilityEnabled || + !device.PDDInstalled { + return usecase.ErrDeviceNotReady + } + return nil +} + +func hasActiveDeviceTask( + ctx context.Context, + tx *sql.Tx, + deviceID string, + now time.Time, +) (bool, error) { + var active int + err := tx.QueryRowContext( + ctx, + `SELECT EXISTS ( + SELECT 1 + FROM purchase_tasks + WHERE claimed_by_device_id = ? + AND ( + status IN ('RUNNING', 'WAITING_CONFIRMATION') + OR ( + status = 'CLAIMED' + AND claim_expires_at > ? + ) + ) + )`, + deviceID, + formatTimestamp(now), + ).Scan(&active) + if err != nil { + return false, repositoryFailure(err) + } + return active == 1, nil +} + +func validateClaim( + task domain.PurchaseTask, + userID string, + deviceID string, + generation int64, + tokenHash string, + now time.Time, +) error { + if task.ClaimedByUserID == nil || + *task.ClaimedByUserID != userID || + task.ClaimedByDeviceID == nil || + *task.ClaimedByDeviceID != deviceID || + task.ClaimGeneration != generation || + task.ClaimTokenHash == nil || + *task.ClaimTokenHash != tokenHash { + return usecase.ErrClaimInvalid + } + if task.ClaimExpiresAt == nil || !task.ClaimExpiresAt.After(now) { + return usecase.ErrClaimExpired + } + return nil +} + +func replayTerminalTransition( + ctx context.Context, + tx *sql.Tx, + requestHash string, + taskID string, + generation int64, + expectedStatus domain.TaskStatus, + record lifecycleRequestRecord, +) (domain.PurchaseTask, bool, error) { + if record.RequestHash != requestHash { + return domain.PurchaseTask{}, false, + usecase.ErrIdempotencyConflict + } + if record.ResultKind != "TASK" || + record.TaskID == nil || + record.ClaimGeneration == nil || + *record.TaskID != taskID || + *record.ClaimGeneration != generation { + return domain.PurchaseTask{}, false, + usecase.ErrRepositoryInvariant + } + task, err := getLifecycleTask(ctx, tx, taskID) + if err != nil { + return domain.PurchaseTask{}, false, err + } + if task.Status != expectedStatus || + task.ClaimGeneration != generation { + return domain.PurchaseTask{}, false, + usecase.ErrTaskStateConflict + } + if err := tx.Commit(); err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + return task, true, nil +} + +func lookupLifecycleRequest( + ctx context.Context, + tx *sql.Tx, + userID string, + deviceID string, + operation string, + idempotencyKey string, +) (lifecycleRequestRecord, bool, error) { + var record lifecycleRequestRecord + var taskID sql.NullString + var generation sql.NullInt64 + var executionID sql.NullString + err := tx.QueryRowContext( + ctx, + `SELECT + request_sha256, result_kind, task_id, + claim_generation, execution_id + FROM lifecycle_requests + WHERE user_id = ? + AND device_id = ? + AND operation = ? + AND idempotency_key = ?`, + userID, + deviceID, + operation, + idempotencyKey, + ).Scan( + &record.RequestHash, + &record.ResultKind, + &taskID, + &generation, + &executionID, + ) + if errors.Is(err, sql.ErrNoRows) { + return lifecycleRequestRecord{}, false, nil + } + if err != nil { + return lifecycleRequestRecord{}, false, repositoryFailure(err) + } + if taskID.Valid { + record.TaskID = &taskID.String + } + if generation.Valid { + record.ClaimGeneration = &generation.Int64 + } + if executionID.Valid { + record.ExecutionID = &executionID.String + } + return record, true, nil +} + +func insertLifecycleRequest( + ctx context.Context, + tx *sql.Tx, + userID string, + deviceID string, + operation string, + idempotencyKey string, + requestHash string, + resultKind string, + taskID *string, + generation *int64, + executionID *string, + now time.Time, +) error { + _, err := tx.ExecContext( + ctx, + `INSERT INTO lifecycle_requests ( + user_id, device_id, operation, idempotency_key, request_sha256, + result_kind, task_id, claim_generation, execution_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + userID, + deviceID, + operation, + idempotencyKey, + requestHash, + resultKind, + nullableString(taskID), + nullableInt64(generation), + nullableString(executionID), + formatTimestamp(now), + ) + if err != nil { + return repositoryFailure(err) + } + return nil +} + +func getLifecycleTask( + ctx context.Context, + queryer queryRower, + taskID string, +) (domain.PurchaseTask, error) { + task, err := scanTask(queryer.QueryRowContext( + ctx, + `SELECT + id, creator_subject, created_by_user_id, source_ref, title, description, sku, + image_asset_id, quantity, max_budget_cents, currency, status, + version, claimed_by_user_id, claimed_by_device_id, claim_generation, + claim_token_hash, claim_issued_at, claim_expires_at, cancel_reason, + cancel_requested_at, cancel_requested_by_user_id, canceled_at, + created_at, updated_at + FROM purchase_tasks + WHERE id = ?`, + taskID, + )) + if errors.Is(err, sql.ErrNoRows) { + return domain.PurchaseTask{}, usecase.ErrRepositoryNotFound + } + if err != nil { + return domain.PurchaseTask{}, repositoryFailure(err) + } + return task, nil +} + +func getClaimProtectedTask( + ctx context.Context, + queryer queryRower, + taskID string, +) (domain.PurchaseTask, error) { + task, err := getLifecycleTask(ctx, queryer, taskID) + if errors.Is(err, usecase.ErrRepositoryNotFound) { + return domain.PurchaseTask{}, usecase.ErrClaimInvalid + } + return task, err +} + +func getExecutionByID( + ctx context.Context, + queryer queryRower, + executionID string, +) (domain.TaskExecution, error) { + execution, err := scanExecution(queryer.QueryRowContext( + ctx, + `SELECT + id, task_id, attempt_no, claim_generation, user_id, device_id, + current_step, order_submitted, started_at, last_heartbeat_at, + finished_at + FROM task_executions + WHERE id = ?`, + executionID, + )) + if errors.Is(err, sql.ErrNoRows) { + return domain.TaskExecution{}, usecase.ErrExecutionMismatch + } + if err != nil { + return domain.TaskExecution{}, repositoryFailure(err) + } + return execution, nil +} + +var _ usecase.LifecycleRepository = (*Store)(nil) diff --git a/backend-api/internal/repository/sqlite/lifecycle_repository_test.go b/backend-api/internal/repository/sqlite/lifecycle_repository_test.go new file mode 100644 index 0000000..f7ed5b7 --- /dev/null +++ b/backend-api/internal/repository/sqlite/lifecycle_repository_test.go @@ -0,0 +1,1455 @@ +package sqlite_test + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "sync" + "testing" + "time" + + "cmroubao/backend-api/internal/domain" + platformdatabase "cmroubao/backend-api/internal/platform/database" + repository "cmroubao/backend-api/internal/repository/sqlite" + "cmroubao/backend-api/internal/usecase" +) + +const lifecycleCreatorSubject = "local-admin" + +type lifecycleFixture struct { + store *repository.Store + db *sql.DB + + adminUserID string + buyerOneID string + buyerTwoID string + deviceOneID string + deviceTwoID string +} + +func TestLifecycleRepositoryRecordsReadinessAndRequiresFreshReadyDevice( + t *testing.T, +) { + fixture := newLifecycleFixture(t) + ctx := context.Background() + now := lifecycleTime(0) + task := createLifecycleTask(t, fixture, 1, now.Add(-time.Minute)) + request := lifecycleClaimRequest( + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + 1, + now, + now.Add(10*time.Minute), + now.Add(-2*time.Minute), + ) + + _, err := fixture.store.ClaimNext(ctx, request) + assertLifecycleError(t, err, usecase.ErrDeviceNotReady) + + notReady, err := fixture.store.RecordDeviceHeartbeat( + ctx, + lifecycleDeviceHeartbeat( + fixture.buyerOneID, + fixture.deviceOneID, + now, + true, + false, + ), + ) + if err != nil { + t.Fatalf("RecordDeviceHeartbeat(not ready) error = %v", err) + } + if !notReady.Device.AccessibilityEnabled || + notReady.Device.PDDInstalled || + notReady.Device.ReadinessAt == nil || + !notReady.Device.ReadinessAt.Equal(now) { + t.Fatalf("not-ready heartbeat = %+v", notReady) + } + _, err = fixture.store.ClaimNext(ctx, request) + assertLifecycleError(t, err, usecase.ErrDeviceNotReady) + + readyAt := now.Add(3 * time.Minute) + ready, err := fixture.store.RecordDeviceHeartbeat( + ctx, + lifecycleDeviceHeartbeat( + fixture.buyerOneID, + fixture.deviceOneID, + readyAt, + true, + true, + ), + ) + if err != nil { + t.Fatalf("RecordDeviceHeartbeat(ready) error = %v", err) + } + if ready.Device.AppVersion == nil || + *ready.Device.AppVersion != "0.1.0" || + ready.Device.AndroidVersion == nil || + *ready.Device.AndroidVersion != "16" || + ready.Device.PDDVersion == nil || + *ready.Device.PDDVersion != "8.17.0" || + ready.Device.LastSeenAt == nil || + !ready.Device.LastSeenAt.Equal(readyAt) { + t.Fatalf("ready heartbeat = %+v", ready) + } + + staleRequest := request + staleRequest.Now = readyAt.Add(3 * time.Minute) + staleRequest.ExpiresAt = staleRequest.Now.Add(10 * time.Minute) + staleRequest.ReadinessAfter = staleRequest.Now.Add(-2 * time.Minute) + _, err = fixture.store.ClaimNext(ctx, staleRequest) + assertLifecycleError(t, err, usecase.ErrDeviceNotReady) + + freshAt := staleRequest.Now + if _, err := fixture.store.RecordDeviceHeartbeat( + ctx, + lifecycleDeviceHeartbeat( + fixture.buyerOneID, + fixture.deviceOneID, + freshAt, + true, + true, + ), + ); err != nil { + t.Fatalf("refresh readiness: %v", err) + } + staleRequest.IdempotencyKey = "claim-ready" + staleRequest.RequestHash = lifecycleHash("claim-ready") + staleRequest.ClaimTokenHash = lifecycleHash("claim-token-ready") + staleRequest.Event = lifecycleEvent( + 10, + "", + "TASK_CLAIMED", + freshAt, + fixture.buyerOneID, + fixture.deviceOneID, + ) + claimed, err := fixture.store.ClaimNext(ctx, staleRequest) + if err != nil || claimed.Task == nil || claimed.Task.ID != task.ID { + t.Fatalf("ClaimNext(fresh) = %+v, error = %v", claimed, err) + } + + active, err := fixture.store.RecordDeviceHeartbeat( + ctx, + lifecycleDeviceHeartbeat( + fixture.buyerOneID, + fixture.deviceOneID, + freshAt.Add(time.Second), + true, + true, + ), + ) + if err != nil { + t.Fatalf("RecordDeviceHeartbeat(active) error = %v", err) + } + if active.ActiveTaskID == nil || *active.ActiveTaskID != task.ID { + t.Fatalf("active task = %+v", active.ActiveTaskID) + } +} + +func TestLifecycleRepositoryClaimIsFIFOIdempotentAndDeviceSingleActive( + t *testing.T, +) { + fixture := newLifecycleFixture(t) + ctx := context.Background() + now := lifecycleTime(0) + older := createLifecycleTask(t, fixture, 1, now.Add(-2*time.Minute)) + _ = createLifecycleTask(t, fixture, 2, now.Add(-time.Minute)) + recordReadyHeartbeat( + t, + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + now, + ) + request := lifecycleClaimRequest( + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + 20, + now, + now.Add(10*time.Minute), + now.Add(-2*time.Minute), + ) + + first, err := fixture.store.ClaimNext(ctx, request) + if err != nil || first.Task == nil || first.Replayed { + t.Fatalf("first ClaimNext() = %+v, error = %v", first, err) + } + if first.Task.ID != older.ID || + first.Task.Status != domain.TaskStatusClaimed || + first.Task.Version != older.Version+1 || + first.Task.ClaimGeneration != 1 || + first.Task.ClaimTokenHash == nil || + *first.Task.ClaimTokenHash != request.ClaimTokenHash || + first.Task.ClaimedByDeviceID == nil || + *first.Task.ClaimedByDeviceID != fixture.deviceOneID { + t.Fatalf("claimed task = %+v", first.Task) + } + + replayRequest := request + replayRequest.Event = lifecycleEvent( + 21, + "", + "TASK_CLAIMED", + now.Add(time.Second), + fixture.buyerOneID, + fixture.deviceOneID, + ) + replayed, err := fixture.store.ClaimNext(ctx, replayRequest) + if err != nil || replayed.Task == nil || !replayed.Replayed || + replayed.Task.ID != first.Task.ID || + replayed.Task.ClaimGeneration != first.Task.ClaimGeneration || + replayed.Task.ClaimTokenHash == nil || + *replayed.Task.ClaimTokenHash != request.ClaimTokenHash { + t.Fatalf("replayed ClaimNext() = %+v, error = %v", replayed, err) + } + assertTaskEventCount(t, fixture.db, older.ID, "TASK_CLAIMED", 1) + + conflict := request + conflict.RequestHash = lifecycleHash("different-claim-request") + _, err = fixture.store.ClaimNext(ctx, conflict) + assertLifecycleError(t, err, usecase.ErrIdempotencyConflict) + + secondKey := request + secondKey.IdempotencyKey = "claim-second-click" + secondKey.RequestHash = lifecycleHash("claim-second-click") + secondKey.ClaimTokenHash = lifecycleHash("claim-token-second-click") + secondKey.Event = lifecycleEvent( + 22, + "", + "TASK_CLAIMED", + now.Add(2*time.Second), + fixture.buyerOneID, + fixture.deviceOneID, + ) + _, err = fixture.store.ClaimNext(ctx, secondKey) + assertLifecycleError(t, err, usecase.ErrDeviceActiveTask) +} + +func TestLifecycleRepositoryNoTaskReplayRemainsEmpty(t *testing.T) { + fixture := newLifecycleFixture(t) + ctx := context.Background() + now := lifecycleTime(0) + recordReadyHeartbeat( + t, + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + now, + ) + request := lifecycleClaimRequest( + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + 30, + now, + now.Add(10*time.Minute), + now.Add(-2*time.Minute), + ) + + empty, err := fixture.store.ClaimNext(ctx, request) + if err != nil || empty.Task != nil || empty.Replayed { + t.Fatalf("empty ClaimNext() = %+v, error = %v", empty, err) + } + _ = createLifecycleTask(t, fixture, 1, now.Add(time.Second)) + replayed, err := fixture.store.ClaimNext(ctx, request) + if err != nil || replayed.Task != nil || !replayed.Replayed { + t.Fatalf("empty replay = %+v, error = %v", replayed, err) + } + + newRequest := request + newRequest.IdempotencyKey = "claim-after-empty" + newRequest.RequestHash = lifecycleHash("claim-after-empty") + newRequest.ClaimTokenHash = lifecycleHash("claim-token-after-empty") + newRequest.Event = lifecycleEvent( + 31, + "", + "TASK_CLAIMED", + now.Add(2*time.Second), + fixture.buyerOneID, + fixture.deviceOneID, + ) + claimed, err := fixture.store.ClaimNext(ctx, newRequest) + if err != nil || claimed.Task == nil { + t.Fatalf("new claim after empty = %+v, error = %v", claimed, err) + } +} + +func TestLifecycleRepositoryReclaimsExpiredClaimAndRejectsOldReplay( + t *testing.T, +) { + fixture := newLifecycleFixture(t) + ctx := context.Background() + now := lifecycleTime(0) + task := createLifecycleTask(t, fixture, 1, now.Add(-time.Minute)) + recordReadyHeartbeat( + t, + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + now, + ) + recordReadyHeartbeat( + t, + fixture, + fixture.buyerTwoID, + fixture.deviceTwoID, + now, + ) + firstRequest := lifecycleClaimRequest( + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + 40, + now, + now.Add(time.Minute), + now.Add(-2*time.Minute), + ) + first, err := fixture.store.ClaimNext(ctx, firstRequest) + if err != nil || first.Task == nil { + t.Fatalf("first ClaimNext() = %+v, error = %v", first, err) + } + + reclaimAt := now.Add(2 * time.Minute) + secondRequest := lifecycleClaimRequest( + fixture, + fixture.buyerTwoID, + fixture.deviceTwoID, + 41, + reclaimAt, + reclaimAt.Add(10*time.Minute), + now.Add(-time.Second), + ) + reclaimed, err := fixture.store.ClaimNext(ctx, secondRequest) + if err != nil || reclaimed.Task == nil { + t.Fatalf("reclaim ClaimNext() = %+v, error = %v", reclaimed, err) + } + if reclaimed.Task.ID != task.ID || + reclaimed.Task.ClaimGeneration != 2 || + reclaimed.Task.ClaimedByUserID == nil || + *reclaimed.Task.ClaimedByUserID != fixture.buyerTwoID || + reclaimed.Task.ClaimedByDeviceID == nil || + *reclaimed.Task.ClaimedByDeviceID != fixture.deviceTwoID { + t.Fatalf("reclaimed task = %+v", reclaimed.Task) + } + assertTaskEventCount(t, fixture.db, task.ID, "TASK_RECLAIMED", 1) + + _, err = fixture.store.ClaimNext(ctx, firstRequest) + assertLifecycleError(t, err, usecase.ErrClaimReplayExpired) + + _, err = fixture.store.StartTask( + ctx, + lifecycleStartRequest( + fixture, + *first.Task, + fixture.buyerOneID, + fixture.deviceOneID, + firstRequest.ClaimTokenHash, + 42, + reclaimAt, + ), + ) + assertLifecycleError(t, err, usecase.ErrClaimInvalid) +} + +func TestLifecycleRepositoryReclaimsOwnExpiredClaimBeforeFIFO( + t *testing.T, +) { + fixture := newLifecycleFixture(t) + ctx := context.Background() + now := lifecycleTime(45) + ownTask := createLifecycleTask( + t, + fixture, + 1, + now.Add(-5*time.Minute), + ) + recordReadyHeartbeat( + t, + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + now.Add(-4*time.Minute), + ) + firstRequest := lifecycleClaimRequest( + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + 45, + now.Add(-4*time.Minute), + now.Add(-time.Minute), + now.Add(-6*time.Minute), + ) + first, err := fixture.store.ClaimNext(ctx, firstRequest) + if err != nil || first.Task == nil || first.Task.ID != ownTask.ID { + t.Fatalf("first claim = %+v, error = %v", first, err) + } + olderPending := createLifecycleTask( + t, + fixture, + 2, + now.Add(-10*time.Minute), + ) + reclaimRequest := lifecycleClaimRequest( + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + 46, + now, + now.Add(10*time.Minute), + now.Add(-10*time.Minute), + ) + reclaimed, err := fixture.store.ClaimNext(ctx, reclaimRequest) + if err != nil || reclaimed.Task == nil || + reclaimed.Task.ID != ownTask.ID { + t.Fatalf("own expired reclaim = %+v, error = %v", reclaimed, err) + } + if reclaimed.Task.ClaimGeneration != 2 { + t.Fatalf("reclaimed generation = %d", reclaimed.Task.ClaimGeneration) + } + pending, err := fixture.store.GetTaskDetail( + ctx, + lifecycleCreatorSubject, + olderPending.ID, + ) + if err != nil || pending.Task.Status != domain.TaskStatusPending { + t.Fatalf("older pending task = %+v, error = %v", pending.Task, err) + } +} + +func TestLifecycleRepositoryConcurrentClaimHasSingleWinner(t *testing.T) { + fixture := newLifecycleFixture(t) + secondStore := openSecondLifecycleStore(t, fixture.db) + now := lifecycleTime(0) + task := createLifecycleTask(t, fixture, 1, now.Add(-time.Minute)) + recordReadyHeartbeat( + t, + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + now, + ) + recordReadyHeartbeat( + t, + fixture, + fixture.buyerTwoID, + fixture.deviceTwoID, + now, + ) + requests := []usecase.ClaimNextRepositoryRequest{ + lifecycleClaimRequest( + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + 50, + now, + now.Add(10*time.Minute), + now.Add(-2*time.Minute), + ), + lifecycleClaimRequest( + fixture, + fixture.buyerTwoID, + fixture.deviceTwoID, + 51, + now, + now.Add(10*time.Minute), + now.Add(-2*time.Minute), + ), + } + stores := []*repository.Store{fixture.store, secondStore} + type claimOutcome struct { + result usecase.ClaimNextRepositoryResult + err error + } + start := make(chan struct{}) + outcomes := make(chan claimOutcome, len(requests)) + var workers sync.WaitGroup + for index, request := range requests { + request := request + store := stores[index] + workers.Add(1) + go func() { + defer workers.Done() + <-start + result, err := store.ClaimNext( + context.Background(), + request, + ) + outcomes <- claimOutcome{result: result, err: err} + }() + } + close(start) + workers.Wait() + close(outcomes) + + claimed := 0 + empty := 0 + for outcome := range outcomes { + if outcome.err != nil { + t.Fatalf("concurrent ClaimNext() error = %v", outcome.err) + } + if outcome.result.Task == nil { + empty++ + continue + } + claimed++ + if outcome.result.Task.ID != task.ID { + t.Fatalf("concurrent claimed task = %+v", outcome.result.Task) + } + } + if claimed != 1 || empty != 1 { + t.Fatalf("concurrent outcomes: claimed=%d empty=%d", claimed, empty) + } + assertTaskEventCount(t, fixture.db, task.ID, "TASK_CLAIMED", 1) +} + +func openSecondLifecycleStore( + t *testing.T, + first *sql.DB, +) *repository.Store { + t.Helper() + var sequence int + var name string + var path string + if err := first.QueryRow(`PRAGMA database_list`).Scan( + &sequence, + &name, + &path, + ); err != nil { + t.Fatalf("read lifecycle database path: %v", err) + } + db, err := platformdatabase.Open(context.Background(), path) + if err != nil { + t.Fatalf("open second lifecycle database connection: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + store, err := repository.New(db) + if err != nil { + t.Fatalf("repository.New(second connection): %v", err) + } + return store +} + +func TestLifecycleRepositoryStartIsIdempotentAndCreatesOneExecution( + t *testing.T, +) { + fixture, claimed, tokenHash, claimAt := prepareLifecycleClaim(t, 60) + ctx := context.Background() + startAt := claimAt.Add(time.Minute) + request := lifecycleStartRequest( + fixture, + claimed, + fixture.buyerOneID, + fixture.deviceOneID, + tokenHash, + 61, + startAt, + ) + + started, err := fixture.store.StartTask(ctx, request) + if err != nil || started.Replayed { + t.Fatalf("StartTask() = %+v, error = %v", started, err) + } + if started.Task.Status != domain.TaskStatusRunning || + started.Task.Version != claimed.Version+1 || + started.Task.ClaimExpiresAt == nil || + !started.Task.ClaimExpiresAt.Equal(request.ExpiresAt) || + started.Execution.ID != request.Execution.ID || + started.Execution.AttemptNo != 1 || + started.Execution.LastHeartbeatAt == nil || + !started.Execution.LastHeartbeatAt.Equal(startAt) { + t.Fatalf("started task/execution = %+v / %+v", started.Task, started.Execution) + } + claimReplayRequest := lifecycleClaimRequest( + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + 60, + startAt, + startAt.Add(10*time.Minute), + startAt.Add(-2*time.Minute), + ) + claimReplay, err := fixture.store.ClaimNext(ctx, claimReplayRequest) + if err != nil || !claimReplay.Replayed || + claimReplay.Task == nil || + claimReplay.Task.ID != started.Task.ID || + claimReplay.Task.Status != domain.TaskStatusRunning { + t.Fatalf( + "claim replay after start = %+v, error = %v", + claimReplay, + err, + ) + } + + replayRequest := request + replayRequest.Execution.ID = uuid(699) + replayRequest.Event = lifecycleEvent( + 69, + claimed.ID, + "TASK_STARTED", + startAt.Add(time.Second), + fixture.buyerOneID, + fixture.deviceOneID, + ) + replayed, err := fixture.store.StartTask(ctx, replayRequest) + if err != nil || !replayed.Replayed || + replayed.Execution.ID != started.Execution.ID || + replayed.Task.ID != started.Task.ID { + t.Fatalf("replayed StartTask() = %+v, error = %v", replayed, err) + } + assertExecutionCount(t, fixture.db, claimed.ID, 1) + assertTaskEventCount(t, fixture.db, claimed.ID, "TASK_STARTED", 1) + + conflict := request + conflict.RequestHash = lifecycleHash("different-start") + _, err = fixture.store.StartTask(ctx, conflict) + assertLifecycleError(t, err, usecase.ErrIdempotencyConflict) +} + +func TestLifecycleRepositoryStartRejectsVersionTokenAndExpiredLease( + t *testing.T, +) { + tests := []struct { + name string + change func(*usecase.StartTaskRepositoryRequest) + expectedErr error + }{ + { + name: "version", + change: func(request *usecase.StartTaskRepositoryRequest) { + request.ExpectedVersion++ + }, + expectedErr: usecase.ErrTaskVersionConflict, + }, + { + name: "token", + change: func(request *usecase.StartTaskRepositoryRequest) { + request.ClaimTokenHash = lifecycleHash("wrong-token") + }, + expectedErr: usecase.ErrClaimInvalid, + }, + { + name: "expired at boundary", + change: func(request *usecase.StartTaskRepositoryRequest) { + request.Now = request.ExpiresAt.Add(9 * time.Minute) + request.ExpiresAt = request.Now.Add(90 * time.Second) + }, + expectedErr: usecase.ErrClaimExpired, + }, + } + for index, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture, claimed, tokenHash, claimAt := prepareLifecycleClaim( + t, + 70+index*10, + ) + request := lifecycleStartRequest( + fixture, + claimed, + fixture.buyerOneID, + fixture.deviceOneID, + tokenHash, + 71+index*10, + claimAt.Add(time.Minute), + ) + if test.name == "expired at boundary" { + request.Now = *claimed.ClaimExpiresAt + request.ExpiresAt = request.Now.Add(90 * time.Second) + } else { + test.change(&request) + } + _, err := fixture.store.StartTask( + context.Background(), + request, + ) + assertLifecycleError(t, err, test.expectedErr) + assertExecutionCount(t, fixture.db, claimed.ID, 0) + assertTaskEventCount( + t, + fixture.db, + claimed.ID, + "TASK_STARTED", + 0, + ) + }) + } +} + +func TestLifecycleRepositoryHeartbeatExtendsLeaseWithoutEvent( + t *testing.T, +) { + fixture, claimed, tokenHash, claimAt := prepareLifecycleClaim(t, 100) + ctx := context.Background() + startRequest := lifecycleStartRequest( + fixture, + claimed, + fixture.buyerOneID, + fixture.deviceOneID, + tokenHash, + 101, + claimAt.Add(time.Minute), + ) + started, err := fixture.store.StartTask(ctx, startRequest) + if err != nil { + t.Fatalf("StartTask() error = %v", err) + } + eventCountBefore := taskEventTotal(t, fixture.db, claimed.ID) + heartbeatAt := startRequest.Now.Add(30 * time.Second) + minimumExpiry := heartbeatAt.Add(90 * time.Second) + heartbeat, err := fixture.store.HeartbeatTask( + ctx, + usecase.TaskHeartbeatRepositoryRequest{ + UserID: fixture.buyerOneID, + DeviceID: fixture.deviceOneID, + TaskID: claimed.ID, + ExecutionID: started.Execution.ID, + ClaimGeneration: claimed.ClaimGeneration, + ClaimTokenHash: tokenHash, + Step: "SEARCH", + Now: heartbeatAt, + MinimumExpiry: minimumExpiry, + }, + ) + if err != nil { + t.Fatalf("HeartbeatTask() error = %v", err) + } + if heartbeat.Task.Version != started.Task.Version+1 || + heartbeat.Task.ClaimExpiresAt == nil || + heartbeat.Task.ClaimExpiresAt.Before(minimumExpiry) || + heartbeat.Execution.CurrentStep != "SEARCH" || + heartbeat.Execution.LastHeartbeatAt == nil || + !heartbeat.Execution.LastHeartbeatAt.Equal(heartbeatAt) || + heartbeat.CancelRequested { + t.Fatalf("heartbeat = %+v", heartbeat) + } + if after := taskEventTotal(t, fixture.db, claimed.ID); after != eventCountBefore { + t.Fatalf("heartbeat appended event: before=%d after=%d", eventCountBefore, after) + } + assertDeviceLastSeen(t, fixture.db, fixture.deviceOneID, heartbeatAt) + + expiredAt := *heartbeat.Task.ClaimExpiresAt + _, err = fixture.store.HeartbeatTask( + ctx, + usecase.TaskHeartbeatRepositoryRequest{ + UserID: fixture.buyerOneID, + DeviceID: fixture.deviceOneID, + TaskID: claimed.ID, + ExecutionID: started.Execution.ID, + ClaimGeneration: claimed.ClaimGeneration, + ClaimTokenHash: tokenHash, + Step: "SCAN_RESULTS", + Now: expiredAt, + MinimumExpiry: expiredAt.Add(90 * time.Second), + }, + ) + assertLifecycleError(t, err, usecase.ErrClaimExpired) +} + +func TestLifecycleRepositoryReleaseIsIdempotentAndInvalidatesClaim( + t *testing.T, +) { + fixture, claimed, tokenHash, claimAt := prepareLifecycleClaim(t, 120) + ctx := context.Background() + request := usecase.ReleaseTaskRepositoryRequest{ + UserID: fixture.buyerOneID, + DeviceID: fixture.deviceOneID, + TaskID: claimed.ID, + ClaimGeneration: claimed.ClaimGeneration, + ClaimTokenHash: tokenHash, + ExpectedVersion: claimed.Version, + IdempotencyKey: "release-121", + RequestHash: lifecycleHash("release-121"), + Now: claimAt.Add(time.Minute), + Event: lifecycleEvent( + 121, + claimed.ID, + "TASK_RELEASED", + claimAt.Add(time.Minute), + fixture.buyerOneID, + fixture.deviceOneID, + ), + } + + released, replayed, err := fixture.store.ReleaseTask(ctx, request) + if err != nil || replayed { + t.Fatalf("ReleaseTask() = %+v, %t, error = %v", released, replayed, err) + } + if released.Status != domain.TaskStatusPending || + released.Version != claimed.Version+1 || + released.ClaimedByUserID != nil || + released.ClaimedByDeviceID != nil || + released.ClaimTokenHash != nil || + released.ClaimIssuedAt != nil || + released.ClaimExpiresAt != nil { + t.Fatalf("released task = %+v", released) + } + + replayRequest := request + replayRequest.Event = lifecycleEvent( + 122, + claimed.ID, + "TASK_RELEASED", + request.Now.Add(time.Second), + fixture.buyerOneID, + fixture.deviceOneID, + ) + replayedTask, replayed, err := fixture.store.ReleaseTask(ctx, replayRequest) + if err != nil || !replayed || replayedTask.ID != claimed.ID { + t.Fatalf("release replay = %+v, %t, error = %v", replayedTask, replayed, err) + } + assertTaskEventCount(t, fixture.db, claimed.ID, "TASK_RELEASED", 1) + + claimReplay := lifecycleClaimRequest( + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + 120, + claimAt, + *claimed.ClaimExpiresAt, + claimAt.Add(-2*time.Minute), + ) + claimReplay.ClaimTokenHash = tokenHash + _, err = fixture.store.ClaimNext(ctx, claimReplay) + assertLifecycleError(t, err, usecase.ErrClaimReplayExpired) +} + +func TestLifecycleRepositoryAdminCancelClaimedTaskIsImmediate( + t *testing.T, +) { + fixture, claimed, _, claimAt := prepareLifecycleClaim(t, 130) + adminID := fixture.adminUserID + canceledAt := claimAt.Add(time.Minute) + canceled, err := fixture.store.CancelTask( + context.Background(), + lifecycleCreatorSubject, + claimed.ID, + "admin canceled before start", + canceledAt, + domain.TaskEvent{ + ID: uuid(131), + TaskID: claimed.ID, + ActorUserID: &adminID, + Type: "TASK_CANCELED", + Message: "task canceled", + OccurredAt: canceledAt, + }, + ) + if err != nil { + t.Fatalf("CancelTask(CLAIMED) error = %v", err) + } + if canceled.Status != domain.TaskStatusCanceled || + canceled.ClaimedByUserID != nil || + canceled.ClaimedByDeviceID != nil || + canceled.ClaimTokenHash != nil || + canceled.ClaimExpiresAt != nil { + t.Fatalf("canceled claimed task = %+v", canceled) + } + assertTaskEventCount(t, fixture.db, claimed.ID, "TASK_CANCELED", 1) +} + +func TestLifecycleRepositoryCancelAcknowledgementEndsExecution( + t *testing.T, +) { + fixture, claimed, tokenHash, claimAt := prepareLifecycleClaim(t, 140) + ctx := context.Background() + startRequest := lifecycleStartRequest( + fixture, + claimed, + fixture.buyerOneID, + fixture.deviceOneID, + tokenHash, + 141, + claimAt.Add(time.Minute), + ) + started, err := fixture.store.StartTask(ctx, startRequest) + if err != nil { + t.Fatalf("StartTask() error = %v", err) + } + cancelAt := startRequest.Now.Add(10 * time.Second) + adminID := fixture.adminUserID + cancelRequested, err := fixture.store.CancelTask( + ctx, + lifecycleCreatorSubject, + claimed.ID, + "admin requested stop", + cancelAt, + domain.TaskEvent{ + ID: uuid(142), + TaskID: claimed.ID, + ActorUserID: &adminID, + Type: "TASK_CANCELED", + Message: "task canceled", + OccurredAt: cancelAt, + }, + ) + if err != nil { + t.Fatalf("CancelTask(RUNNING) error = %v", err) + } + if cancelRequested.Status != domain.TaskStatusRunning || + cancelRequested.CancelRequestedAt == nil || + cancelRequested.CancelRequestedByUserID == nil || + *cancelRequested.CancelRequestedByUserID != fixture.adminUserID || + cancelRequested.CanceledAt != nil { + t.Fatalf("cancel request = %+v", cancelRequested) + } + repeated, err := fixture.store.CancelTask( + ctx, + lifecycleCreatorSubject, + claimed.ID, + "must not replace first request", + cancelAt.Add(time.Second), + domain.TaskEvent{ + ID: uuid(143), + TaskID: claimed.ID, + ActorUserID: &adminID, + Type: "TASK_CANCELED", + Message: "task canceled", + OccurredAt: cancelAt.Add(time.Second), + }, + ) + if err != nil || repeated.Version != cancelRequested.Version { + t.Fatalf("repeat CancelTask() = %+v, error = %v", repeated, err) + } + assertTaskEventCount( + t, + fixture.db, + claimed.ID, + "TASK_CANCEL_REQUESTED", + 1, + ) + detail, err := fixture.store.GetTaskDetail( + ctx, + lifecycleCreatorSubject, + claimed.ID, + ) + if err != nil || detail.Execution == nil { + t.Fatalf("GetTaskDetail() = %+v, error = %v", detail, err) + } + + heartbeat, err := fixture.store.HeartbeatTask( + ctx, + usecase.TaskHeartbeatRepositoryRequest{ + UserID: fixture.buyerOneID, + DeviceID: fixture.deviceOneID, + TaskID: claimed.ID, + ExecutionID: started.Execution.ID, + ClaimGeneration: claimed.ClaimGeneration, + ClaimTokenHash: tokenHash, + Step: "STOPPING", + Now: cancelAt.Add(time.Second), + MinimumExpiry: cancelAt.Add(91 * time.Second), + }, + ) + if err != nil || !heartbeat.CancelRequested { + t.Fatalf("cancel heartbeat = %+v, error = %v", heartbeat, err) + } + + ackAt := cancelAt.Add(2 * time.Second) + request := usecase.CancelAcknowledgementRepositoryRequest{ + UserID: fixture.buyerOneID, + DeviceID: fixture.deviceOneID, + TaskID: claimed.ID, + ExecutionID: started.Execution.ID, + ClaimGeneration: claimed.ClaimGeneration, + ClaimTokenHash: tokenHash, + ExpectedVersion: heartbeat.Task.Version, + IdempotencyKey: "cancel-ack-144", + RequestHash: lifecycleHash("cancel-ack-144"), + Now: ackAt, + Event: lifecycleEvent( + 144, + claimed.ID, + "TASK_CANCELED", + ackAt, + fixture.buyerOneID, + fixture.deviceOneID, + ), + } + canceled, replayed, err := fixture.store.AcknowledgeTaskCancellation( + ctx, + request, + ) + if err != nil || replayed { + t.Fatalf( + "AcknowledgeTaskCancellation() = %+v, %t, error = %v", + canceled, + replayed, + err, + ) + } + if canceled.Status != domain.TaskStatusCanceled || + canceled.Version != heartbeat.Task.Version+1 || + canceled.CanceledAt == nil || + !canceled.CanceledAt.Equal(ackAt) || + canceled.ClaimedByDeviceID != nil || + canceled.ClaimTokenHash != nil { + t.Fatalf("canceled task = %+v", canceled) + } + finished, err := fixture.store.GetTaskDetail( + ctx, + lifecycleCreatorSubject, + claimed.ID, + ) + if err != nil || finished.Execution == nil || + finished.Execution.FinishedAt == nil || + !finished.Execution.FinishedAt.Equal(ackAt) { + t.Fatalf("finished detail = %+v, error = %v", finished, err) + } + + replayRequest := request + replayRequest.Event = lifecycleEvent( + 145, + claimed.ID, + "TASK_CANCELED", + ackAt.Add(time.Second), + fixture.buyerOneID, + fixture.deviceOneID, + ) + replayedTask, replayed, err := fixture.store.AcknowledgeTaskCancellation( + ctx, + replayRequest, + ) + if err != nil || !replayed || replayedTask.ID != claimed.ID { + t.Fatalf( + "cancel acknowledgement replay = %+v, %t, error = %v", + replayedTask, + replayed, + err, + ) + } + assertTaskEventCount(t, fixture.db, claimed.ID, "TASK_CANCELED", 1) +} + +func newLifecycleFixture(t *testing.T) *lifecycleFixture { + t.Helper() + db := openDatabase(t) + fixture := &lifecycleFixture{ + db: db, + adminUserID: uuid(999), + buyerOneID: uuid(901), + buyerTwoID: uuid(902), + deviceOneID: uuid(911), + deviceTwoID: uuid(912), + } + createdAt := lifecycleTime(-10) + seedLifecycleUser( + t, + db, + fixture.adminUserID, + "lifecycle-admin", + domain.UserRoleAdmin, + createdAt, + ) + seedLifecycleUser( + t, + db, + fixture.buyerOneID, + "lifecycle-buyer-one", + domain.UserRoleBuyer, + createdAt, + ) + seedLifecycleUser( + t, + db, + fixture.buyerTwoID, + "lifecycle-buyer-two", + domain.UserRoleBuyer, + createdAt, + ) + seedLifecycleDevice( + t, + db, + fixture.deviceOneID, + "lifecycle-device-one", + fixture.buyerOneID, + lifecycleHash("device-one-token"), + createdAt, + ) + seedLifecycleDevice( + t, + db, + fixture.deviceTwoID, + "lifecycle-device-two", + fixture.buyerTwoID, + lifecycleHash("device-two-token"), + createdAt, + ) + store, err := repository.New(db) + if err != nil { + t.Fatalf("repository.New() error = %v", err) + } + fixture.store = store + return fixture +} + +func seedLifecycleUser( + t *testing.T, + db *sql.DB, + id string, + username string, + role domain.UserRole, + createdAt time.Time, +) { + t.Helper() + if _, err := db.ExecContext( + context.Background(), + `INSERT INTO users ( + id, username, password_hash, role, is_active, created_at, updated_at + ) VALUES (?, ?, 'test-only-password-hash', ?, 1, ?, ?)`, + id, + username, + role, + lifecycleTimestamp(createdAt), + lifecycleTimestamp(createdAt), + ); err != nil { + t.Fatalf("seed user %s: %v", username, err) + } +} + +func seedLifecycleDevice( + t *testing.T, + db *sql.DB, + id string, + name string, + userID string, + tokenHash string, + createdAt time.Time, +) { + t.Helper() + if _, err := db.ExecContext( + context.Background(), + `INSERT INTO devices ( + id, name, token_hash, bound_user_id, is_enabled, created_at, updated_at + ) VALUES (?, ?, ?, ?, 1, ?, ?)`, + id, + name, + tokenHash, + userID, + lifecycleTimestamp(createdAt), + lifecycleTimestamp(createdAt), + ); err != nil { + t.Fatalf("seed device %s: %v", name, err) + } +} + +func createLifecycleTask( + t *testing.T, + fixture *lifecycleFixture, + index int, + createdAt time.Time, +) domain.PurchaseTask { + t.Helper() + asset := testAsset(index, createdAt) + if _, _, err := fixture.store.CreateAssetIdempotent( + context.Background(), + asset, + fmt.Sprintf("lifecycle-asset-%d", index), + lifecycleHash(fmt.Sprintf("lifecycle-asset-%d", index)), + ); err != nil { + t.Fatalf("create lifecycle asset %d: %v", index, err) + } + task := testTask( + index, + asset.ID, + fmt.Sprintf("lifecycle-source-%d", index), + createdAt, + ) + created, _, err := fixture.store.CreateTaskIdempotent( + context.Background(), + task, + lifecycleEvent( + 1000+index, + task.ID, + "TASK_CREATED", + createdAt, + fixture.adminUserID, + "", + ), + fmt.Sprintf("lifecycle-task-%d", index), + lifecycleHash(fmt.Sprintf("lifecycle-task-%d", index)), + ) + if err != nil { + t.Fatalf("create lifecycle task %d: %v", index, err) + } + return created +} + +func lifecycleDeviceHeartbeat( + userID string, + deviceID string, + reportedAt time.Time, + accessibilityEnabled bool, + pddInstalled bool, +) usecase.DeviceHeartbeatUpdate { + return usecase.DeviceHeartbeatUpdate{ + UserID: userID, + DeviceID: deviceID, + AppVersion: "0.1.0", + AndroidVersion: "16", + PDDVersion: "8.17.0", + AccessibilityEnabled: accessibilityEnabled, + PDDInstalled: pddInstalled, + ReportedAt: reportedAt, + } +} + +func recordReadyHeartbeat( + t *testing.T, + fixture *lifecycleFixture, + userID string, + deviceID string, + reportedAt time.Time, +) { + t.Helper() + if _, err := fixture.store.RecordDeviceHeartbeat( + context.Background(), + lifecycleDeviceHeartbeat( + userID, + deviceID, + reportedAt, + true, + true, + ), + ); err != nil { + t.Fatalf("record ready heartbeat: %v", err) + } +} + +func lifecycleClaimRequest( + fixture *lifecycleFixture, + userID string, + deviceID string, + index int, + now time.Time, + expiresAt time.Time, + readinessAfter time.Time, +) usecase.ClaimNextRepositoryRequest { + key := fmt.Sprintf("claim-%d", index) + return usecase.ClaimNextRepositoryRequest{ + CreatorSubject: lifecycleCreatorSubject, + UserID: userID, + DeviceID: deviceID, + IdempotencyKey: key, + RequestHash: lifecycleHash(key), + ClaimTokenHash: lifecycleHash("token-" + key), + Now: now, + ExpiresAt: expiresAt, + ReadinessAfter: readinessAfter, + Event: lifecycleEvent( + 2000+index, + "", + "TASK_CLAIMED", + now, + userID, + deviceID, + ), + } +} + +func lifecycleStartRequest( + fixture *lifecycleFixture, + task domain.PurchaseTask, + userID string, + deviceID string, + tokenHash string, + index int, + now time.Time, +) usecase.StartTaskRepositoryRequest { + key := fmt.Sprintf("start-%d", index) + return usecase.StartTaskRepositoryRequest{ + UserID: userID, + DeviceID: deviceID, + TaskID: task.ID, + ClaimGeneration: task.ClaimGeneration, + ClaimTokenHash: tokenHash, + ExpectedVersion: task.Version, + IdempotencyKey: key, + RequestHash: lifecycleHash(key), + Now: now, + ExpiresAt: now.Add(90 * time.Second), + Execution: domain.TaskExecution{ + ID: uuid(3000 + index), + TaskID: task.ID, + ClaimGeneration: task.ClaimGeneration, + UserID: userID, + DeviceID: deviceID, + CurrentStep: "PREFLIGHT", + OrderSubmitted: false, + StartedAt: now, + }, + Event: lifecycleEvent( + 4000+index, + task.ID, + "TASK_STARTED", + now, + userID, + deviceID, + ), + } +} + +func prepareLifecycleClaim( + t *testing.T, + index int, +) (*lifecycleFixture, domain.PurchaseTask, string, time.Time) { + t.Helper() + fixture := newLifecycleFixture(t) + now := lifecycleTime(index) + _ = createLifecycleTask(t, fixture, 1, now.Add(-time.Minute)) + recordReadyHeartbeat( + t, + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + now, + ) + request := lifecycleClaimRequest( + fixture, + fixture.buyerOneID, + fixture.deviceOneID, + index, + now, + now.Add(10*time.Minute), + now.Add(-2*time.Minute), + ) + result, err := fixture.store.ClaimNext(context.Background(), request) + if err != nil || result.Task == nil { + t.Fatalf("prepare ClaimNext() = %+v, error = %v", result, err) + } + return fixture, *result.Task, request.ClaimTokenHash, now +} + +func lifecycleEvent( + index int, + taskID string, + eventType string, + occurredAt time.Time, + userID string, + deviceID string, +) domain.TaskEvent { + event := domain.TaskEvent{ + ID: uuid(5000 + index), + TaskID: taskID, + Type: eventType, + Message: "lifecycle test event", + OccurredAt: occurredAt, + } + if userID != "" { + event.ActorUserID = &userID + } + if deviceID != "" { + event.ActorDeviceID = &deviceID + } + return event +} + +func assertLifecycleError( + t *testing.T, + err error, + expected error, +) { + t.Helper() + if !errors.Is(err, expected) { + t.Fatalf("error = %v, want errors.Is(%v)", err, expected) + } +} + +func assertTaskEventCount( + t *testing.T, + db *sql.DB, + taskID string, + eventType string, + expected int, +) { + t.Helper() + var count int + if err := db.QueryRowContext( + context.Background(), + `SELECT COUNT(*) + FROM task_events + WHERE task_id = ? AND event_type = ?`, + taskID, + eventType, + ).Scan(&count); err != nil { + t.Fatalf("count %s events: %v", eventType, err) + } + if count != expected { + t.Fatalf("%s event count = %d, want %d", eventType, count, expected) + } +} + +func taskEventTotal(t *testing.T, db *sql.DB, taskID string) int { + t.Helper() + var count int + if err := db.QueryRowContext( + context.Background(), + `SELECT COUNT(*) FROM task_events WHERE task_id = ?`, + taskID, + ).Scan(&count); err != nil { + t.Fatalf("count task events: %v", err) + } + return count +} + +func assertExecutionCount( + t *testing.T, + db *sql.DB, + taskID string, + expected int, +) { + t.Helper() + var count int + if err := db.QueryRowContext( + context.Background(), + `SELECT COUNT(*) FROM task_executions WHERE task_id = ?`, + taskID, + ).Scan(&count); err != nil { + t.Fatalf("count task executions: %v", err) + } + if count != expected { + t.Fatalf("execution count = %d, want %d", count, expected) + } +} + +func assertDeviceLastSeen( + t *testing.T, + db *sql.DB, + deviceID string, + expected time.Time, +) { + t.Helper() + var value string + if err := db.QueryRowContext( + context.Background(), + `SELECT last_seen_at FROM devices WHERE id = ?`, + deviceID, + ).Scan(&value); err != nil { + t.Fatalf("query device last_seen_at: %v", err) + } + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + t.Fatalf("parse device last_seen_at %q: %v", value, err) + } + if !parsed.Equal(expected) { + t.Fatalf("device last_seen_at = %s, want %s", parsed, expected) + } +} + +func lifecycleHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func lifecycleTime(offsetMinutes int) time.Time { + return time.Date(2026, 7, 26, 8, 0, 0, 0, time.UTC). + Add(time.Duration(offsetMinutes) * time.Minute) +} + +func lifecycleTimestamp(value time.Time) string { + return value.UTC().Format(time.RFC3339Nano) +} diff --git a/backend-api/internal/repository/sqlite/store_test.go b/backend-api/internal/repository/sqlite/store_test.go index 56e32d5..b9e9155 100644 --- a/backend-api/internal/repository/sqlite/store_test.go +++ b/backend-api/internal/repository/sqlite/store_test.go @@ -190,7 +190,7 @@ func TestStoreAssetAndTaskLifecycleIsTransactionalAndIdempotent( t.Fatalf("detail = %+v", detail) } - canceled, err := store.CancelPendingTask( + canceled, err := store.CancelTask( ctx, "local-admin", task.ID, @@ -199,7 +199,7 @@ func TestStoreAssetAndTaskLifecycleIsTransactionalAndIdempotent( testEvent(3, task.ID, "TASK_CANCELED", now.Add(time.Minute)), ) if err != nil { - t.Fatalf("CancelPendingTask() error = %v", err) + t.Fatalf("CancelTask() error = %v", err) } if canceled.Status != domain.TaskStatusCanceled || canceled.Version != 2 || @@ -207,7 +207,7 @@ func TestStoreAssetAndTaskLifecycleIsTransactionalAndIdempotent( *canceled.CancelReason != "no longer needed" { t.Fatalf("canceled task = %+v", canceled) } - _, err = store.CancelPendingTask( + _, err = store.CancelTask( ctx, "local-admin", task.ID, diff --git a/backend-api/internal/repository/sqlite/task_repository.go b/backend-api/internal/repository/sqlite/task_repository.go index 36f10b8..3b35ece 100644 --- a/backend-api/internal/repository/sqlite/task_repository.go +++ b/backend-api/internal/repository/sqlite/task_repository.go @@ -3,6 +3,7 @@ package sqlite import ( "context" "database/sql" + "errors" "strings" "time" @@ -145,7 +146,10 @@ func (s *Store) ListTasks( query.WriteString(`SELECT id, creator_subject, created_by_user_id, source_ref, title, description, sku, image_asset_id, quantity, max_budget_cents, currency, status, - version, cancel_reason, canceled_at, created_at, updated_at + version, claimed_by_user_id, claimed_by_device_id, claim_generation, + claim_token_hash, claim_issued_at, claim_expires_at, cancel_reason, + cancel_requested_at, cancel_requested_by_user_id, canceled_at, + created_at, updated_at FROM purchase_tasks WHERE creator_subject = ?`) arguments := []any{filter.CreatorSubject} @@ -242,7 +246,9 @@ func (s *Store) GetTaskDetail( } rows, err := tx.QueryContext( ctx, - `SELECT id, task_id, actor_user_id, event_type, message, occurred_at + `SELECT + id, task_id, actor_user_id, actor_device_id, + event_type, message, occurred_at FROM task_events WHERE task_id = ? ORDER BY occurred_at ASC, id ASC`, @@ -256,11 +262,13 @@ func (s *Store) GetTaskDetail( for rows.Next() { var event domain.TaskEvent var actorUserID sql.NullString + var actorDeviceID sql.NullString var occurredAt string if err := rows.Scan( &event.ID, &event.TaskID, &actorUserID, + &actorDeviceID, &event.Type, &event.Message, &occurredAt, @@ -270,6 +278,9 @@ func (s *Store) GetTaskDetail( if actorUserID.Valid { event.ActorUserID = &actorUserID.String } + if actorDeviceID.Valid { + event.ActorDeviceID = &actorDeviceID.String + } event.OccurredAt, err = parseTimestamp(occurredAt) if err != nil { return domain.TaskDetail{}, err @@ -282,10 +293,31 @@ func (s *Store) GetTaskDetail( if err := rows.Close(); err != nil { return domain.TaskDetail{}, repositoryFailure(err) } + execution, err := scanExecution(tx.QueryRowContext( + ctx, + `SELECT + id, task_id, attempt_no, claim_generation, user_id, device_id, + current_step, order_submitted, started_at, last_heartbeat_at, + finished_at + FROM task_executions + WHERE task_id = ? + ORDER BY attempt_no DESC + LIMIT 1`, + taskID, + )) + var executionPointer *domain.TaskExecution + if errors.Is(err, sql.ErrNoRows) { + executionPointer = nil + } else if err != nil { + return domain.TaskDetail{}, repositoryFailure(err) + } else { + executionPointer = &execution + } detail := domain.TaskDetail{ - Task: task, - Asset: asset, - Events: events, + Task: task, + Asset: asset, + Execution: executionPointer, + Events: events, } if err := tx.Commit(); err != nil { return domain.TaskDetail{}, repositoryFailure(err) @@ -293,7 +325,7 @@ func (s *Store) GetTaskDetail( return detail, nil } -func (s *Store) CancelPendingTask( +func (s *Store) CancelTask( ctx context.Context, creatorSubject string, taskID string, @@ -313,25 +345,66 @@ func (s *Store) CancelPendingTask( if !domain.CanCancel(task.Status) { return domain.PurchaseTask{}, usecase.ErrTaskStateConflict } - result, err := tx.ExecContext( - ctx, - `UPDATE purchase_tasks - SET status = 'CANCELED', - version = version + 1, - cancel_reason = NULLIF(?, ''), - canceled_at = ?, - updated_at = ? - WHERE id = ? - AND creator_subject = ? - AND status = 'PENDING' - AND version = ?`, - reason, - formatTimestamp(canceledAt), - formatTimestamp(canceledAt), - taskID, - creatorSubject, - task.Version, - ) + if domain.CanRequestCancel(task.Status) && + task.CancelRequestedAt != nil { + if err := tx.Commit(); err != nil { + return domain.PurchaseTask{}, repositoryFailure(err) + } + return task, nil + } + var result sql.Result + if domain.CanAdminCancelImmediately(task.Status) { + result, err = tx.ExecContext( + ctx, + `UPDATE purchase_tasks + SET status = 'CANCELED', + version = version + 1, + claimed_by_user_id = NULL, + claimed_by_device_id = NULL, + claim_token_hash = NULL, + claim_issued_at = NULL, + claim_expires_at = NULL, + cancel_reason = NULLIF(?, ''), + cancel_requested_at = NULL, + cancel_requested_by_user_id = NULL, + canceled_at = ?, + updated_at = ? + WHERE id = ? + AND creator_subject = ? + AND status IN ('PENDING', 'CLAIMED') + AND version = ?`, + reason, + formatTimestamp(canceledAt), + formatTimestamp(canceledAt), + taskID, + creatorSubject, + task.Version, + ) + } else { + event.Type = "TASK_CANCEL_REQUESTED" + event.Message = "task cancellation requested" + result, err = tx.ExecContext( + ctx, + `UPDATE purchase_tasks + SET version = version + 1, + cancel_reason = NULLIF(?, ''), + cancel_requested_at = ?, + cancel_requested_by_user_id = ?, + updated_at = ? + WHERE id = ? + AND creator_subject = ? + AND status IN ('RUNNING', 'WAITING_CONFIRMATION') + AND cancel_requested_at IS NULL + AND version = ?`, + reason, + formatTimestamp(canceledAt), + nullableString(event.ActorUserID), + formatTimestamp(canceledAt), + taskID, + creatorSubject, + task.Version, + ) + } if err != nil { return domain.PurchaseTask{}, repositoryFailure(err) } @@ -363,11 +436,13 @@ func insertTaskEvent( _, err := tx.ExecContext( ctx, `INSERT INTO task_events ( - id, task_id, actor_user_id, event_type, message, occurred_at - ) VALUES (?, ?, ?, ?, ?, ?)`, + id, task_id, actor_user_id, actor_device_id, + event_type, message, occurred_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, event.ID, event.TaskID, nullableString(event.ActorUserID), + nullableString(event.ActorDeviceID), event.Type, event.Message, formatTimestamp(event.OccurredAt), diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index 9667322..8d06f84 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -160,18 +160,7 @@ func (h *adminHandlers) assetContent(ctx *gin.Context) { return } defer result.Content.Close() - - ctx.Header("Cache-Control", "private, no-store") - ctx.Header("Content-Type", result.Asset.MediaType) - ctx.Header("Content-Length", strconv.FormatInt(result.Asset.SizeBytes, 10)) - ctx.Header("ETag", `"`+result.Asset.SHA256+`"`) - ctx.Header("X-Content-Type-Options", "nosniff") - ctx.Header( - "Content-Disposition", - `inline; filename="`+result.Asset.ID+`.jpg"`, - ) - ctx.Status(http.StatusOK) - _, _ = io.Copy(ctx.Writer, result.Content) + writeAssetContent(ctx, result) } func (h *adminHandlers) createTask(ctx *gin.Context) { @@ -302,13 +291,32 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) { events := make([]gin.H, 0, len(detail.Events)) for _, event := range detail.Events { events = append(events, gin.H{ - "id": event.ID, - "actor_user_id": event.ActorUserID, - "type": event.Type, - "message": event.Message, - "occurred_at": formatTime(event.OccurredAt), + "id": event.ID, + "actor_user_id": event.ActorUserID, + "actor_device_id": event.ActorDeviceID, + "type": event.Type, + "message": event.Message, + "occurred_at": formatTime(event.OccurredAt), }) } + var claim any + if detail.Task.ClaimGeneration > 0 { + claim = gin.H{ + "user_id": detail.Task.ClaimedByUserID, + "device_id": detail.Task.ClaimedByDeviceID, + "generation": detail.Task.ClaimGeneration, + "issued_at": formatOptionalTime(detail.Task.ClaimIssuedAt), + "expires_at": formatOptionalTime(detail.Task.ClaimExpiresAt), + "cancel_requested_at": formatOptionalTime( + detail.Task.CancelRequestedAt, + ), + "cancel_requested_by_user_id": detail.Task.CancelRequestedByUserID, + } + } + var execution any + if detail.Execution != nil { + execution = executionResponse(*detail.Execution) + } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{ "id": detail.Task.ID, @@ -327,8 +335,8 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) { "currency": detail.Task.Currency, }, "derived_requirement": nil, - "claim": nil, - "execution": nil, + "claim": claim, + "execution": execution, "events": events, "assets": []gin.H{ assetResponse(detail.Asset), @@ -441,6 +449,8 @@ func writeUsecaseError(ctx *gin.Context, err error) { } case usecase.ErrorKindNotFound: status = http.StatusNotFound + case usecase.ErrorKindForbidden: + status = http.StatusForbidden case usecase.ErrorKindConflict: status = http.StatusConflict case usecase.ErrorKindUnavailable: @@ -501,15 +511,19 @@ func assetResponse(asset domain.Asset) gin.H { func taskSummaryResponse(task domain.PurchaseTask) gin.H { return gin.H{ - "id": task.ID, - "status": task.Status, - "title": task.Title, - "sku": task.SKU, - "quantity": task.Quantity, - "max_budget": domain.FormatOptionalCNY(task.MaxBudgetCents), - "created_at": formatTime(task.CreatedAt), - "updated_at": formatTime(task.UpdatedAt), - "version": task.Version, + "id": task.ID, + "status": task.Status, + "title": task.Title, + "sku": task.SKU, + "quantity": task.Quantity, + "max_budget": domain.FormatOptionalCNY(task.MaxBudgetCents), + "created_at": formatTime(task.CreatedAt), + "updated_at": formatTime(task.UpdatedAt), + "version": task.Version, + "cancel_requested": task.CancelRequestedAt != nil, + "cancel_requested_at": formatOptionalTime( + task.CancelRequestedAt, + ), } } @@ -522,3 +536,41 @@ func taskListItemResponse(task domain.PurchaseTask) gin.H { func formatTime(value time.Time) string { return value.UTC().Format(time.RFC3339Nano) } + +func formatOptionalTime(value *time.Time) any { + if value == nil { + return nil + } + return formatTime(*value) +} + +func executionResponse(execution domain.TaskExecution) gin.H { + return gin.H{ + "id": execution.ID, + "attempt_no": execution.AttemptNo, + "claim_generation": execution.ClaimGeneration, + "user_id": execution.UserID, + "device_id": execution.DeviceID, + "current_step": execution.CurrentStep, + "order_submitted": execution.OrderSubmitted, + "started_at": formatTime(execution.StartedAt), + "last_heartbeat_at": formatOptionalTime( + execution.LastHeartbeatAt, + ), + "finished_at": formatOptionalTime(execution.FinishedAt), + } +} + +func writeAssetContent(ctx *gin.Context, result usecase.AssetContent) { + ctx.Header("Cache-Control", "private, no-store") + ctx.Header("Content-Type", result.Asset.MediaType) + ctx.Header("Content-Length", strconv.FormatInt(result.Asset.SizeBytes, 10)) + ctx.Header("ETag", `"`+result.Asset.SHA256+`"`) + ctx.Header("X-Content-Type-Options", "nosniff") + ctx.Header( + "Content-Disposition", + `inline; filename="`+result.Asset.ID+`.jpg"`, + ) + ctx.Status(http.StatusOK) + _, _ = io.Copy(ctx.Writer, result.Content) +} diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index d3c8e78..542c9a6 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -360,7 +360,9 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler { Database: db, RegisterPublicRoutes: discardRoutes, RegisterAdminRoutes: registrar, + RegisterDeviceRoutes: discardRoutes, AdminSessions: allowAdminAuthenticator{}, + DeviceAccess: allowAdminAuthenticator{}, LogEvent: discardEvent, }) if err != nil { diff --git a/backend-api/internal/transport/httpapi/device_handlers.go b/backend-api/internal/transport/httpapi/device_handlers.go new file mode 100644 index 0000000..41e5c33 --- /dev/null +++ b/backend-api/internal/transport/httpapi/device_handlers.go @@ -0,0 +1,455 @@ +package httpapi + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/transport/authcommon" + "cmroubao/backend-api/internal/usecase" + + "github.com/gin-gonic/gin" +) + +const claimTokenHeader = "X-Claim-Token" + +type DeviceServices struct { + Lifecycle *usecase.LifecycleService + Assets *usecase.AssetService +} + +func (services DeviceServices) validate() error { + if services.Lifecycle == nil || services.Assets == nil { + return errors.New("device services are required") + } + return nil +} + +type deviceHandlers struct { + services DeviceServices +} + +func NewDeviceRouteRegistrar( + services DeviceServices, +) (RouteRegistrar, error) { + if err := services.validate(); err != nil { + return nil, err + } + handler := &deviceHandlers{services: services} + return func(routes gin.IRoutes) error { + routes.POST( + "/api/v1/devices/heartbeat", + handler.heartbeatDevice, + ) + routes.POST( + "/api/v1/tasks/claim-next", + handler.claimNext, + ) + routes.POST( + "/api/v1/tasks/:id/start", + handler.startTask, + ) + routes.POST( + "/api/v1/tasks/:id/heartbeat", + handler.heartbeatTask, + ) + routes.GET( + "/api/v1/tasks/:id/reference-image", + handler.referenceImage, + ) + routes.POST( + "/api/v1/tasks/:id/release", + handler.releaseTask, + ) + routes.POST( + "/api/v1/tasks/:id/cancel-ack", + handler.acknowledgeCancellation, + ) + return nil + }, nil +} + +func (handler *deviceHandlers) referenceImage(ctx *gin.Context) { + principal, ok := devicePrincipal(ctx) + if !ok { + return + } + generation, err := strconv.ParseInt( + strings.TrimSpace(ctx.Query("claim_generation")), + 10, + 64, + ) + if err != nil || generation < 1 { + writePublicError( + ctx, + http.StatusUnprocessableEntity, + "TASK_REFERENCE_IMAGE_INVALID", + "task reference image request is invalid", + false, + fieldDetails("claim_generation", "must be a positive integer"), + ) + return + } + task, err := handler.services.Lifecycle.AuthorizeReferenceImage( + ctx.Request.Context(), + usecase.ReferenceImageCommand{ + UserID: principal.UserID, + DeviceID: principal.DeviceID, + TaskID: ctx.Param("id"), + ClaimGeneration: generation, + ClaimToken: ctx.GetHeader(claimTokenHeader), + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + result, err := handler.services.Assets.OpenTaskReference( + ctx.Request.Context(), + localAdminSubject, + task.ImageAssetID, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + defer result.Content.Close() + writeAssetContent(ctx, result) +} + +func (handler *deviceHandlers) heartbeatDevice(ctx *gin.Context) { + principal, ok := devicePrincipal(ctx) + if !ok { + return + } + var request struct { + DeviceID string `json:"device_id"` + AppVersion string `json:"app_version"` + AndroidVersion string `json:"android_version"` + PDDVersion string `json:"pdd_version"` + Readiness struct { + AccessibilityEnabled bool `json:"accessibility_enabled"` + PDDInstalled bool `json:"pdd_installed"` + ActiveTaskID *string `json:"active_task_id"` + } `json:"readiness"` + } + if !decodeDeviceJSON(ctx, &request) || + !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { + return + } + result, err := handler.services.Lifecycle.HeartbeatDevice( + ctx.Request.Context(), + usecase.DeviceHeartbeatCommand{ + UserID: principal.UserID, + DeviceID: principal.DeviceID, + AppVersion: request.AppVersion, + AndroidVersion: request.AndroidVersion, + PDDVersion: request.PDDVersion, + AccessibilityEnabled: request.Readiness.AccessibilityEnabled, + PDDInstalled: request.Readiness.PDDInstalled, + ClientActiveTaskID: request.Readiness.ActiveTaskID, + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "device_id": result.Device.ID, + "readiness": gin.H{ + "reported_at": formatOptionalTime(result.Device.ReadinessAt), + "accessibility_enabled": result.Device.AccessibilityEnabled, + "pdd_installed": result.Device.PDDInstalled, + }, + "active_task_id": result.ActiveTaskID, + "client_state_matches": result.ClientStateMatches, + "server_time": formatTime(result.ServerTime), + }) +} + +func (handler *deviceHandlers) claimNext(ctx *gin.Context) { + principal, ok := devicePrincipal(ctx) + if !ok { + return + } + var request struct { + DeviceID string `json:"device_id"` + } + if !decodeDeviceJSON(ctx, &request) || + !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { + return + } + result, err := handler.services.Lifecycle.ClaimNext( + ctx.Request.Context(), + usecase.ClaimNextCommand{ + UserID: principal.UserID, + DeviceID: principal.DeviceID, + IdempotencyKey: ctx.GetHeader("Idempotency-Key"), + ClaimToken: ctx.GetHeader(claimTokenHeader), + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + if result.Task == nil { + ctx.Status(http.StatusNoContent) + return + } + ctx.JSON(http.StatusOK, gin.H{ + "task": deviceTaskResponse(*result.Task), + "replayed": result.Replayed, + "server_time": formatTime(result.ServerTime), + }) +} + +func (handler *deviceHandlers) startTask(ctx *gin.Context) { + principal, ok := devicePrincipal(ctx) + if !ok { + return + } + var request lifecycleTransitionRequest + if !decodeDeviceJSON(ctx, &request) || + !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { + return + } + result, err := handler.services.Lifecycle.StartTask( + ctx.Request.Context(), + usecase.StartTaskCommand{ + UserID: principal.UserID, + DeviceID: principal.DeviceID, + TaskID: ctx.Param("id"), + ClaimGeneration: request.ClaimGeneration, + ClaimToken: ctx.GetHeader(claimTokenHeader), + ExpectedVersion: request.ExpectedVersion, + IdempotencyKey: ctx.GetHeader("Idempotency-Key"), + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "task": deviceTaskResponse(result.Task), + "execution": executionResponse(result.Execution), + "replayed": result.Replayed, + "server_time": formatTime(result.ServerTime), + }) +} + +func (handler *deviceHandlers) heartbeatTask(ctx *gin.Context) { + principal, ok := devicePrincipal(ctx) + if !ok { + return + } + var request struct { + DeviceID string `json:"device_id"` + ExecutionID string `json:"execution_id"` + ClaimGeneration int64 `json:"claim_generation"` + Step string `json:"step"` + } + if !decodeDeviceJSON(ctx, &request) || + !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { + return + } + result, err := handler.services.Lifecycle.HeartbeatTask( + ctx.Request.Context(), + usecase.TaskHeartbeatCommand{ + UserID: principal.UserID, + DeviceID: principal.DeviceID, + TaskID: ctx.Param("id"), + ExecutionID: request.ExecutionID, + ClaimGeneration: request.ClaimGeneration, + ClaimToken: ctx.GetHeader(claimTokenHeader), + Step: request.Step, + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "task": deviceTaskResponse(result.Task), + "execution": executionResponse(result.Execution), + "cancel_requested": result.CancelRequested, + "server_time": formatTime(result.ServerTime), + }) +} + +func (handler *deviceHandlers) releaseTask(ctx *gin.Context) { + principal, ok := devicePrincipal(ctx) + if !ok { + return + } + var request lifecycleTransitionRequest + if !decodeDeviceJSON(ctx, &request) || + !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { + return + } + result, err := handler.services.Lifecycle.ReleaseTask( + ctx.Request.Context(), + usecase.ReleaseTaskCommand{ + UserID: principal.UserID, + DeviceID: principal.DeviceID, + TaskID: ctx.Param("id"), + ClaimGeneration: request.ClaimGeneration, + ClaimToken: ctx.GetHeader(claimTokenHeader), + ExpectedVersion: request.ExpectedVersion, + IdempotencyKey: ctx.GetHeader("Idempotency-Key"), + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "task": deviceTaskResponse(result.Task), + "replayed": result.Replayed, + "server_time": formatTime(result.ServerTime), + }) +} + +func (handler *deviceHandlers) acknowledgeCancellation( + ctx *gin.Context, +) { + principal, ok := devicePrincipal(ctx) + if !ok { + return + } + var request struct { + lifecycleTransitionRequest + ExecutionID string `json:"execution_id"` + } + if !decodeDeviceJSON(ctx, &request) || + !deviceIDMatches( + ctx, + request.DeviceID, + principal.DeviceID, + ) { + return + } + result, err := handler.services.Lifecycle.AcknowledgeCancellation( + ctx.Request.Context(), + usecase.AcknowledgeCancellationCommand{ + UserID: principal.UserID, + DeviceID: principal.DeviceID, + TaskID: ctx.Param("id"), + ExecutionID: request.ExecutionID, + ClaimGeneration: request.ClaimGeneration, + ClaimToken: ctx.GetHeader(claimTokenHeader), + ExpectedVersion: request.ExpectedVersion, + IdempotencyKey: ctx.GetHeader("Idempotency-Key"), + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "task": deviceTaskResponse(result.Task), + "replayed": result.Replayed, + "server_time": formatTime(result.ServerTime), + }) +} + +type lifecycleTransitionRequest struct { + DeviceID string `json:"device_id"` + ClaimGeneration int64 `json:"claim_generation"` + ExpectedVersion int64 `json:"expected_version"` +} + +func devicePrincipal( + ctx *gin.Context, +) (domain.AuthPrincipal, bool) { + principal, ok := authcommon.Principal(ctx.Request.Context()) + if ok && + principal.Role == domain.UserRoleBuyer && + principal.UserID != "" && + principal.DeviceID != "" { + return principal, true + } + writePublicError( + ctx, + http.StatusUnauthorized, + "DEVICE_ACCESS_REQUIRED", + "device access token required", + false, + gin.H{}, + ) + return domain.AuthPrincipal{}, false +} + +func decodeDeviceJSON(ctx *gin.Context, target any) bool { + if !hasMediaType(ctx, "application/json") { + writePublicError( + ctx, + http.StatusUnsupportedMediaType, + "UNSUPPORTED_MEDIA_TYPE", + "application/json is required", + false, + gin.H{}, + ) + return false + } + if err := decodeJSON(ctx, target); err != nil { + writePublicError( + ctx, + http.StatusBadRequest, + "INVALID_JSON", + "request body must be valid JSON", + false, + gin.H{}, + ) + return false + } + return true +} + +func deviceIDMatches( + ctx *gin.Context, + presented string, + authoritative string, +) bool { + if presented == "" || presented == authoritative { + return true + } + writePublicError( + ctx, + http.StatusForbidden, + "DEVICE_ID_MISMATCH", + "request device does not match access token", + false, + gin.H{}, + ) + return false +} + +func deviceTaskResponse(task domain.PurchaseTask) gin.H { + referenceImageURL := "/api/v1/tasks/" + task.ID + + "/reference-image?claim_generation=" + + strconv.FormatInt(task.ClaimGeneration, 10) + return gin.H{ + "id": task.ID, + "status": task.Status, + "version": task.Version, + "claim_generation": task.ClaimGeneration, + "claim_issued_at": formatOptionalTime(task.ClaimIssuedAt), + "claim_expires_at": formatOptionalTime(task.ClaimExpiresAt), + "title": task.Title, + "description": task.Description, + "sku": task.SKU, + "image_asset_id": task.ImageAssetID, + "reference_image_url": referenceImageURL, + "quantity": task.Quantity, + "max_budget": domain.FormatOptionalCNY(task.MaxBudgetCents), + "currency": task.Currency, + } +} diff --git a/backend-api/internal/transport/httpapi/device_handlers_test.go b/backend-api/internal/transport/httpapi/device_handlers_test.go new file mode 100644 index 0000000..6c4bef8 --- /dev/null +++ b/backend-api/internal/transport/httpapi/device_handlers_test.go @@ -0,0 +1,1038 @@ +package httpapi + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "image" + "image/color" + "image/jpeg" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/platform/assetstore" + "cmroubao/backend-api/internal/platform/database" + "cmroubao/backend-api/internal/platform/migration" + repository "cmroubao/backend-api/internal/repository/sqlite" + "cmroubao/backend-api/internal/usecase" + + "github.com/gin-gonic/gin" +) + +const ( + deviceTestAdminID = "00000000-0000-4000-8000-000000000201" + deviceTestBuyerID = "00000000-0000-4000-8000-000000000202" + deviceTestDeviceID = "00000000-0000-4000-8000-000000000203" +) + +func TestDeviceRoutesRequireBuyerBearerAndSeparateRoles(t *testing.T) { + fixture := newDeviceHTTPFixture(t) + body := `{ + "app_version":"0.1.0", + "android_version":"16", + "pdd_version":"8.17.0", + "readiness":{ + "accessibility_enabled":true, + "pdd_installed":true, + "active_task_id":null + } + }` + + withoutBearer := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/devices/heartbeat", + contentType: "application/json", + body: strings.NewReader(body), + }) + if withoutBearer.Code != http.StatusUnauthorized { + t.Fatalf( + "request without bearer status/body = %d / %s", + withoutBearer.Code, + withoutBearer.Body, + ) + } + assertErrorCode(t, withoutBearer, "DEVICE_ACCESS_REQUIRED") + + adminBearer := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/devices/heartbeat", + contentType: "application/json", + body: strings.NewReader(body), + bearerToken: testCSRFOpaqueToken, + }) + if adminBearer.Code != http.StatusUnauthorized { + t.Fatalf( + "admin bearer status/body = %d / %s", + adminBearer.Code, + adminBearer.Body, + ) + } + assertErrorCode(t, adminBearer, "DEVICE_ACCESS_REQUIRED") + + adminCookieRequest := httptest.NewRequest( + http.MethodPost, + "/api/v1/devices/heartbeat", + strings.NewReader(body), + ) + adminCookieRequest.Header.Set("Content-Type", "application/json") + adminCookieRequest.AddCookie(&http.Cookie{ + Name: "cmroubao_admin_session", + Value: testOpaqueToken, + }) + adminCookieResponse := httptest.NewRecorder() + fixture.router.ServeHTTP(adminCookieResponse, adminCookieRequest) + if adminCookieResponse.Code != http.StatusUnauthorized { + t.Fatalf( + "admin cookie on device route status/body = %d / %s", + adminCookieResponse.Code, + adminCookieResponse.Body, + ) + } + assertErrorCode( + t, + adminCookieResponse, + "DEVICE_ACCESS_REQUIRED", + ) + + buyerOnAdmin := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodGet, + target: "/api/v1/admin-probe", + bearerToken: testOpaqueToken, + }) + if buyerOnAdmin.Code != http.StatusUnauthorized { + t.Fatalf( + "buyer bearer on admin route status/body = %d / %s", + buyerOnAdmin.Code, + buyerOnAdmin.Body, + ) + } + assertErrorCode(t, buyerOnAdmin, "ADMIN_SESSION_REQUIRED") +} + +func TestDeviceHeartbeatUsesPrincipalDeviceAndRejectsBodyMismatch( + t *testing.T, +) { + fixture := newDeviceHTTPFixture(t) + + mismatch := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/devices/heartbeat", + contentType: "application/json", + body: strings.NewReader(`{ + "device_id":"00000000-0000-4000-8000-000000000299", + "app_version":"0.1.0", + "android_version":"16", + "pdd_version":"8.17.0", + "readiness":{ + "accessibility_enabled":true, + "pdd_installed":true + } + }`), + bearerToken: testOpaqueToken, + }) + if mismatch.Code != http.StatusForbidden { + t.Fatalf( + "device mismatch status/body = %d / %s", + mismatch.Code, + mismatch.Body, + ) + } + assertErrorCode(t, mismatch, "DEVICE_ID_MISMATCH") + + heartbeat := fixture.readyHeartbeat(t) + if heartbeat.Code != http.StatusOK { + t.Fatalf( + "heartbeat status/body = %d / %s", + heartbeat.Code, + heartbeat.Body, + ) + } + var response struct { + DeviceID string `json:"device_id"` + Readiness struct { + AccessibilityEnabled bool `json:"accessibility_enabled"` + PDDInstalled bool `json:"pdd_installed"` + } `json:"readiness"` + ClientStateMatches bool `json:"client_state_matches"` + } + decodeResponse(t, heartbeat, &response) + if response.DeviceID != deviceTestDeviceID || + !response.Readiness.AccessibilityEnabled || + !response.Readiness.PDDInstalled || + !response.ClientStateMatches { + t.Fatalf("heartbeat response = %+v", response) + } + if heartbeat.Header().Get("Cache-Control") != "no-store" { + t.Fatalf( + "heartbeat Cache-Control = %q", + heartbeat.Header().Get("Cache-Control"), + ) + } +} + +func TestDeviceRoutesRejectUnsupportedAndUnknownJSON(t *testing.T) { + fixture := newDeviceHTTPFixture(t) + body := `{ + "app_version":"0.1.0", + "android_version":"16", + "pdd_version":"8.17.0", + "readiness":{ + "accessibility_enabled":true, + "pdd_installed":true + } + }` + + unsupported := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/devices/heartbeat", + body: strings.NewReader(body), + bearerToken: testOpaqueToken, + }) + if unsupported.Code != http.StatusUnsupportedMediaType { + t.Fatalf( + "unsupported media status/body = %d / %s", + unsupported.Code, + unsupported.Body, + ) + } + assertErrorCode(t, unsupported, "UNSUPPORTED_MEDIA_TYPE") + + unknownField := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/devices/heartbeat", + contentType: "application/json", + body: strings.NewReader(`{ + "app_version":"0.1.0", + "android_version":"16", + "pdd_version":"8.17.0", + "readiness":{ + "accessibility_enabled":true, + "pdd_installed":true + }, + "unexpected":true + }`), + bearerToken: testOpaqueToken, + }) + if unknownField.Code != http.StatusBadRequest { + t.Fatalf( + "unknown JSON status/body = %d / %s", + unknownField.Code, + unknownField.Body, + ) + } + assertErrorCode(t, unknownField, "INVALID_JSON") +} + +func TestDeviceClaimReturnsTaskOrNoContentWithoutClaimSecret( + t *testing.T, +) { + fixture := newDeviceHTTPFixture(t) + requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK) + + noTask := fixture.claimNext(t, "claim-empty", testOpaqueToken) + requireDeviceStatus(t, noTask, http.StatusNoContent) + assertNoClaimSecret(t, noTask, testOpaqueToken) + + taskID := fixture.createPendingTask(t) + claimed := fixture.claimNext(t, "claim-task", testOpaqueToken) + requireDeviceStatus(t, claimed, http.StatusOK) + var response deviceLifecycleResponse + decodeResponse(t, claimed, &response) + if response.Task.ID != taskID || + response.Task.Status != string(domain.TaskStatusClaimed) || + response.Task.Version < 2 || + response.Task.ClaimGeneration != 1 || + response.Task.ReferenceImageURL == "" || + response.Replayed { + t.Fatalf("claim response = %+v", response) + } + assertNoClaimSecret(t, claimed, testOpaqueToken) + + wrongToken := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodGet, + target: response.Task.ReferenceImageURL, + bearerToken: testOpaqueToken, + claimToken: testCSRFOpaqueToken, + }) + requireDeviceStatus(t, wrongToken, http.StatusForbidden) + assertErrorCode(t, wrongToken, "TASK_CLAIM_INVALID") + + imageResponse := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodGet, + target: response.Task.ReferenceImageURL, + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + }) + requireDeviceStatus(t, imageResponse, http.StatusOK) + if imageResponse.Header().Get("Content-Type") != "image/jpeg" || + imageResponse.Header().Get("Cache-Control") != "private, no-store" || + imageResponse.Body.Len() == 0 { + t.Fatalf( + "reference image headers/body = %#v / %d", + imageResponse.Header(), + imageResponse.Body.Len(), + ) + } + assertNoClaimSecret(t, imageResponse, testOpaqueToken) +} + +func TestDeviceConcurrentHTTPClaimKeepsOneActiveTaskPerDevice( + t *testing.T, +) { + fixture := newDeviceHTTPFixture(t) + requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK) + fixture.createPendingTask(t) + fixture.createPendingTask(t) + server := httptest.NewServer(fixture.router) + t.Cleanup(server.Close) + + type outcome struct { + status int + body string + err error + } + outcomes := make(chan outcome, 2) + start := make(chan struct{}) + var workers sync.WaitGroup + for index, token := range []string{ + testOpaqueToken, + testCSRFOpaqueToken, + } { + index := index + token := token + workers.Add(1) + go func() { + defer workers.Done() + <-start + request, err := http.NewRequest( + http.MethodPost, + server.URL+"/api/v1/tasks/claim-next", + strings.NewReader(`{}`), + ) + if err != nil { + outcomes <- outcome{err: err} + return + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+testOpaqueToken) + request.Header.Set(claimTokenHeader, token) + request.Header.Set( + "Idempotency-Key", + fmt.Sprintf("concurrent-http-claim-%d", index), + ) + response, err := server.Client().Do(request) + if err != nil { + outcomes <- outcome{err: err} + return + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + outcomes <- outcome{ + status: response.StatusCode, + body: string(body), + err: err, + } + }() + } + close(start) + workers.Wait() + close(outcomes) + + statuses := map[int]int{} + for result := range outcomes { + if result.err != nil { + t.Fatalf("concurrent HTTP claim error = %v", result.err) + } + statuses[result.status]++ + if strings.Contains( + strings.ToLower(result.body), + "claim_token", + ) { + t.Fatalf("concurrent response leaked claim field: %s", result.body) + } + } + if statuses[http.StatusOK] != 1 || + statuses[http.StatusConflict] != 1 { + t.Fatalf("concurrent HTTP statuses = %#v", statuses) + } + var active int + if err := fixture.db.QueryRow( + `SELECT COUNT(*) + FROM purchase_tasks + WHERE claimed_by_device_id = ? + AND status = 'CLAIMED'`, + deviceTestDeviceID, + ).Scan(&active); err != nil { + t.Fatalf("count active HTTP claims: %v", err) + } + if active != 1 { + t.Fatalf("active HTTP claims = %d", active) + } +} + +func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) { + fixture := newDeviceHTTPFixture(t) + requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK) + taskID := fixture.createPendingTask(t) + claim := fixture.claimNext(t, "claim-for-start", testOpaqueToken) + requireDeviceStatus(t, claim, http.StatusOK) + var claimed deviceLifecycleResponse + decodeResponse(t, claim, &claimed) + + startBody := fmt.Sprintf( + `{"claim_generation":%d,"expected_version":%d}`, + claimed.Task.ClaimGeneration, + claimed.Task.Version, + ) + wrongToken := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/start", + contentType: "application/json", + body: strings.NewReader(startBody), + bearerToken: testOpaqueToken, + claimToken: testCSRFOpaqueToken, + idempotencyKey: "start-wrong-token", + }) + if wrongToken.Code != http.StatusForbidden { + t.Fatalf( + "wrong claim token status/body = %d / %s", + wrongToken.Code, + wrongToken.Body, + ) + } + assertErrorCode(t, wrongToken, "TASK_CLAIM_INVALID") + + startedResponse := performDeviceRequest( + t, + fixture.router, + deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/start", + contentType: "application/json", + body: strings.NewReader(startBody), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "start-correct-token", + }, + ) + requireDeviceStatus(t, startedResponse, http.StatusOK) + var started deviceLifecycleResponse + decodeResponse(t, startedResponse, &started) + if started.Task.Status != string(domain.TaskStatusRunning) || + started.Execution.ID == "" || + started.Execution.CurrentStep != "PREFLIGHT" || + started.Execution.OrderSubmitted { + t.Fatalf("start response = %+v", started) + } + assertNoClaimSecret(t, startedResponse, testOpaqueToken) + + heartbeatBody := fmt.Sprintf( + `{ + "execution_id":%q, + "claim_generation":%d, + "step":"SEARCH_RESULTS" + }`, + started.Execution.ID, + started.Task.ClaimGeneration, + ) + taskHeartbeat := performDeviceRequest( + t, + fixture.router, + deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/heartbeat", + contentType: "application/json", + body: strings.NewReader(heartbeatBody), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + }, + ) + requireDeviceStatus(t, taskHeartbeat, http.StatusOK) + var heartbeat deviceLifecycleResponse + decodeResponse(t, taskHeartbeat, &heartbeat) + if heartbeat.Task.Status != string(domain.TaskStatusRunning) || + heartbeat.Execution.ID != started.Execution.ID || + heartbeat.Execution.CurrentStep != "SEARCH_RESULTS" || + heartbeat.Execution.OrderSubmitted || + heartbeat.CancelRequested { + t.Fatalf("task heartbeat response = %+v", heartbeat) + } + assertNoClaimSecret(t, taskHeartbeat, testOpaqueToken) +} + +func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) { + fixture := newDeviceHTTPFixture(t) + requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK) + taskID := fixture.createPendingTask(t) + claim := fixture.claimNext(t, "claim-for-release", testOpaqueToken) + requireDeviceStatus(t, claim, http.StatusOK) + var claimed deviceLifecycleResponse + decodeResponse(t, claim, &claimed) + + releaseBody := fmt.Sprintf( + `{"claim_generation":%d,"expected_version":%d}`, + claimed.Task.ClaimGeneration, + claimed.Task.Version, + ) + releasedResponse := performDeviceRequest( + t, + fixture.router, + deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/release", + contentType: "application/json", + body: strings.NewReader(releaseBody), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "release-claim", + }, + ) + requireDeviceStatus(t, releasedResponse, http.StatusOK) + var released deviceLifecycleResponse + decodeResponse(t, releasedResponse, &released) + if released.Task.ID != taskID || + released.Task.Status != string(domain.TaskStatusPending) || + released.Task.Version != claimed.Task.Version+1 || + released.Task.ClaimGeneration != claimed.Task.ClaimGeneration || + released.Replayed { + t.Fatalf("release response = %+v", released) + } + assertNoClaimSecret(t, releasedResponse, testOpaqueToken) +} + +func TestDeviceCancelAcknowledgementFollowsAdminStopRequest( + t *testing.T, +) { + fixture := newDeviceHTTPFixture(t) + requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK) + taskID := fixture.createPendingTask(t) + claim := fixture.claimNext(t, "claim-for-cancel", testOpaqueToken) + requireDeviceStatus(t, claim, http.StatusOK) + var claimed deviceLifecycleResponse + decodeResponse(t, claim, &claimed) + + startBody := fmt.Sprintf( + `{"claim_generation":%d,"expected_version":%d}`, + claimed.Task.ClaimGeneration, + claimed.Task.Version, + ) + startResponse := performDeviceRequest( + t, + fixture.router, + deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/start", + contentType: "application/json", + body: strings.NewReader(startBody), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "start-for-cancel", + }, + ) + requireDeviceStatus(t, startResponse, http.StatusOK) + var started deviceLifecycleResponse + decodeResponse(t, startResponse, &started) + + cancelRequested, err := fixture.tasks.Cancel( + context.Background(), + usecase.CancelTaskCommand{ + CreatorSubject: "local-admin", + ActorUserID: deviceTestAdminID, + TaskID: taskID, + Reason: "HTTP cancel acknowledgement test", + }, + ) + if err != nil || + cancelRequested.Status != domain.TaskStatusRunning || + cancelRequested.CancelRequestedAt == nil { + t.Fatalf( + "admin cancel request = %+v, error = %v", + cancelRequested, + err, + ) + } + + heartbeatBody := fmt.Sprintf( + `{ + "execution_id":%q, + "claim_generation":%d, + "step":"STOPPING" + }`, + started.Execution.ID, + started.Task.ClaimGeneration, + ) + heartbeatResponse := performDeviceRequest( + t, + fixture.router, + deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/heartbeat", + contentType: "application/json", + body: strings.NewReader(heartbeatBody), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + }, + ) + requireDeviceStatus(t, heartbeatResponse, http.StatusOK) + var heartbeat deviceLifecycleResponse + decodeResponse(t, heartbeatResponse, &heartbeat) + if !heartbeat.CancelRequested { + t.Fatalf("cancel heartbeat response = %+v", heartbeat) + } + + ackBody := fmt.Sprintf( + `{ + "execution_id":%q, + "claim_generation":%d, + "expected_version":%d + }`, + started.Execution.ID, + heartbeat.Task.ClaimGeneration, + heartbeat.Task.Version, + ) + acknowledged := performDeviceRequest( + t, + fixture.router, + deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/cancel-ack", + contentType: "application/json", + body: strings.NewReader(ackBody), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "cancel-ack-http", + }, + ) + requireDeviceStatus(t, acknowledged, http.StatusOK) + var canceled deviceLifecycleResponse + decodeResponse(t, acknowledged, &canceled) + if canceled.Task.Status != string(domain.TaskStatusCanceled) || + canceled.Replayed { + t.Fatalf("cancel acknowledgement = %+v", canceled) + } + assertNoClaimSecret(t, acknowledged, testOpaqueToken) + + replayed := performDeviceRequest( + t, + fixture.router, + deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/cancel-ack", + contentType: "application/json", + body: strings.NewReader(ackBody), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "cancel-ack-http", + }, + ) + requireDeviceStatus(t, replayed, http.StatusOK) + var replayedBody deviceLifecycleResponse + decodeResponse(t, replayed, &replayedBody) + if !replayedBody.Replayed || + replayedBody.Task.Status != string(domain.TaskStatusCanceled) { + t.Fatalf("cancel acknowledgement replay = %+v", replayedBody) + } +} + +type deviceHTTPFixture struct { + db *sql.DB + store *repository.Store + assets *usecase.AssetService + tasks *usecase.TaskService + router http.Handler + + taskSequence int +} + +func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture { + t.Helper() + ctx := context.Background() + db, err := database.Open( + ctx, + filepath.Join(t.TempDir(), "device-handlers.db"), + ) + if err != nil { + t.Fatalf("database.Open() error = %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + runner, err := migration.New(db) + if err != nil { + t.Fatalf("migration.New() error = %v", err) + } + if _, err := runner.Up(ctx); err != nil { + t.Fatalf("migration.Up() error = %v", err) + } + seedDeviceHTTPIdentity(t, db) + store, err := repository.New(db) + if err != nil { + t.Fatalf("repository.New() error = %v", err) + } + clock := usecase.SystemClock{} + ids := usecase.UUIDGenerator{} + tasks, err := usecase.NewTaskService(store, clock, ids) + if err != nil { + t.Fatalf("usecase.NewTaskService() error = %v", err) + } + files, err := assetstore.New(filepath.Join(t.TempDir(), "assets")) + if err != nil { + t.Fatalf("assetstore.New() error = %v", err) + } + assets, err := usecase.NewAssetService(store, files, clock, ids) + if err != nil { + t.Fatalf("usecase.NewAssetService() error = %v", err) + } + lifecycle, err := usecase.NewLifecycleService( + store, + clock, + ids, + 10*time.Minute, + 10*time.Minute, + 2*time.Minute, + ) + if err != nil { + t.Fatalf("usecase.NewLifecycleService() error = %v", err) + } + deviceRoutes, err := NewDeviceRouteRegistrar( + DeviceServices{ + Lifecycle: lifecycle, + Assets: assets, + }, + ) + if err != nil { + t.Fatalf("NewDeviceRouteRegistrar() error = %v", err) + } + authenticator := deviceHTTPAuthenticator{} + router, err := NewRouter(RouterDependencies{ + Database: db, + RegisterPublicRoutes: discardRoutes, + RegisterAdminRoutes: registerDeviceTestAdminProbe, + RegisterDeviceRoutes: deviceRoutes, + AdminSessions: authenticator, + DeviceAccess: authenticator, + LogEvent: discardEvent, + }) + if err != nil { + t.Fatalf("NewRouter() error = %v", err) + } + return &deviceHTTPFixture{ + db: db, + store: store, + assets: assets, + tasks: tasks, + router: router, + } +} + +func seedDeviceHTTPIdentity(t *testing.T, db *sql.DB) { + t.Helper() + now := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339Nano) + for _, user := range []struct { + id string + username string + role domain.UserRole + }{ + {deviceTestAdminID, "device-http-admin", domain.UserRoleAdmin}, + {deviceTestBuyerID, "device-http-buyer", domain.UserRoleBuyer}, + } { + if _, err := db.ExecContext( + context.Background(), + `INSERT INTO users ( + id, username, password_hash, role, is_active, + created_at, updated_at + ) VALUES (?, ?, 'test-only-password-hash', ?, 1, ?, ?)`, + user.id, + user.username, + user.role, + now, + now, + ); err != nil { + t.Fatalf("seed user %s: %v", user.username, err) + } + } + if _, err := db.ExecContext( + context.Background(), + `INSERT INTO devices ( + id, name, token_hash, bound_user_id, is_enabled, + created_at, updated_at + ) VALUES (?, 'device-http-test', ?, ?, 1, ?, ?)`, + deviceTestDeviceID, + deviceTestHash("device-registration-token"), + deviceTestBuyerID, + now, + now, + ); err != nil { + t.Fatalf("seed device: %v", err) + } +} + +func (fixture *deviceHTTPFixture) readyHeartbeat( + t *testing.T, +) *httptest.ResponseRecorder { + t.Helper() + return performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/devices/heartbeat", + contentType: "application/json", + body: strings.NewReader(`{ + "app_version":"0.1.0", + "android_version":"16", + "pdd_version":"8.17.0", + "readiness":{ + "accessibility_enabled":true, + "pdd_installed":true, + "active_task_id":null + } + }`), + bearerToken: testOpaqueToken, + }) +} + +func (fixture *deviceHTTPFixture) claimNext( + t *testing.T, + idempotencyKey string, + claimToken string, +) *httptest.ResponseRecorder { + t.Helper() + return performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/claim-next", + contentType: "application/json", + body: strings.NewReader(`{}`), + bearerToken: testOpaqueToken, + claimToken: claimToken, + idempotencyKey: idempotencyKey, + }) +} + +func (fixture *deviceHTTPFixture) createPendingTask(t *testing.T) string { + t.Helper() + fixture.taskSequence++ + index := fixture.taskSequence + uploaded, err := fixture.assets.UploadTaskReference( + context.Background(), + usecase.UploadTaskReferenceCommand{ + CreatorSubject: "local-admin", + IdempotencyKey: fmt.Sprintf("device-http-asset-%d", index), + DeclaredMediaType: "image/jpeg", + Content: deviceReferenceImage(t, index), + }, + ) + if err != nil || uploaded.Asset.ID == "" { + t.Fatalf("upload asset = %+v, error = %v", uploaded, err) + } + sourceRef := fmt.Sprintf("device-http-source-%d", index) + result, err := fixture.tasks.Create( + context.Background(), + usecase.CreateTaskCommand{ + CreatorSubject: "local-admin", + ActorUserID: deviceTestAdminID, + IdempotencyKey: fmt.Sprintf("device-http-task-%d", index), + SourceRef: &sourceRef, + Title: "测试采购商品", + Description: "仅用于 HTTP 合同测试", + SKU: fmt.Sprintf("TEST-SKU-%d", index), + ImageAssetID: uploaded.Asset.ID, + Quantity: 2, + }, + ) + if err != nil { + t.Fatalf("create task: %v", err) + } + return result.Task.ID +} + +func deviceReferenceImage(t *testing.T, index int) io.Reader { + t.Helper() + var encoded bytes.Buffer + source := image.NewRGBA(image.Rect(0, 0, 4, 4)) + for y := 0; y < 4; y++ { + for x := 0; x < 4; x++ { + source.Set(x, y, color.RGBA{ + R: uint8(40 + index), + G: uint8(80 + x), + B: uint8(120 + y), + A: 255, + }) + } + } + if err := jpeg.Encode(&encoded, source, nil); err != nil { + t.Fatalf("encode device reference image: %v", err) + } + return bytes.NewReader(encoded.Bytes()) +} + +type deviceRequest struct { + method string + target string + contentType string + body io.Reader + bearerToken string + claimToken string + idempotencyKey string +} + +func performDeviceRequest( + t *testing.T, + router http.Handler, + spec deviceRequest, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(spec.method, spec.target, spec.body) + if spec.contentType != "" { + request.Header.Set("Content-Type", spec.contentType) + } + if spec.bearerToken != "" { + request.Header.Set( + "Authorization", + "Bearer "+spec.bearerToken, + ) + } + if spec.claimToken != "" { + request.Header.Set(claimTokenHeader, spec.claimToken) + } + if spec.idempotencyKey != "" { + request.Header.Set("Idempotency-Key", spec.idempotencyKey) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +type deviceLifecycleResponse struct { + Task struct { + ID string `json:"id"` + Status string `json:"status"` + Version int64 `json:"version"` + ClaimGeneration int64 `json:"claim_generation"` + ReferenceImageURL string `json:"reference_image_url"` + } `json:"task"` + Execution struct { + ID string `json:"id"` + CurrentStep string `json:"current_step"` + OrderSubmitted bool `json:"order_submitted"` + } `json:"execution"` + Replayed bool `json:"replayed"` + CancelRequested bool `json:"cancel_requested"` +} + +func requireDeviceStatus( + t *testing.T, + response *httptest.ResponseRecorder, + want int, +) { + t.Helper() + if response.Code != want { + t.Fatalf( + "status/body = %d / %s, want %d", + response.Code, + response.Body, + want, + ) + } +} + +func assertNoClaimSecret( + t *testing.T, + response *httptest.ResponseRecorder, + claimToken string, +) { + t.Helper() + responseText := strings.ToLower(response.Body.String()) + claimHash := deviceTestHash(claimToken) + for _, forbidden := range []string{ + strings.ToLower(claimToken), + claimHash, + "x-claim-token", + "claim_token", + "claim_token_hash", + } { + if strings.Contains(responseText, forbidden) { + t.Fatalf( + "response exposes claim secret %q: %s", + forbidden, + response.Body, + ) + } + } + if response.Header().Get(claimTokenHeader) != "" { + t.Fatalf( + "%s response header must be empty", + claimTokenHeader, + ) + } +} + +func deviceTestHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +type deviceHTTPAuthenticator struct{} + +func (deviceHTTPAuthenticator) AuthenticateAdmin( + context.Context, + string, +) (domain.AuthPrincipal, error) { + return domain.AuthPrincipal{ + UserID: deviceTestAdminID, + Username: "device-http-admin", + Role: domain.UserRoleAdmin, + SessionID: "device-http-admin-session", + ExpiresAt: time.Now().Add(time.Hour), + }, nil +} + +func (deviceHTTPAuthenticator) AuthenticateAccessToken( + _ context.Context, + token string, +) (domain.AuthPrincipal, error) { + switch token { + case testOpaqueToken: + return domain.AuthPrincipal{ + UserID: deviceTestBuyerID, + Username: "device-http-buyer", + Role: domain.UserRoleBuyer, + DeviceID: deviceTestDeviceID, + ExpiresAt: time.Now().Add( + time.Hour, + ), + }, nil + case testCSRFOpaqueToken: + return domain.AuthPrincipal{ + UserID: deviceTestAdminID, + Username: "device-http-admin", + Role: domain.UserRoleAdmin, + SessionID: "device-http-admin-session", + ExpiresAt: time.Now().Add( + time.Hour, + ), + }, nil + default: + return domain.AuthPrincipal{}, errors.New( + "test access token is invalid", + ) + } +} + +func registerDeviceTestAdminProbe(routes gin.IRoutes) error { + routes.GET("/api/v1/admin-probe", func(ctx *gin.Context) { + ctx.Status(http.StatusNoContent) + }) + return nil +} + +var ( + _ AdminAuthenticator = deviceHTTPAuthenticator{} + _ DeviceAuthenticator = deviceHTTPAuthenticator{} +) diff --git a/backend-api/internal/transport/httpapi/router.go b/backend-api/internal/transport/httpapi/router.go index 935ece0..f3ebf6b 100644 --- a/backend-api/internal/transport/httpapi/router.go +++ b/backend-api/internal/transport/httpapi/router.go @@ -25,7 +25,9 @@ type RouterDependencies struct { Database DatabasePinger RegisterPublicRoutes RouteRegistrar RegisterAdminRoutes RouteRegistrar + RegisterDeviceRoutes RouteRegistrar AdminSessions AdminAuthenticator + DeviceAccess DeviceAuthenticator LogEvent EventLogger } @@ -62,9 +64,15 @@ func NewRouter(dependencies RouterDependencies) (http.Handler, error) { if dependencies.RegisterPublicRoutes == nil { return nil, errors.New("public route registrar is required") } + if dependencies.RegisterDeviceRoutes == nil { + return nil, errors.New("device route registrar is required") + } if dependencies.AdminSessions == nil { return nil, errors.New("admin authenticator is required") } + if dependencies.DeviceAccess == nil { + return nil, errors.New("device authenticator is required") + } if dependencies.LogEvent == nil { return nil, errors.New("event logger is required") } @@ -86,6 +94,11 @@ func NewRouter(dependencies RouterDependencies) (http.Handler, error) { if err := dependencies.RegisterAdminRoutes(adminRoutes); err != nil { return nil, err } + deviceRoutes := router.Group("") + deviceRoutes.Use(RequireDeviceAccess(dependencies.DeviceAccess)) + if err := dependencies.RegisterDeviceRoutes(deviceRoutes); err != nil { + return nil, err + } router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, errorResponse( ctx, diff --git a/backend-api/internal/transport/httpapi/router_test.go b/backend-api/internal/transport/httpapi/router_test.go index f3d2bbc..fb1c1f2 100644 --- a/backend-api/internal/transport/httpapi/router_test.go +++ b/backend-api/internal/transport/httpapi/router_test.go @@ -120,7 +120,9 @@ func TestRouterRequiresDependencies(t *testing.T) { Database: fakePinger{}, RegisterPublicRoutes: discardRoutes, RegisterAdminRoutes: discardRoutes, + RegisterDeviceRoutes: discardRoutes, AdminSessions: allowAdminAuthenticator{}, + DeviceAccess: allowAdminAuthenticator{}, LogEvent: discardEvent, } missingDatabase := valid @@ -138,11 +140,21 @@ func TestRouterRequiresDependencies(t *testing.T) { if _, err := NewRouter(missingPublicRoutes); err == nil { t.Fatal("NewRouter(nil public routes) error = nil") } + missingDeviceRoutes := valid + missingDeviceRoutes.RegisterDeviceRoutes = nil + if _, err := NewRouter(missingDeviceRoutes); err == nil { + t.Fatal("NewRouter(nil device routes) error = nil") + } missingAuth := valid missingAuth.AdminSessions = nil if _, err := NewRouter(missingAuth); err == nil { t.Fatal("NewRouter(nil admin auth) error = nil") } + missingDeviceAuth := valid + missingDeviceAuth.DeviceAccess = nil + if _, err := NewRouter(missingDeviceAuth); err == nil { + t.Fatal("NewRouter(nil device auth) error = nil") + } missingLogger := valid missingLogger.LogEvent = nil if _, err := NewRouter(missingLogger); err == nil { @@ -158,7 +170,9 @@ func newTestRouter( Database: database, RegisterPublicRoutes: discardRoutes, RegisterAdminRoutes: discardRoutes, + RegisterDeviceRoutes: discardRoutes, AdminSessions: allowAdminAuthenticator{}, + DeviceAccess: allowAdminAuthenticator{}, LogEvent: logEvent, }) } @@ -255,6 +269,19 @@ func (allowAdminAuthenticator) AuthenticateAdmin( }, nil } +func (allowAdminAuthenticator) AuthenticateAccessToken( + context.Context, + string, +) (domain.AuthPrincipal, error) { + return domain.AuthPrincipal{ + UserID: "00000000-0000-4000-8000-000000000098", + Username: "buyer", + Role: domain.UserRoleBuyer, + DeviceID: "00000000-0000-4000-8000-000000000097", + ExpiresAt: time.Now().Add(time.Hour), + }, nil +} + var requestIDPattern = regexp.MustCompile( `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, ) diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index 50b63fa..014ab12 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -299,9 +299,9 @@ func (h *Handler) CancelTask(ctx *gin.Context) { ) return } - _, err := h.service.CancelPending( + task, err := h.service.CancelTask( ctx.Request.Context(), - CancelPendingInput{ + CancelTaskInput{ TaskID: taskID, IdempotencyKey: cancelKey, }, @@ -317,9 +317,13 @@ func (h *Handler) CancelTask(ctx *gin.Context) { h.renderServiceError(ctx, err, "取消失败,请稍后重试。") return } + notice := "cancel-requested" + if task.Status == "CANCELED" { + notice = "canceled" + } ctx.Redirect( http.StatusSeeOther, - "/tasks/"+pathEscape(taskID)+"?notice=canceled", + "/tasks/"+pathEscape(taskID)+"?notice="+notice, ) } @@ -644,6 +648,8 @@ func detailNotice(value string) string { switch value { case "canceled": return "任务已取消,不会自动恢复。" + case "cancel-requested": + return "已请求设备安全停止;设备确认前任务仍保持当前执行状态。" case "cancel-conflict": return "任务状态已变化,当前不能取消。" default: @@ -736,19 +742,20 @@ type newTaskPageView struct { } type taskDetailView struct { - ID string - Title string - SKU string - Description string - Quantity int64 - MaxBudget string - Status string - StatusLabel string - StatusClass string - ReferenceAssetID string - CreatedAt time.Time - UpdatedAt time.Time - CanCancel bool + ID string + Title string + SKU string + Description string + Quantity int64 + MaxBudget string + Status string + StatusLabel string + StatusClass string + ReferenceAssetID string + CreatedAt time.Time + UpdatedAt time.Time + CanCancel bool + CancelRequiresAck bool } type taskDetailPage struct { @@ -779,7 +786,18 @@ func taskDetailViewFrom(task Task) taskDetailView { ReferenceAssetID: task.ReferenceAssetID, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, - CanCancel: task.Status == "PENDING", + CanCancel: canCancelTaskStatus(task.Status), + CancelRequiresAck: task.Status == "RUNNING" || + task.Status == "WAITING_CONFIRMATION", + } +} + +func canCancelTaskStatus(status string) bool { + switch status { + case "PENDING", "CLAIMED", "RUNNING", "WAITING_CONFIRMATION": + return true + default: + return false } } diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index a1493fc..a5772c1 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -432,6 +432,7 @@ func TestTaskDetailPendingCancelUsesCSRFAndPRG(t *testing.T) { CreatedAt: time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC), UpdatedAt: time.Date(2026, 7, 26, 3, 5, 5, 0, time.UTC), }, + cancelResult: Task{Status: "CANCELED"}, } router := newTestRouter(t, service) detail := performRequest( @@ -508,6 +509,81 @@ func TestTaskDetailDoesNotLeakForbiddenResource(t *testing.T) { } } +func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) { + tests := []struct { + status string + canCancel bool + requiresAck bool + }{ + {status: "PENDING", canCancel: true}, + {status: "CLAIMED", canCancel: true}, + {status: "RUNNING", canCancel: true, requiresAck: true}, + { + status: "WAITING_CONFIRMATION", + canCancel: true, + requiresAck: true, + }, + {status: "SUCCEEDED"}, + {status: "FAILED"}, + {status: "CANCELED"}, + } + for _, test := range tests { + t.Run(test.status, func(t *testing.T) { + view := taskDetailViewFrom(Task{Status: test.status}) + if view.CanCancel != test.canCancel || + view.CancelRequiresAck != test.requiresAck { + t.Fatalf("task detail view = %+v", view) + } + }) + } + if notice := detailNotice("cancel-requested"); !strings.Contains( + notice, + "安全停止", + ) { + t.Fatalf("cancel requested notice = %q", notice) + } +} + +func TestRunningTaskDetailExplainsCancelAcknowledgement(t *testing.T) { + service := &fakeService{ + getResult: Task{ + ID: testTaskID, + Title: "运行中任务", + SKU: "RUNNING-SKU", + Quantity: 1, + Status: "RUNNING", + ReferenceAssetID: "00000000-0000-4000-8000-000000000009", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }, + } + router := newTestRouter(t, service) + response := performRequest( + t, + router, + http.MethodGet, + "/tasks/"+testTaskID, + nil, + "", + ) + if response.Code != http.StatusOK { + t.Fatalf("running task detail status = %d", response.Code) + } + body := response.Body.String() + for _, expected := range []string{ + "请求安全停止任务?", + "设备确认前任务仍保持当前执行状态", + "确认请求停止", + } { + if !strings.Contains(body, expected) { + t.Fatalf("running detail missing %q", expected) + } + } + if strings.Contains(body, "采购执行员将不能再领取") { + t.Fatal("running detail uses immediate cancellation copy") + } +} + func TestStaticFilesAreEmbeddedAndProtected(t *testing.T) { router := newTestRouter(t, &fakeService{}) for _, route := range []string{"/static/admin.css", "/static/admin.js"} { @@ -554,7 +630,7 @@ type fakeService struct { createCalls int cancelResult Task cancelErr error - cancelInput CancelPendingInput + cancelInput CancelTaskInput } func (service *fakeService) ListTasks( @@ -595,9 +671,9 @@ func (service *fakeService) CreateTask( return service.createResult, service.createErr } -func (service *fakeService) CancelPending( +func (service *fakeService) CancelTask( _ context.Context, - input CancelPendingInput, + input CancelTaskInput, ) (Task, error) { service.cancelInput = input return service.cancelResult, service.cancelErr diff --git a/backend-api/internal/transport/webui/templates/task-detail.gohtml b/backend-api/internal/transport/webui/templates/task-detail.gohtml index 605990d..4809ab5 100644 --- a/backend-api/internal/transport/webui/templates/task-detail.gohtml +++ b/backend-api/internal/transport/webui/templates/task-detail.gohtml @@ -76,26 +76,40 @@ {{if .Task.CanCancel}} + {{if .Task.CancelRequiresAck}} +

请求安全停止任务?

+

系统将通知采购 App 在安全检查点停止;设备确认前任务仍保持当前执行状态。

+ {{else}}

确认取消任务?

任务取消后不会自动恢复,采购执行员将不能再领取。

+ {{end}}
+ data-loading-label="{{if .Task.CancelRequiresAck}}正在请求停止…{{else}}正在取消…{{end}}"> + {{if .Task.CancelRequiresAck}}确认请求停止{{else}}确认取消{{end}} +
diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go index 61628d6..abf51f0 100644 --- a/backend-api/internal/transport/webui/types.go +++ b/backend-api/internal/transport/webui/types.go @@ -23,7 +23,7 @@ type Service interface { GetTask(context.Context, string) (Task, error) UploadReference(context.Context, UploadReferenceInput) (UploadedAsset, error) CreateTask(context.Context, CreateTaskInput) (Task, error) - CancelPending(context.Context, CancelPendingInput) (Task, error) + CancelTask(context.Context, CancelTaskInput) (Task, error) } type ListTasksInput struct { @@ -81,7 +81,7 @@ type CreateTaskInput struct { ImageAssetID string } -type CancelPendingInput struct { +type CancelTaskInput struct { TaskID string IdempotencyKey string } diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index d43af47..c592522 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -128,9 +128,9 @@ func actorUserID(ctx context.Context) string { return principal.UserID } -func (adapter *UsecaseAdapter) CancelPending( +func (adapter *UsecaseAdapter) CancelTask( ctx context.Context, - input CancelPendingInput, + input CancelTaskInput, ) (Task, error) { task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{ CreatorSubject: localAdminSubject, diff --git a/backend-api/internal/usecase/lifecycle_errors.go b/backend-api/internal/usecase/lifecycle_errors.go new file mode 100644 index 0000000..8912dc0 --- /dev/null +++ b/backend-api/internal/usecase/lifecycle_errors.go @@ -0,0 +1,69 @@ +package usecase + +import "errors" + +var ( + ErrDeviceNotReady = errors.New("device readiness is missing or stale") + ErrDeviceActiveTask = errors.New("device already has an active task") + ErrClaimInvalid = errors.New("task claim is invalid") + ErrClaimExpired = errors.New("task claim lease expired") + ErrClaimReplayExpired = errors.New("claim idempotency replay is stale") + ErrTaskVersionConflict = errors.New("task version conflict") + ErrExecutionMismatch = errors.New("task execution does not match") +) + +func wrapLifecycleRepositoryError(err error) error { + switch { + case errors.Is(err, ErrDeviceNotReady): + return newError( + ErrorKindConflict, + "DEVICE_NOT_READY", + "device is not ready to claim a task", + err, + ) + case errors.Is(err, ErrDeviceActiveTask): + return newError( + ErrorKindConflict, + "DEVICE_ACTIVE_TASK", + "device already has an active task", + err, + ) + case errors.Is(err, ErrClaimInvalid): + return newError( + ErrorKindForbidden, + "TASK_CLAIM_INVALID", + "task claim is invalid", + err, + ) + case errors.Is(err, ErrClaimExpired): + return newError( + ErrorKindConflict, + "TASK_LEASE_EXPIRED", + "task claim lease expired", + err, + ) + case errors.Is(err, ErrClaimReplayExpired): + return newError( + ErrorKindConflict, + "CLAIM_REPLAY_EXPIRED", + "claim replay is no longer active", + err, + ) + case errors.Is(err, ErrTaskVersionConflict): + return newError( + ErrorKindConflict, + "TASK_VERSION_CONFLICT", + "task version has changed", + err, + ) + case errors.Is(err, ErrExecutionMismatch): + return newError( + ErrorKindConflict, + "TASK_EXECUTION_CONFLICT", + "task execution does not match", + err, + ) + default: + return wrapRepositoryError(err) + } +} diff --git a/backend-api/internal/usecase/lifecycle_ports.go b/backend-api/internal/usecase/lifecycle_ports.go new file mode 100644 index 0000000..90ecd6c --- /dev/null +++ b/backend-api/internal/usecase/lifecycle_ports.go @@ -0,0 +1,148 @@ +package usecase + +import ( + "context" + "time" + + "cmroubao/backend-api/internal/domain" +) + +type DeviceHeartbeatUpdate struct { + UserID string + DeviceID string + AppVersion string + AndroidVersion string + PDDVersion string + AccessibilityEnabled bool + PDDInstalled bool + ReportedAt time.Time +} + +type DeviceHeartbeatRecord struct { + Device domain.Device + ActiveTaskID *string +} + +type ClaimNextRepositoryRequest struct { + CreatorSubject string + UserID string + DeviceID string + IdempotencyKey string + RequestHash string + ClaimTokenHash string + Now time.Time + ExpiresAt time.Time + ReadinessAfter time.Time + Event domain.TaskEvent +} + +type ClaimNextRepositoryResult struct { + Task *domain.PurchaseTask + Replayed bool +} + +type StartTaskRepositoryRequest struct { + UserID string + DeviceID string + TaskID string + ClaimGeneration int64 + ClaimTokenHash string + ExpectedVersion int64 + IdempotencyKey string + RequestHash string + Now time.Time + ExpiresAt time.Time + Execution domain.TaskExecution + Event domain.TaskEvent +} + +type StartTaskRepositoryResult struct { + Task domain.PurchaseTask + Execution domain.TaskExecution + Replayed bool +} + +type TaskHeartbeatRepositoryRequest struct { + UserID string + DeviceID string + TaskID string + ExecutionID string + ClaimGeneration int64 + ClaimTokenHash string + Step string + Now time.Time + MinimumExpiry time.Time +} + +type TaskClaimRepositoryRequest struct { + UserID string + DeviceID string + TaskID string + ClaimGeneration int64 + ClaimTokenHash string + Now time.Time +} + +type TaskHeartbeatRepositoryResult struct { + Task domain.PurchaseTask + Execution domain.TaskExecution + CancelRequested bool +} + +type ReleaseTaskRepositoryRequest struct { + UserID string + DeviceID string + TaskID string + ClaimGeneration int64 + ClaimTokenHash string + ExpectedVersion int64 + IdempotencyKey string + RequestHash string + Now time.Time + Event domain.TaskEvent +} + +type CancelAcknowledgementRepositoryRequest struct { + UserID string + DeviceID string + TaskID string + ExecutionID string + ClaimGeneration int64 + ClaimTokenHash string + ExpectedVersion int64 + IdempotencyKey string + RequestHash string + Now time.Time + Event domain.TaskEvent +} + +type LifecycleRepository interface { + RecordDeviceHeartbeat( + context.Context, + DeviceHeartbeatUpdate, + ) (DeviceHeartbeatRecord, error) + ClaimNext( + context.Context, + ClaimNextRepositoryRequest, + ) (ClaimNextRepositoryResult, error) + StartTask( + context.Context, + StartTaskRepositoryRequest, + ) (StartTaskRepositoryResult, error) + HeartbeatTask( + context.Context, + TaskHeartbeatRepositoryRequest, + ) (TaskHeartbeatRepositoryResult, error) + GetActiveClaimTask( + context.Context, + TaskClaimRepositoryRequest, + ) (domain.PurchaseTask, error) + ReleaseTask( + context.Context, + ReleaseTaskRepositoryRequest, + ) (domain.PurchaseTask, bool, error) + AcknowledgeTaskCancellation( + context.Context, + CancelAcknowledgementRepositoryRequest, + ) (domain.PurchaseTask, bool, error) +} diff --git a/backend-api/internal/usecase/lifecycle_service.go b/backend-api/internal/usecase/lifecycle_service.go new file mode 100644 index 0000000..9358240 --- /dev/null +++ b/backend-api/internal/usecase/lifecycle_service.go @@ -0,0 +1,783 @@ +package usecase + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" + "unicode/utf8" + + "cmroubao/backend-api/internal/domain" +) + +const ( + lifecycleCreatorSubject = "local-admin" + maxLifecycleStepBytes = 64 +) + +type LifecycleService struct { + repository LifecycleRepository + clock Clock + ids IDGenerator + claimLease time.Duration + runningLease time.Duration + readinessTTL time.Duration +} + +type DeviceHeartbeatCommand struct { + UserID string + DeviceID string + AppVersion string + AndroidVersion string + PDDVersion string + AccessibilityEnabled bool + PDDInstalled bool + ClientActiveTaskID *string +} + +type DeviceHeartbeatResult struct { + Device domain.Device + ActiveTaskID *string + ClientStateMatches bool + ServerTime time.Time +} + +type ClaimNextCommand struct { + UserID string + DeviceID string + IdempotencyKey string + ClaimToken string +} + +type ClaimNextResult struct { + Task *domain.PurchaseTask + Replayed bool + ServerTime time.Time +} + +type StartTaskCommand struct { + UserID string + DeviceID string + TaskID string + ClaimGeneration int64 + ClaimToken string + ExpectedVersion int64 + IdempotencyKey string +} + +type StartTaskResult struct { + Task domain.PurchaseTask + Execution domain.TaskExecution + Replayed bool + ServerTime time.Time +} + +type TaskHeartbeatCommand struct { + UserID string + DeviceID string + TaskID string + ExecutionID string + ClaimGeneration int64 + ClaimToken string + Step string +} + +type TaskHeartbeatResult struct { + Task domain.PurchaseTask + Execution domain.TaskExecution + CancelRequested bool + ServerTime time.Time +} + +type ReferenceImageCommand struct { + UserID string + DeviceID string + TaskID string + ClaimGeneration int64 + ClaimToken string +} + +type ReleaseTaskCommand struct { + UserID string + DeviceID string + TaskID string + ClaimGeneration int64 + ClaimToken string + ExpectedVersion int64 + IdempotencyKey string +} + +type ReleaseTaskResult struct { + Task domain.PurchaseTask + Replayed bool + ServerTime time.Time +} + +type AcknowledgeCancellationCommand struct { + UserID string + DeviceID string + TaskID string + ExecutionID string + ClaimGeneration int64 + ClaimToken string + ExpectedVersion int64 + IdempotencyKey string +} + +type AcknowledgeCancellationResult struct { + Task domain.PurchaseTask + Replayed bool + ServerTime time.Time +} + +func NewLifecycleService( + repository LifecycleRepository, + clock Clock, + ids IDGenerator, + claimLease time.Duration, + runningLease time.Duration, + readinessTTL time.Duration, +) (*LifecycleService, error) { + if repository == nil || clock == nil || ids == nil { + return nil, errors.New("lifecycle service dependencies are required") + } + if claimLease <= 0 || runningLease <= 0 || readinessTTL <= 0 { + return nil, errors.New("lifecycle durations must be positive") + } + return &LifecycleService{ + repository: repository, + clock: clock, + ids: ids, + claimLease: claimLease, + runningLease: runningLease, + readinessTTL: readinessTTL, + }, nil +} + +func (service *LifecycleService) HeartbeatDevice( + ctx context.Context, + command DeviceHeartbeatCommand, +) (DeviceHeartbeatResult, error) { + command.UserID = strings.TrimSpace(command.UserID) + command.DeviceID = strings.TrimSpace(command.DeviceID) + fields := lifecycleIdentityFields(command.UserID, command.DeviceID) + appVersion := strings.TrimSpace(command.AppVersion) + androidVersion := strings.TrimSpace(command.AndroidVersion) + pddVersion := strings.TrimSpace(command.PDDVersion) + validateVersionField(fields, "app_version", appVersion) + validateVersionField(fields, "android_version", androidVersion) + validateVersionField(fields, "pdd_version", pddVersion) + if command.ClientActiveTaskID != nil { + value := strings.TrimSpace(*command.ClientActiveTaskID) + command.ClientActiveTaskID = &value + if value != "" && !isUUID(value) { + fields["active_task_id"] = "must be a UUID or null" + } + } + if len(fields) > 0 { + return DeviceHeartbeatResult{}, invalidError( + "DEVICE_HEARTBEAT_INVALID", + "device heartbeat is invalid", + fields, + ) + } + now := service.clock.Now().UTC() + record, err := service.repository.RecordDeviceHeartbeat( + ctx, + DeviceHeartbeatUpdate{ + UserID: command.UserID, + DeviceID: command.DeviceID, + AppVersion: appVersion, + AndroidVersion: androidVersion, + PDDVersion: pddVersion, + AccessibilityEnabled: command.AccessibilityEnabled, + PDDInstalled: command.PDDInstalled, + ReportedAt: now, + }, + ) + if err != nil { + return DeviceHeartbeatResult{}, + wrapLifecycleRepositoryError(err) + } + return DeviceHeartbeatResult{ + Device: record.Device, + ActiveTaskID: record.ActiveTaskID, + ClientStateMatches: sameOptionalID( + command.ClientActiveTaskID, + record.ActiveTaskID, + ), + ServerTime: now, + }, nil +} + +func (service *LifecycleService) ClaimNext( + ctx context.Context, + command ClaimNextCommand, +) (ClaimNextResult, error) { + command.UserID = strings.TrimSpace(command.UserID) + command.DeviceID = strings.TrimSpace(command.DeviceID) + command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) + fields := lifecycleWriteFields( + command.UserID, + command.DeviceID, + command.IdempotencyKey, + command.ClaimToken, + ) + if len(fields) > 0 { + return ClaimNextResult{}, invalidError( + "TASK_CLAIM_INVALID", + "task claim request is invalid", + fields, + ) + } + requestHash, err := lifecycleRequestHash(struct { + UserID string `json:"user_id"` + DeviceID string `json:"device_id"` + ClaimTokenHash string `json:"claim_token_sha256"` + }{ + UserID: command.UserID, + DeviceID: command.DeviceID, + ClaimTokenHash: hashSecret(command.ClaimToken), + }) + if err != nil { + return ClaimNextResult{}, internalLifecycleFailure(err) + } + event, err := service.newLifecycleEvent( + command.UserID, + command.DeviceID, + "", + "TASK_CLAIMED", + "task claimed", + ) + if err != nil { + return ClaimNextResult{}, err + } + now := service.clock.Now().UTC() + result, err := service.repository.ClaimNext( + ctx, + ClaimNextRepositoryRequest{ + CreatorSubject: lifecycleCreatorSubject, + UserID: command.UserID, + DeviceID: command.DeviceID, + IdempotencyKey: command.IdempotencyKey, + RequestHash: requestHash, + ClaimTokenHash: hashSecret(command.ClaimToken), + Now: now, + ExpiresAt: now.Add(service.claimLease), + ReadinessAfter: now.Add(-service.readinessTTL), + Event: event, + }, + ) + if err != nil { + return ClaimNextResult{}, wrapLifecycleRepositoryError(err) + } + return ClaimNextResult{ + Task: result.Task, + Replayed: result.Replayed, + ServerTime: now, + }, nil +} + +func (service *LifecycleService) StartTask( + ctx context.Context, + command StartTaskCommand, +) (StartTaskResult, error) { + command = normalizeStartTaskCommand(command) + fields := lifecycleTransitionFields( + command.UserID, + command.DeviceID, + command.TaskID, + command.ClaimGeneration, + command.ClaimToken, + command.ExpectedVersion, + command.IdempotencyKey, + ) + if len(fields) > 0 { + return StartTaskResult{}, invalidError( + "TASK_START_INVALID", + "task start request is invalid", + fields, + ) + } + executionID, err := service.ids.NewID() + if err != nil { + return StartTaskResult{}, internalLifecycleFailure(err) + } + event, err := service.newLifecycleEvent( + command.UserID, + command.DeviceID, + command.TaskID, + "TASK_STARTED", + "task started", + ) + if err != nil { + return StartTaskResult{}, err + } + now := service.clock.Now().UTC() + requestHash, err := lifecycleRequestHash(commandHashView{ + TaskID: command.TaskID, + ClaimGeneration: command.ClaimGeneration, + ClaimTokenHash: hashSecret(command.ClaimToken), + ExpectedVersion: command.ExpectedVersion, + }) + if err != nil { + return StartTaskResult{}, internalLifecycleFailure(err) + } + result, err := service.repository.StartTask( + ctx, + StartTaskRepositoryRequest{ + UserID: command.UserID, + DeviceID: command.DeviceID, + TaskID: command.TaskID, + ClaimGeneration: command.ClaimGeneration, + ClaimTokenHash: hashSecret(command.ClaimToken), + ExpectedVersion: command.ExpectedVersion, + IdempotencyKey: command.IdempotencyKey, + RequestHash: requestHash, + Now: now, + ExpiresAt: now.Add(service.runningLease), + Execution: domain.TaskExecution{ + ID: executionID, + TaskID: command.TaskID, + ClaimGeneration: command.ClaimGeneration, + UserID: command.UserID, + DeviceID: command.DeviceID, + CurrentStep: "PREFLIGHT", + OrderSubmitted: false, + StartedAt: now, + }, + Event: event, + }, + ) + if err != nil { + return StartTaskResult{}, wrapLifecycleRepositoryError(err) + } + return StartTaskResult{ + Task: result.Task, + Execution: result.Execution, + Replayed: result.Replayed, + ServerTime: now, + }, nil +} + +func (service *LifecycleService) HeartbeatTask( + ctx context.Context, + command TaskHeartbeatCommand, +) (TaskHeartbeatResult, error) { + command.UserID = strings.TrimSpace(command.UserID) + command.DeviceID = strings.TrimSpace(command.DeviceID) + command.TaskID = strings.TrimSpace(command.TaskID) + command.ExecutionID = strings.TrimSpace(command.ExecutionID) + command.Step = strings.TrimSpace(command.Step) + fields := lifecycleClaimFields( + command.UserID, + command.DeviceID, + command.TaskID, + command.ClaimGeneration, + command.ClaimToken, + ) + if !isUUID(command.ExecutionID) { + fields["execution_id"] = "must be a UUID" + } + if !validLifecycleStep(command.Step) { + fields["step"] = "must be 1-64 uppercase ASCII characters" + } + if len(fields) > 0 { + return TaskHeartbeatResult{}, invalidError( + "TASK_HEARTBEAT_INVALID", + "task heartbeat is invalid", + fields, + ) + } + now := service.clock.Now().UTC() + result, err := service.repository.HeartbeatTask( + ctx, + TaskHeartbeatRepositoryRequest{ + UserID: command.UserID, + DeviceID: command.DeviceID, + TaskID: command.TaskID, + ExecutionID: command.ExecutionID, + ClaimGeneration: command.ClaimGeneration, + ClaimTokenHash: hashSecret(command.ClaimToken), + Step: command.Step, + Now: now, + MinimumExpiry: now.Add(service.runningLease), + }, + ) + if err != nil { + return TaskHeartbeatResult{}, + wrapLifecycleRepositoryError(err) + } + return TaskHeartbeatResult{ + Task: result.Task, + Execution: result.Execution, + CancelRequested: result.CancelRequested, + ServerTime: now, + }, nil +} + +func (service *LifecycleService) AuthorizeReferenceImage( + ctx context.Context, + command ReferenceImageCommand, +) (domain.PurchaseTask, error) { + command.UserID = strings.TrimSpace(command.UserID) + command.DeviceID = strings.TrimSpace(command.DeviceID) + command.TaskID = strings.TrimSpace(command.TaskID) + fields := lifecycleClaimFields( + command.UserID, + command.DeviceID, + command.TaskID, + command.ClaimGeneration, + command.ClaimToken, + ) + if len(fields) > 0 { + return domain.PurchaseTask{}, invalidError( + "TASK_REFERENCE_IMAGE_INVALID", + "task reference image request is invalid", + fields, + ) + } + task, err := service.repository.GetActiveClaimTask( + ctx, + TaskClaimRepositoryRequest{ + UserID: command.UserID, + DeviceID: command.DeviceID, + TaskID: command.TaskID, + ClaimGeneration: command.ClaimGeneration, + ClaimTokenHash: hashSecret(command.ClaimToken), + Now: service.clock.Now().UTC(), + }, + ) + if err != nil { + return domain.PurchaseTask{}, + wrapLifecycleRepositoryError(err) + } + return task, nil +} + +func (service *LifecycleService) ReleaseTask( + ctx context.Context, + command ReleaseTaskCommand, +) (ReleaseTaskResult, error) { + command.UserID = strings.TrimSpace(command.UserID) + command.DeviceID = strings.TrimSpace(command.DeviceID) + command.TaskID = strings.TrimSpace(command.TaskID) + command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) + fields := lifecycleTransitionFields( + command.UserID, + command.DeviceID, + command.TaskID, + command.ClaimGeneration, + command.ClaimToken, + command.ExpectedVersion, + command.IdempotencyKey, + ) + if len(fields) > 0 { + return ReleaseTaskResult{}, invalidError( + "TASK_RELEASE_INVALID", + "task release request is invalid", + fields, + ) + } + event, err := service.newLifecycleEvent( + command.UserID, + command.DeviceID, + command.TaskID, + "TASK_RELEASED", + "task released", + ) + if err != nil { + return ReleaseTaskResult{}, err + } + now := service.clock.Now().UTC() + requestHash, err := lifecycleRequestHash(commandHashView{ + TaskID: command.TaskID, + ClaimGeneration: command.ClaimGeneration, + ClaimTokenHash: hashSecret(command.ClaimToken), + ExpectedVersion: command.ExpectedVersion, + }) + if err != nil { + return ReleaseTaskResult{}, internalLifecycleFailure(err) + } + task, replayed, err := service.repository.ReleaseTask( + ctx, + ReleaseTaskRepositoryRequest{ + UserID: command.UserID, + DeviceID: command.DeviceID, + TaskID: command.TaskID, + ClaimGeneration: command.ClaimGeneration, + ClaimTokenHash: hashSecret(command.ClaimToken), + ExpectedVersion: command.ExpectedVersion, + IdempotencyKey: command.IdempotencyKey, + RequestHash: requestHash, + Now: now, + Event: event, + }, + ) + if err != nil { + return ReleaseTaskResult{}, wrapLifecycleRepositoryError(err) + } + return ReleaseTaskResult{ + Task: task, + Replayed: replayed, + ServerTime: now, + }, nil +} + +func (service *LifecycleService) AcknowledgeCancellation( + ctx context.Context, + command AcknowledgeCancellationCommand, +) (AcknowledgeCancellationResult, error) { + command.UserID = strings.TrimSpace(command.UserID) + command.DeviceID = strings.TrimSpace(command.DeviceID) + command.TaskID = strings.TrimSpace(command.TaskID) + command.ExecutionID = strings.TrimSpace(command.ExecutionID) + command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) + fields := lifecycleTransitionFields( + command.UserID, + command.DeviceID, + command.TaskID, + command.ClaimGeneration, + command.ClaimToken, + command.ExpectedVersion, + command.IdempotencyKey, + ) + if !isUUID(command.ExecutionID) { + fields["execution_id"] = "must be a UUID" + } + if len(fields) > 0 { + return AcknowledgeCancellationResult{}, invalidError( + "TASK_CANCEL_ACK_INVALID", + "task cancellation acknowledgement is invalid", + fields, + ) + } + event, err := service.newLifecycleEvent( + command.UserID, + command.DeviceID, + command.TaskID, + "TASK_CANCELED", + "task cancellation acknowledged", + ) + if err != nil { + return AcknowledgeCancellationResult{}, err + } + now := service.clock.Now().UTC() + requestHash, err := lifecycleRequestHash(struct { + commandHashView + ExecutionID string `json:"execution_id"` + }{ + commandHashView: commandHashView{ + TaskID: command.TaskID, + ClaimGeneration: command.ClaimGeneration, + ClaimTokenHash: hashSecret(command.ClaimToken), + ExpectedVersion: command.ExpectedVersion, + }, + ExecutionID: command.ExecutionID, + }) + if err != nil { + return AcknowledgeCancellationResult{}, + internalLifecycleFailure(err) + } + task, replayed, err := service.repository.AcknowledgeTaskCancellation( + ctx, + CancelAcknowledgementRepositoryRequest{ + UserID: command.UserID, + DeviceID: command.DeviceID, + TaskID: command.TaskID, + ExecutionID: command.ExecutionID, + ClaimGeneration: command.ClaimGeneration, + ClaimTokenHash: hashSecret(command.ClaimToken), + ExpectedVersion: command.ExpectedVersion, + IdempotencyKey: command.IdempotencyKey, + RequestHash: requestHash, + Now: now, + Event: event, + }, + ) + if err != nil { + return AcknowledgeCancellationResult{}, + wrapLifecycleRepositoryError(err) + } + return AcknowledgeCancellationResult{ + Task: task, + Replayed: replayed, + ServerTime: now, + }, nil +} + +type commandHashView struct { + TaskID string `json:"task_id"` + ClaimGeneration int64 `json:"claim_generation"` + ClaimTokenHash string `json:"claim_token_sha256"` + ExpectedVersion int64 `json:"expected_version"` +} + +func normalizeStartTaskCommand(command StartTaskCommand) StartTaskCommand { + command.UserID = strings.TrimSpace(command.UserID) + command.DeviceID = strings.TrimSpace(command.DeviceID) + command.TaskID = strings.TrimSpace(command.TaskID) + command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) + return command +} + +func lifecycleWriteFields( + userID string, + deviceID string, + idempotencyKey string, + claimToken string, +) map[string]string { + fields := lifecycleIdentityFields(userID, deviceID) + validateIdempotencyField(fields, idempotencyKey) + if !validOpaqueToken(claimToken) { + fields["claim_token"] = "must be a 256-bit Raw URL value" + } + return fields +} + +func lifecycleClaimFields( + userID string, + deviceID string, + taskID string, + claimGeneration int64, + claimToken string, +) map[string]string { + fields := lifecycleIdentityFields(userID, deviceID) + if !isUUID(taskID) { + fields["task_id"] = "must be a UUID" + } + if claimGeneration < 1 { + fields["claim_generation"] = "must be positive" + } + if !validOpaqueToken(claimToken) { + fields["claim_token"] = "must be a 256-bit Raw URL value" + } + return fields +} + +func lifecycleTransitionFields( + userID string, + deviceID string, + taskID string, + claimGeneration int64, + claimToken string, + expectedVersion int64, + idempotencyKey string, +) map[string]string { + fields := lifecycleClaimFields( + userID, + deviceID, + taskID, + claimGeneration, + claimToken, + ) + if expectedVersion < 1 { + fields["expected_version"] = "must be positive" + } + validateIdempotencyField(fields, idempotencyKey) + return fields +} + +func lifecycleIdentityFields( + userID string, + deviceID string, +) map[string]string { + fields := make(map[string]string) + if !isUUID(userID) { + fields["user_id"] = "must be a UUID" + } + if !isUUID(deviceID) { + fields["device_id"] = "must be a UUID" + } + return fields +} + +func validateIdempotencyField( + fields map[string]string, + value string, +) { + if value == "" || + len([]byte(value)) > maxIdempotencyKeyBytes || + !isPrintableASCII(value) { + fields["idempotency_key"] = "invalid" + } +} + +func validateVersionField( + fields map[string]string, + name string, + value string, +) { + if value == "" { + fields[name] = "required" + } else if !utf8.ValidString(value) || + len([]byte(value)) > domain.MaxVersionBytes { + fields[name] = "must be valid UTF-8 up to 128 bytes" + } +} + +func validLifecycleStep(value string) bool { + if value == "" || len([]byte(value)) > maxLifecycleStepBytes { + return false + } + for _, char := range value { + if (char < 'A' || char > 'Z') && + (char < '0' || char > '9') && + char != '_' { + return false + } + } + return true +} + +func lifecycleRequestHash(value any) (string, error) { + encoded, err := json.Marshal(value) + if err != nil { + return "", err + } + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil +} + +func (service *LifecycleService) newLifecycleEvent( + userID string, + deviceID string, + taskID string, + eventType string, + message string, +) (domain.TaskEvent, error) { + id, err := service.ids.NewID() + if err != nil { + return domain.TaskEvent{}, internalLifecycleFailure(err) + } + actorUserID := userID + actorDeviceID := deviceID + return domain.TaskEvent{ + ID: id, + TaskID: taskID, + ActorUserID: &actorUserID, + ActorDeviceID: &actorDeviceID, + Type: eventType, + Message: message, + OccurredAt: service.clock.Now().UTC(), + }, nil +} + +func sameOptionalID(left, right *string) bool { + if left == nil || strings.TrimSpace(*left) == "" { + return right == nil + } + return right != nil && strings.TrimSpace(*left) == *right +} + +func internalLifecycleFailure(err error) error { + return newError( + ErrorKindInternal, + "INTERNAL_ERROR", + "internal server error", + err, + ) +} diff --git a/backend-api/internal/usecase/lifecycle_service_test.go b/backend-api/internal/usecase/lifecycle_service_test.go new file mode 100644 index 0000000..c26cf29 --- /dev/null +++ b/backend-api/internal/usecase/lifecycle_service_test.go @@ -0,0 +1,396 @@ +package usecase + +import ( + "context" + "testing" + "time" + + "cmroubao/backend-api/internal/domain" +) + +const ( + lifecycleUserID = "00000000-0000-4000-8000-000000000091" + lifecycleDeviceID = "00000000-0000-4000-8000-000000000092" + lifecycleTaskID = "00000000-0000-4000-8000-000000000093" + lifecycleExecID = "00000000-0000-4000-8000-000000000094" +) + +func TestLifecycleServiceHeartbeatAndClaimUseServerIdentityAndTime( + t *testing.T, +) { + repository := &fakeLifecycleRepository{ + heartbeatRecord: DeviceHeartbeatRecord{ + Device: domain.Device{ID: lifecycleDeviceID}, + }, + claimResult: ClaimNextRepositoryResult{ + Task: &domain.PurchaseTask{ID: lifecycleTaskID}, + }, + } + service := mustLifecycleService(t, repository) + clientActive := lifecycleTaskID + heartbeat, err := service.HeartbeatDevice( + context.Background(), + DeviceHeartbeatCommand{ + UserID: lifecycleUserID, + DeviceID: lifecycleDeviceID, + AppVersion: "0.1.0", + AndroidVersion: "16", + PDDVersion: "8.17.0", + AccessibilityEnabled: true, + PDDInstalled: true, + ClientActiveTaskID: &clientActive, + }, + ) + if err != nil { + t.Fatalf("HeartbeatDevice() error = %v", err) + } + if heartbeat.ClientStateMatches || + !repository.heartbeatUpdate.ReportedAt.Equal(fakeClock{}.Now()) { + t.Fatalf("heartbeat result/update = %+v / %+v", heartbeat, repository.heartbeatUpdate) + } + + rawToken := validTestToken(30) + claim, err := service.ClaimNext( + context.Background(), + ClaimNextCommand{ + UserID: lifecycleUserID, + DeviceID: lifecycleDeviceID, + IdempotencyKey: "claim-1", + ClaimToken: rawToken, + }, + ) + if err != nil { + t.Fatalf("ClaimNext() error = %v", err) + } + request := repository.claimRequest + if claim.Task == nil || claim.Task.ID != lifecycleTaskID || + request.ClaimTokenHash == rawToken || + request.ClaimTokenHash != hashSecret(rawToken) || + len(request.RequestHash) != 64 || + request.ExpiresAt.Sub(request.Now) != 10*time.Minute || + request.Now.Sub(request.ReadinessAfter) != 2*time.Minute { + t.Fatalf("claim/request = %+v / %+v", claim, request) + } + if request.Event.ActorUserID == nil || + *request.Event.ActorUserID != lifecycleUserID || + request.Event.ActorDeviceID == nil || + *request.Event.ActorDeviceID != lifecycleDeviceID { + t.Fatalf("claim event = %+v", request.Event) + } +} + +func TestLifecycleServiceTransitionsHashClaimAndCreateExecution( + t *testing.T, +) { + repository := &fakeLifecycleRepository{ + startResult: StartTaskRepositoryResult{ + Task: domain.PurchaseTask{ + ID: lifecycleTaskID, + Status: domain.TaskStatusRunning, + Version: 3, + ClaimGeneration: 1, + }, + Execution: domain.TaskExecution{ID: lifecycleExecID}, + }, + heartbeatTaskResult: TaskHeartbeatRepositoryResult{ + Task: domain.PurchaseTask{ + ID: lifecycleTaskID, + Status: domain.TaskStatusRunning, + Version: 4, + ClaimGeneration: 1, + }, + Execution: domain.TaskExecution{ID: lifecycleExecID}, + CancelRequested: true, + }, + releaseTask: domain.PurchaseTask{ + ID: lifecycleTaskID, + Status: domain.TaskStatusPending, + }, + cancelTask: domain.PurchaseTask{ + ID: lifecycleTaskID, + Status: domain.TaskStatusCanceled, + }, + } + service := mustLifecycleService(t, repository) + token := validTestToken(40) + + start, err := service.StartTask( + context.Background(), + StartTaskCommand{ + UserID: lifecycleUserID, + DeviceID: lifecycleDeviceID, + TaskID: lifecycleTaskID, + ClaimGeneration: 1, + ClaimToken: token, + ExpectedVersion: 2, + IdempotencyKey: "start-1", + }, + ) + if err != nil { + t.Fatalf("StartTask() error = %v", err) + } + startRequest := repository.startRequest + if start.Execution.ID != lifecycleExecID || + startRequest.ClaimTokenHash != hashSecret(token) || + startRequest.Execution.ID == "" || + startRequest.Execution.CurrentStep != "PREFLIGHT" || + startRequest.ExpiresAt.Sub(startRequest.Now) != 90*time.Second || + startRequest.Event.Type != "TASK_STARTED" { + t.Fatalf("start/request = %+v / %+v", start, startRequest) + } + + heartbeat, err := service.HeartbeatTask( + context.Background(), + TaskHeartbeatCommand{ + UserID: lifecycleUserID, + DeviceID: lifecycleDeviceID, + TaskID: lifecycleTaskID, + ExecutionID: lifecycleExecID, + ClaimGeneration: 1, + ClaimToken: token, + Step: "SCAN_RESULTS", + }, + ) + if err != nil { + t.Fatalf("HeartbeatTask() error = %v", err) + } + if !heartbeat.CancelRequested || + repository.taskHeartbeatRequest.MinimumExpiry.Sub( + repository.taskHeartbeatRequest.Now, + ) != 90*time.Second { + t.Fatalf("heartbeat/request = %+v / %+v", heartbeat, repository.taskHeartbeatRequest) + } + + release, err := service.ReleaseTask( + context.Background(), + ReleaseTaskCommand{ + UserID: lifecycleUserID, + DeviceID: lifecycleDeviceID, + TaskID: lifecycleTaskID, + ClaimGeneration: 1, + ClaimToken: token, + ExpectedVersion: 2, + IdempotencyKey: "release-1", + }, + ) + if err != nil || release.Task.Status != domain.TaskStatusPending { + t.Fatalf("ReleaseTask() = %+v, error = %v", release, err) + } + if repository.releaseRequest.Event.Type != "TASK_RELEASED" { + t.Fatalf("release event = %+v", repository.releaseRequest.Event) + } + + acknowledged, err := service.AcknowledgeCancellation( + context.Background(), + AcknowledgeCancellationCommand{ + UserID: lifecycleUserID, + DeviceID: lifecycleDeviceID, + TaskID: lifecycleTaskID, + ExecutionID: lifecycleExecID, + ClaimGeneration: 1, + ClaimToken: token, + ExpectedVersion: 4, + IdempotencyKey: "cancel-ack-1", + }, + ) + if err != nil || + acknowledged.Task.Status != domain.TaskStatusCanceled { + t.Fatalf( + "AcknowledgeCancellation() = %+v, error = %v", + acknowledged, + err, + ) + } + if repository.cancelRequest.Event.Type != "TASK_CANCELED" { + t.Fatalf("cancel event = %+v", repository.cancelRequest.Event) + } +} + +func TestLifecycleServiceRejectsMalformedClaimsAndMapsConflicts( + t *testing.T, +) { + repository := &fakeLifecycleRepository{ + claimErr: ErrDeviceNotReady, + } + service := mustLifecycleService(t, repository) + _, err := service.ClaimNext( + context.Background(), + ClaimNextCommand{ + UserID: lifecycleUserID, + DeviceID: lifecycleDeviceID, + IdempotencyKey: "claim-1", + ClaimToken: validTestToken(50), + }, + ) + assertUsecaseError(t, err, ErrorKindConflict, "DEVICE_NOT_READY") + + _, err = service.ClaimNext( + context.Background(), + ClaimNextCommand{ + UserID: lifecycleUserID, + DeviceID: lifecycleDeviceID, + IdempotencyKey: "claim-2", + ClaimToken: "not-a-token", + }, + ) + assertUsecaseError(t, err, ErrorKindInvalid, "TASK_CLAIM_INVALID") + + _, err = service.HeartbeatTask( + context.Background(), + TaskHeartbeatCommand{ + UserID: lifecycleUserID, + DeviceID: lifecycleDeviceID, + TaskID: lifecycleTaskID, + ExecutionID: lifecycleExecID, + ClaimGeneration: 1, + ClaimToken: validTestToken(51), + Step: "not valid", + }, + ) + assertUsecaseError(t, err, ErrorKindInvalid, "TASK_HEARTBEAT_INVALID") +} + +func TestLifecycleServiceAuthorizesReferenceImageWithHashedClaim( + t *testing.T, +) { + task := domain.PurchaseTask{ + ID: lifecycleTaskID, + ImageAssetID: "00000000-0000-4000-8000-000000000095", + } + repository := &fakeLifecycleRepository{claimedTask: task} + service := mustLifecycleService(t, repository) + rawToken := validTestToken(36) + result, err := service.AuthorizeReferenceImage( + context.Background(), + ReferenceImageCommand{ + UserID: lifecycleUserID, + DeviceID: lifecycleDeviceID, + TaskID: lifecycleTaskID, + ClaimGeneration: 2, + ClaimToken: rawToken, + }, + ) + if err != nil || result.ImageAssetID != task.ImageAssetID { + t.Fatalf( + "AuthorizeReferenceImage() = %+v, error = %v", + result, + err, + ) + } + if repository.claimedTaskRequest.ClaimTokenHash != + hashSecret(rawToken) || + repository.claimedTaskRequest.ClaimGeneration != 2 || + !repository.claimedTaskRequest.Now.Equal(fakeClock{}.Now()) { + t.Fatalf( + "claimed task request = %+v", + repository.claimedTaskRequest, + ) + } +} + +type fakeLifecycleRepository struct { + heartbeatUpdate DeviceHeartbeatUpdate + heartbeatRecord DeviceHeartbeatRecord + heartbeatErr error + claimRequest ClaimNextRepositoryRequest + claimResult ClaimNextRepositoryResult + claimErr error + startRequest StartTaskRepositoryRequest + startResult StartTaskRepositoryResult + startErr error + taskHeartbeatRequest TaskHeartbeatRepositoryRequest + heartbeatTaskResult TaskHeartbeatRepositoryResult + taskHeartbeatErr error + claimedTaskRequest TaskClaimRepositoryRequest + claimedTask domain.PurchaseTask + claimedTaskErr error + releaseRequest ReleaseTaskRepositoryRequest + releaseTask domain.PurchaseTask + releaseReplayed bool + releaseErr error + cancelRequest CancelAcknowledgementRepositoryRequest + cancelTask domain.PurchaseTask + cancelReplayed bool + cancelErr error +} + +func (repository *fakeLifecycleRepository) RecordDeviceHeartbeat( + _ context.Context, + update DeviceHeartbeatUpdate, +) (DeviceHeartbeatRecord, error) { + repository.heartbeatUpdate = update + return repository.heartbeatRecord, repository.heartbeatErr +} + +func (repository *fakeLifecycleRepository) ClaimNext( + _ context.Context, + request ClaimNextRepositoryRequest, +) (ClaimNextRepositoryResult, error) { + repository.claimRequest = request + return repository.claimResult, repository.claimErr +} + +func (repository *fakeLifecycleRepository) StartTask( + _ context.Context, + request StartTaskRepositoryRequest, +) (StartTaskRepositoryResult, error) { + repository.startRequest = request + return repository.startResult, repository.startErr +} + +func (repository *fakeLifecycleRepository) HeartbeatTask( + _ context.Context, + request TaskHeartbeatRepositoryRequest, +) (TaskHeartbeatRepositoryResult, error) { + repository.taskHeartbeatRequest = request + return repository.heartbeatTaskResult, repository.taskHeartbeatErr +} + +func (repository *fakeLifecycleRepository) GetActiveClaimTask( + _ context.Context, + request TaskClaimRepositoryRequest, +) (domain.PurchaseTask, error) { + repository.claimedTaskRequest = request + return repository.claimedTask, repository.claimedTaskErr +} + +func (repository *fakeLifecycleRepository) ReleaseTask( + _ context.Context, + request ReleaseTaskRepositoryRequest, +) (domain.PurchaseTask, bool, error) { + repository.releaseRequest = request + return repository.releaseTask, + repository.releaseReplayed, + repository.releaseErr +} + +func (repository *fakeLifecycleRepository) AcknowledgeTaskCancellation( + _ context.Context, + request CancelAcknowledgementRepositoryRequest, +) (domain.PurchaseTask, bool, error) { + repository.cancelRequest = request + return repository.cancelTask, + repository.cancelReplayed, + repository.cancelErr +} + +func mustLifecycleService( + t *testing.T, + repository LifecycleRepository, +) *LifecycleService { + t.Helper() + service, err := NewLifecycleService( + repository, + fakeClock{}, + &sequenceIDs{}, + 10*time.Minute, + 90*time.Second, + 2*time.Minute, + ) + if err != nil { + t.Fatalf("NewLifecycleService() error = %v", err) + } + return service +} + +var _ LifecycleRepository = (*fakeLifecycleRepository)(nil) diff --git a/backend-api/internal/usecase/ports.go b/backend-api/internal/usecase/ports.go index 7045bed..516edda 100644 --- a/backend-api/internal/usecase/ports.go +++ b/backend-api/internal/usecase/ports.go @@ -82,7 +82,7 @@ type TaskRepository interface { string, string, ) (domain.TaskDetail, error) - CancelPendingTask( + CancelTask( context.Context, string, string, diff --git a/backend-api/internal/usecase/task_service.go b/backend-api/internal/usecase/task_service.go index 0d14fbb..a41528c 100644 --- a/backend-api/internal/usecase/task_service.go +++ b/backend-api/internal/usecase/task_service.go @@ -378,7 +378,7 @@ func (s *TaskService) Cancel( Message: "task canceled", OccurredAt: now, } - task, err := s.repository.CancelPendingTask( + task, err := s.repository.CancelTask( ctx, command.CreatorSubject, command.TaskID, diff --git a/backend-api/internal/usecase/task_service_test.go b/backend-api/internal/usecase/task_service_test.go index a9ee021..469440d 100644 --- a/backend-api/internal/usecase/task_service_test.go +++ b/backend-api/internal/usecase/task_service_test.go @@ -203,7 +203,7 @@ func (repository *fakeTaskRepository) GetTaskDetail( return domain.TaskDetail{}, errors.New("not implemented") } -func (repository *fakeTaskRepository) CancelPendingTask( +func (repository *fakeTaskRepository) CancelTask( _ context.Context, _ string, _ string, diff --git a/backend-api/migrations/00004_claims_and_lifecycle.sql b/backend-api/migrations/00004_claims_and_lifecycle.sql new file mode 100644 index 0000000..53e04b9 --- /dev/null +++ b/backend-api/migrations/00004_claims_and_lifecycle.sql @@ -0,0 +1,289 @@ +-- +goose Up +ALTER TABLE devices + ADD COLUMN accessibility_enabled INTEGER NOT NULL DEFAULT 0 + CHECK (accessibility_enabled IN (0, 1)); + +ALTER TABLE devices + ADD COLUMN pdd_installed INTEGER NOT NULL DEFAULT 0 + CHECK (pdd_installed IN (0, 1)); + +ALTER TABLE devices + ADD COLUMN readiness_reported_at TEXT; + +ALTER TABLE purchase_tasks + ADD COLUMN claimed_by_user_id TEXT + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + +ALTER TABLE purchase_tasks + ADD COLUMN claimed_by_device_id TEXT + REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + +ALTER TABLE purchase_tasks + ADD COLUMN claim_generation INTEGER NOT NULL DEFAULT 0 + CHECK (claim_generation >= 0); + +ALTER TABLE purchase_tasks + ADD COLUMN claim_token_hash TEXT + CHECK ( + claim_token_hash IS NULL + OR ( + length(claim_token_hash) = 64 + AND claim_token_hash NOT GLOB '*[^0-9a-f]*' + ) + ); + +ALTER TABLE purchase_tasks + ADD COLUMN claim_issued_at TEXT; + +ALTER TABLE purchase_tasks + ADD COLUMN claim_expires_at TEXT; + +ALTER TABLE purchase_tasks + ADD COLUMN cancel_requested_at TEXT; + +ALTER TABLE purchase_tasks + ADD COLUMN cancel_requested_by_user_id TEXT + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + +CREATE UNIQUE INDEX purchase_tasks_active_device_idx + ON purchase_tasks (claimed_by_device_id) + WHERE claimed_by_device_id IS NOT NULL + AND status IN ('CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION'); + +CREATE TABLE task_executions ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE, + attempt_no INTEGER NOT NULL + CHECK (attempt_no > 0), + claim_generation INTEGER NOT NULL + CHECK (claim_generation > 0), + user_id TEXT NOT NULL + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + device_id TEXT NOT NULL + REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + current_step TEXT NOT NULL + CHECK ( + length(trim(current_step)) > 0 + AND length(CAST(current_step AS BLOB)) <= 64 + ), + last_heartbeat_at TEXT NOT NULL, + order_submitted INTEGER NOT NULL DEFAULT 0 + CHECK (order_submitted = 0), + started_at TEXT NOT NULL, + finished_at TEXT, + UNIQUE (task_id, attempt_no), + UNIQUE (task_id, claim_generation) +); + +CREATE UNIQUE INDEX task_executions_active_task_idx + ON task_executions (task_id) + WHERE finished_at IS NULL; + +CREATE INDEX task_executions_device_heartbeat_idx + ON task_executions (device_id, last_heartbeat_at DESC); + +CREATE TABLE lifecycle_requests ( + user_id TEXT NOT NULL + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + device_id TEXT NOT NULL + REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + operation TEXT NOT NULL + CHECK ( + operation IN ('CLAIM_NEXT', 'START', 'RELEASE', 'CANCEL_ACK') + ), + idempotency_key TEXT NOT NULL + CHECK ( + length(trim(idempotency_key)) > 0 + AND length(CAST(idempotency_key AS BLOB)) <= 128 + ), + request_sha256 TEXT NOT NULL + CHECK ( + length(request_sha256) = 64 + AND request_sha256 NOT GLOB '*[^0-9a-f]*' + ), + result_kind TEXT NOT NULL + CHECK (result_kind IN ('TASK', 'NO_TASK', 'EXECUTION')), + task_id TEXT + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + claim_generation INTEGER + CHECK (claim_generation IS NULL OR claim_generation > 0), + execution_id TEXT + REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, device_id, operation, idempotency_key), + CHECK ( + ( + operation = 'CLAIM_NEXT' + AND result_kind = 'NO_TASK' + AND task_id IS NULL + AND claim_generation IS NULL + AND execution_id IS NULL + ) + OR ( + operation = 'CLAIM_NEXT' + AND result_kind = 'TASK' + AND task_id IS NOT NULL + AND claim_generation IS NOT NULL + AND execution_id IS NULL + ) + OR ( + operation = 'START' + AND result_kind = 'EXECUTION' + AND task_id IS NOT NULL + AND claim_generation IS NOT NULL + AND execution_id IS NOT NULL + ) + OR ( + operation = 'RELEASE' + AND result_kind = 'TASK' + AND task_id IS NOT NULL + AND claim_generation IS NOT NULL + AND execution_id IS NULL + ) + OR ( + operation = 'CANCEL_ACK' + AND result_kind = 'TASK' + AND task_id IS NOT NULL + AND claim_generation IS NOT NULL + AND execution_id IS NOT NULL + ) + ) +); + +ALTER TABLE task_events RENAME TO task_events_v3; + +CREATE TABLE task_events ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE, + event_type TEXT NOT NULL + CHECK ( + event_type IN ( + 'TASK_CREATED', + 'TASK_CLAIMED', + 'TASK_RECLAIMED', + 'TASK_RELEASED', + 'TASK_STARTED', + 'TASK_CANCEL_REQUESTED', + 'TASK_CANCELED' + ) + ), + message TEXT NOT NULL, + occurred_at TEXT NOT NULL, + actor_user_id TEXT + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + actor_device_id TEXT + REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT +); + +INSERT INTO task_events ( + id, + task_id, + event_type, + message, + occurred_at, + actor_user_id, + actor_device_id +) +SELECT + id, + task_id, + event_type, + message, + occurred_at, + actor_user_id, + NULL +FROM task_events_v3; + +DROP TABLE task_events_v3; + +CREATE INDEX task_events_task_occurred_idx + ON task_events (task_id, occurred_at ASC, id ASC); + +CREATE INDEX task_events_actor_user_idx + ON task_events (actor_user_id, occurred_at DESC, id DESC); + +CREATE INDEX task_events_actor_device_idx + ON task_events (actor_device_id, occurred_at DESC, id DESC); + +-- +goose Down +CREATE TEMP TABLE task_events_v4_down_guard ( + allowed INTEGER NOT NULL + CHECK (allowed = 1) +); + +INSERT INTO task_events_v4_down_guard (allowed) +SELECT CASE + WHEN EXISTS ( + SELECT 1 + FROM task_events + WHERE event_type NOT IN ('TASK_CREATED', 'TASK_CANCELED') + OR actor_device_id IS NOT NULL + ) + THEN 0 + ELSE 1 +END; + +DROP TABLE task_events_v4_down_guard; + +ALTER TABLE task_events RENAME TO task_events_v4; + +CREATE TABLE task_events ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE, + event_type TEXT NOT NULL + CHECK (event_type IN ('TASK_CREATED', 'TASK_CANCELED')), + message TEXT NOT NULL, + occurred_at TEXT NOT NULL, + actor_user_id TEXT + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT +); + +INSERT INTO task_events ( + id, + task_id, + event_type, + message, + occurred_at, + actor_user_id +) +SELECT + id, + task_id, + event_type, + message, + occurred_at, + actor_user_id +FROM task_events_v4; + +DROP TABLE task_events_v4; + +CREATE INDEX task_events_task_occurred_idx + ON task_events (task_id, occurred_at ASC, id ASC); + +CREATE INDEX task_events_actor_user_idx + ON task_events (actor_user_id, occurred_at DESC, id DESC); + +DROP TABLE lifecycle_requests; +DROP INDEX task_executions_device_heartbeat_idx; +DROP INDEX task_executions_active_task_idx; +DROP TABLE task_executions; +DROP INDEX purchase_tasks_active_device_idx; + +ALTER TABLE purchase_tasks DROP COLUMN cancel_requested_by_user_id; +ALTER TABLE purchase_tasks DROP COLUMN cancel_requested_at; +ALTER TABLE purchase_tasks DROP COLUMN claim_expires_at; +ALTER TABLE purchase_tasks DROP COLUMN claim_issued_at; +ALTER TABLE purchase_tasks DROP COLUMN claim_token_hash; +ALTER TABLE purchase_tasks DROP COLUMN claim_generation; +ALTER TABLE purchase_tasks DROP COLUMN claimed_by_device_id; +ALTER TABLE purchase_tasks DROP COLUMN claimed_by_user_id; + +ALTER TABLE devices DROP COLUMN readiness_reported_at; +ALTER TABLE devices DROP COLUMN pdd_installed; +ALTER TABLE devices DROP COLUMN accessibility_enabled; diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 652c5d8..77de6cb 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -53,8 +53,9 @@ 当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、 动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已 -验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web 和 T-204 最小鉴权 -均已完成。下一步按编号开始 T-205,实现 App 原子领取、租约和任务状态机。 +验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web、T-204 最小鉴权 +和 T-205 原子领取/租约状态机均已完成。下一步按编号开始 T-206,把 Android +`HttpTaskSource`、前台服务和已确认的任务页面接到设备 API。 严格按以下顺序推进: diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 2c2a8b1..0d95f7a 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -15,7 +15,7 @@ ## 二、用户角色 -- **采购管理员**:创建、查看、取消尚未执行的任务并查看执行结果。 +- **采购管理员**:创建、查看、取消未执行任务,或请求执行中任务安全停止,并查看结果。 - **采购执行员**:在 App 上检查设备状态、手动领取、执行和人工确认任务。 - **设备身份**:代表一台获得授权的 Android 设备领取任务和上报状态。 - **系统管理员/审核员**:目标架构角色,MVP 不提供完整管理界面。 @@ -38,7 +38,7 @@ | 功能 | 说明 | 阶段 | | --- | --- | --- | | 完整 RBAC | 采购管理员、执行员、审核员和系统管理员的细粒度权限。 | V2 | -| 多设备调度 | 设备心跳、任务队列、租约回收和容量调度。 | V2 | +| 多设备容量调度 | 在当前原子领取/租约基础上增加优先级、容量、运营监控和跨实例调度。 | V2 | | 后台通知 | WebSocket/厂商推送只通知有任务,App 仍通过 claim 领取。 | V2 | | 订单提交审批 | 在金额、店铺和品类规则内,经人工审批后允许提交订单。 | V2,需单独安全评审 | | 多平台比价 | 淘宝、1688、京东等平台。 | V3 | @@ -50,10 +50,11 @@ 2. 数量必须是正整数;最高商品总预算如填写,必须大于零。总预算表示当前任务全部 数量的商品金额上限,不包含尚无法可靠确认的运费、优惠或支付金额。 3. SKU、数量和最高商品总预算以管理员输入为准,模型不得更改。 -4. 一台设备同一时间最多有一条 `CLAIMED` 或 `RUNNING` 任务。 +4. 一台设备同一时间最多有一条未过期 `CLAIMED` 或任意 + `RUNNING/WAITING_CONFIRMATION` 任务。 5. 一条任务同一时间只能被一台设备持有;重复点击不能产生重复领取。 -6. App 未就绪时不能开始:无障碍未授权、拼多多未安装、设备离线或已有运行任务都 - 必须说明原因。 +6. App 未就绪时不能领取:无障碍未连接、拼多多未安装、设备 heartbeat 过期或已有 + 活跃任务都必须说明原因。 7. 遇到验证码、登录失效、风控提示、页面未知、预算不满足或模型低置信度时停止, 不猜测点击。 8. MVP 的“成功”表示完成验证闭环并得到人工确认的候选结果, @@ -67,7 +68,14 @@ 普通日志;禁用用户或设备后,已有凭证必须立即失效。 13. 管理登录和 App token 登录必须在高成本密码校验前按服务端观察到的来源地址限流; 超限返回通用错误和重试时间,不得泄露账号、角色或设备是否存在。 -14. 管理员创建和取消任务时,任务事件必须记录真实 ADMIN actor;历史事件允许为空。 +14. 管理员创建/取消和 App 状态迁移必须记录真实 user actor;设备操作还必须记录 + device actor。heartbeat 不写高频事件;历史事件允许 actor 为空。 +15. claim token 由 App 在请求前生成并安全保存,必须与幂等 key 独立;数据库只保存 + SHA-256,API 和日志不得回显原值或 hash。 +16. `CLAIMED` 租约过期后可以被原子回收;`RUNNING/WAITING_CONFIRMATION` 租约 + 过期后不得自动回到队列,避免失联设备与新设备重复执行。 +17. 管理取消 `PENDING/CLAIMED` 可立即终态;执行中只设置取消请求,必须等 App 在 + 安全检查点确认停止后才能进入 `CANCELED`。 ## 六、第一层本地样本约定 @@ -110,7 +118,8 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi - F-002/US-002/IX-003:状态变化后管理页面能看到最新阶段、时间、设备、候选摘要 或结构化错误;无权限用户不可访问。 - F-003/US-003/IX-005:App 点击“获取任务”后原子领取一条任务;重复点击或多请求 - 不得领取第二条或把同一任务分配两次。 + 不得领取第二条或把同一任务分配两次。T-205 已完成后端 heartbeat、原子 claim、 + client-generated token 安全重放、参考图授权和租约;Android 接入属于 T-206。 - F-004/US-004/IX-006:解析结果包含搜索词、识别属性、预算、数量、置信度和警告; 原始输入保留,硬约束与输入一致。T-103 已实现版本化 schema、0.75 置信阈值、 冲突转人工以及 SKU/数量的本地确定性回填;当前样本未提供预算,因此预算保持空。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 6009628..2ed79dd 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -27,7 +27,7 @@ | App 鉴权 | BUYER 密码 + 预授权设备 secret + 1 小时 opaque token | T-204 已验证 | 首次原子绑定空闲设备;数据库只存 token SHA-256,不提供自助登记/refresh。 | | VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | T-103/T-104 使用严格 JSON Schema、单候选单次调用和 2048 px 图片上限;GUI-Owl/MAI-UI 动作模型不具备需求提取能力。 | | 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 | -| 后端测试 | 标准库 `testing` + `httptest` | T-204 已验证 | 当前 142 个测试覆盖配置、迁移、图片限制、任务事务、bcrypt、会话/令牌、设备原子绑定、登录限流、角色/CSRF 隔离、HTTP 生命周期和安全响应。 | +| 后端测试 | 标准库 `testing` + `httptest` | T-205 已验证 | 当前 192 个测试覆盖配置、迁移、图片限制、任务事务、鉴权隔离、设备就绪、原子领取、幂等重放、租约状态机、取消确认、跨连接与真实 TCP 并发。 | | Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | Phase 1 探针已验证 | 166 次测试覆盖 runner、动态页面分类、受控证据、VLM schema、人工确认策略与隐私;OnePlus PKG110 上完成私有 fixture + 本机 mock 的需求提取和 5 候选评估 smoke。 | | 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 | @@ -127,6 +127,9 @@ docs/ T-203 已建立 `domain/usecase/repository/sqlite` 和 `transport/webui`,handler 不直接 执行 SQL,Web 与 JSON API 复用同一 usecase。 +T-205 已在该分层上增加独立 `LifecycleService` 与 SQLite immediate transaction, +固定 client-generated claim token、设备 readiness、CLAIMED/运行租约、execution、 +安全取消和 task-scoped 参考图授权;Android HTTP TaskSource 仍由 T-206 接入。 ## 构建与运行命令 @@ -137,7 +140,7 @@ T-203 已建立 `domain/usecase/repository/sqlite` 和 `transport/webui`,handl | Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;当前全工程 166 次测试通过 | | Android 安装/启动 | `$env:RUN_START_COMMAND="1"; .\init.ps1` | 已在 Android 16 真机验证 | | 后端依赖 | `$env:GOTOOLCHAIN="local"; go mod download`(在 `backend-api/`) | 已验证 | -| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go vet ./...` | 已验证;99 个测试 | +| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go test -race ./...; go vet ./...` | 已验证;192 个测试,全包 race 通过 | | 后端构建 | `go build -o bin/cmroubao-api.exe ./cmd/api` 与 `./cmd/migrate` | 已验证 | | 后端启动 | `go run ./cmd/api` | 已完成本机 HTTP smoke | | 数据迁移 | `go run ./cmd/migrate up` | 已完成 `status/up/up/down/up` smoke | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 0b1f67b..8368c06 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -292,10 +292,16 @@ CLAIMED/RUNNING/WAITING_CONFIRMATION 规则: - `SUCCEEDED` 是验证结果成功,不代表已下单;`order_submitted=false`。 -- `CLAIMED` 的默认租约计划为 10 分钟,运行时每 30 秒心跳续租;数值进入配置。 +- `CLAIMED` 默认租约为 10 分钟;`RUNNING/WAITING_CONFIRMATION` 默认租约为 + 90 秒,App 计划每 30 秒 heartbeat 续租。三个时长均由有上下界的环境变量配置。 - 只有当前设备和有效 claim token 能启动、续租、上报或结束任务。 - 终态不可回退;重试创建新的 execution attempt,不篡改历史证据。 -- 取消正在自动化的任务时,App 应在下一个安全检查点停止。 +- App 在 claim 前生成并安全保存 256 bit Raw URL token;后端只存 SHA-256, + token 与 `Idempotency-Key` 相互独立。 +- 过期 `CLAIMED` 可以原子回收;过期 `RUNNING/WAITING_CONFIRMATION` 保持原状态, + 不自动回队列。 +- 管理取消 `PENDING/CLAIMED` 立即终态;执行中只设置停止请求,App 在下一个安全 + 检查点停止并调用 `cancel-ack` 后才进入 `CANCELED`。 ### 4.2 Android 工作流状态 @@ -348,6 +354,9 @@ IDLE | `android_version` | nullable | Android 系统版本 | | `pdd_version` | nullable | 已验证拼多多版本 | | `last_seen_at` | nullable | 最近心跳 | +| `readiness_reported_at` | nullable | 最近一次就绪上报的服务端时间 | +| `accessibility_enabled` | NOT NULL | 无障碍连接就绪位 | +| `pdd_installed` | NOT NULL | 拼多多安装就绪位 | | `is_enabled` | NOT NULL | 后端开关 | 设备必须先由本地管理命令预授权。首次 BUYER 联合登录可以把 `bound_user_id` 为空的 @@ -378,17 +387,28 @@ IDLE | `quantity` | `> 0` | 权威数量 | | `max_budget` | `> 0`, nullable | 全部数量的权威最高商品总预算,币种为 CNY | | `status` | NOT NULL | 任务状态枚举 | -| `created_by` | FK | 创建人 | +| `claimed_by_user_id` | FK, nullable | 当前采购员 | | `claimed_by_device_id` | FK, nullable | 当前设备 | +| `claim_generation` | `>= 0` | 每次领取递增,释放后保留用于审计 | +| `claim_token_hash` | nullable | 64 位小写 SHA-256;永不保存原 token | +| `claim_issued_at` | nullable | 当前 claim 签发时间 | | `claim_expires_at` | nullable | 租约到期时间 | +| `cancel_reason` | nullable | 管理取消原因 | +| `cancel_requested_at` | nullable | 执行中停止请求时间 | +| `cancel_requested_by_user_id` | FK, nullable | 请求停止的 ADMIN | +| `canceled_at` | nullable | 取消终态时间 | | `version` | NOT NULL | 乐观锁/状态并发控制 | | `created_at/updated_at` | NOT NULL | 审计时间 | ### `task_events` -- 事件只追加;T-204 已实现 `TASK_CREATED`、`TASK_CANCELED`。 +- 事件只追加;T-205 已实现 `TASK_CREATED`、`TASK_CLAIMED`、 + `TASK_RECLAIMED`、`TASK_RELEASED`、`TASK_STARTED`、 + `TASK_CANCEL_REQUESTED`、`TASK_CANCELED`。 - `actor_user_id` 是指向 `users` 的可空外键。新管理操作必须写入真实 ADMIN, T-203 历史事件保持为空。 +- `actor_device_id` 是指向 `devices` 的可空外键;App 状态迁移同时记录用户和设备。 +- 设备/任务 heartbeat 不写事件,避免高频审计膨胀。 - 管理任务详情 API 可返回 actor;密码、session、设备 secret 和 access token 永不 进入事件。 @@ -399,17 +419,28 @@ IDLE | `id` | PK | 一次执行尝试 | | `task_id` | FK | 所属任务 | | `attempt_no` | UNIQUE(task, no) | 尝试序号 | +| `claim_generation` | UNIQUE(task, generation) | 对应的 claim 代次 | | `device_id/user_id` | FK | 执行设备和人员 | -| `extracted_requirements` | JSON | 已校验的模型结果 | -| `candidate_result` | JSON, nullable | 候选及匹配理由 | -| `outcome` | nullable | `CANDIDATE_ACCEPTED`、`CANDIDATE_REJECTED`、`NO_MATCH` 或 `MANUAL_REQUIRED` | +| `current_step` | 1-64 bytes | 最近 heartbeat 的执行步骤 | +| `last_heartbeat_at` | NOT NULL | 最近运行 heartbeat | | `order_submitted` | NOT NULL, false | MVP 数据库约束必须为 false | -| `error_code/error_message` | nullable | 结构化失败 | -| `started_at/finished_at` | nullable | 执行耗时 | +| `started_at/finished_at` | started 必填 | 执行耗时和安全结束 | -### `execution_events` 与 `assets` +候选、模型派生结果、outcome、结构化失败和 execution evidence 属于 T-207,不在 +T-205 的最小 execution 表中提前伪造。 -- `execution_events` 只追加,记录 step、事件类型、可读消息和时间;不保存密码或 token。 +### `lifecycle_requests` + +- 主键为 `user_id + device_id + operation + idempotency_key`。 +- operation 为 `CLAIM_NEXT`、`START`、`RELEASE`、`CANCEL_ACK`;保存请求 SHA-256 + 与 task/generation/execution 结果引用。 +- `NO_TASK` 也是稳定结果;同 key 重放不会因后来新增任务而改变。 +- claim 原 token 不进入该表,请求 hash 只包含 token hash。 + +### `assets` 与后续 `execution_events` + +- T-207 的 `execution_events` 只追加,记录 step、事件类型、可读消息和时间;不保存 + 密码或 token。 - `assets` 保存文件相对路径、媒体类型、大小、哈希、创建者和保留时间。 - 原图和截图必须通过鉴权接口读取,文件名不能直接作为公开 URL。 - 任务参考图上传可接受 JPEG/PNG/WebP,但后端必须先真实解码、限制字节与像素,再 @@ -419,10 +450,16 @@ IDLE - 合约以 [`api.md`](api.md) 为准。 - `claim-next` 必须由 usecase 调用 repository,在一个数据库事务内完成 - “选取 + 校验设备空闲 + 更新状态”。 -- 创建、领取和完成接口支持 `Idempotency-Key`。 + “幂等检查 + 就绪/单活校验 + FIFO 选取/过期回收 + 更新 + 事件”。 +- 当前设备若有自己的过期 `CLAIMED`,优先原子回收该任务;没有时才在全局候选中 + 按 `created_at ASC, id ASC` 选择。该例外保持设备唯一归属并避免静默清理审计状态。 +- SQLite DSN 使用 `_txlock=immediate`、WAL 和 busy timeout;跨两个独立数据库连接 + 的并发测试必须精确得到一个领取者。 +- claim/start/release/cancel-ack 支持 `Idempotency-Key`;heartbeat 不需要幂等表。 - App 对网络超时不能假定失败;必须用任务详情确认服务端最终状态。 - 后端拒绝非法状态迁移,即使客户端 UI 隐藏了对应按钮。 +- App 参考图通过 task-scoped URL 读取;每次校验当前 user/device/generation/token/ + 租约,不允许用可猜测 asset ID 越权读取其他任务。 - SQLite MVP 使用单后端进程;切换多实例前先迁移 PostgreSQL 并验证领取竞争。 - Gin handler 只做鉴权上下文、binding、调用 usecase 和响应映射。 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index 6cb6fe1..fc88185 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -79,7 +79,12 @@ - 请求在边界完成类型、大小、媒体类型和业务规则校验。 - 领取任务必须使用事务;不得先 GET 再由客户端自行标记占用。 - 状态变化校验当前状态、设备归属、claim token、版本和租约。 -- 创建、领取、完成和证据上传的重试路径必须幂等。 +- App 必须在 claim 请求前生成并安全保存独立的 256 bit claim token 与幂等 key; + 后端只存 token SHA-256,响应、事件和日志不得返回原值或 hash。 +- claim/start/release/cancel-ack 和后续完成/证据上传的重试路径必须幂等;`NO_TASK` + 也必须是稳定幂等结果。 +- 设备/任务 heartbeat 使用服务端时钟且不写高频事件;过期 RUNNING 不得自动回到 + 队列。管理取消执行中任务必须等 App 安全确认,不能提前伪装为终态。 - 不向客户端返回 `password_hash`、`token_hash`、存储绝对路径或供应商密钥。 - 管理 Cookie 与 BUYER 设备 Bearer token 使用独立 middleware,不得互相替代; 每次鉴权都重新检查用户/设备启用、绑定、撤销和过期状态。 @@ -92,7 +97,8 @@ - 创建和取消任务必须从认证上下文传入真实 ADMIN actor 并写入只追加事件;共享 `local-admin` 只用于 MVP 资源可见范围,不能冒充操作者。 - 通用错误使用稳定 code 和可读 message;内部堆栈只进受控日志。 -- 文件访问通过鉴权接口,防止路径遍历和猜测 URL。 +- 文件访问通过鉴权接口,防止路径遍历和猜测 URL;App 参考图读取必须重新校验当前 + task/user/device/generation/token/租约,不能只凭 asset UUID 授权。 - Go 命令固定 `GOTOOLCHAIN=local`;`go.mod` 不得出现更高 Go 版本或未固定的 `@latest` 依赖。 - 后端不得自动加载 `.env`、默认开启 CORS、使用包级数据库单例,或在库、handler、 diff --git a/docs/07-user-stories.md b/docs/07-user-stories.md index 3c7b820..e5249ac 100644 --- a/docs/07-user-stories.md +++ b/docs/07-user-stories.md @@ -72,7 +72,9 @@ 1. 设备就绪且有待处理任务时,点击后只出现一条已领取任务。 2. 没有任务时显示空状态,不把空队列当作错误。 3. 无障碍缺失、拼多多不可用、设备禁用或已有活跃任务时,说明原因并禁止领取。 -4. 网络超时或重复点击后,刷新服务端状态不会产生重复领取。 +4. 网络超时或重复点击后,复用请求前已保存的 claim token 和幂等 key 可确认同一 + 领取结果,不产生重复领取。 +5. 只有持有当前 claim 的用户和设备能读取该任务的参考图、开始或续租。 ## US-004 自动检索并得到候选 @@ -131,7 +133,8 @@ 1. 验证码、风控、登录失效、未知页面和安全边界分别产生可区分错误。 2. 网络中断时保留本地待上传证据,恢复后用幂等方式补报。 -3. 用户取消时工作流在安全检查点停止,任务不会继续在后台点击。 +3. 用户或管理员取消时,执行中任务先收到停止请求;App 在安全检查点停止并确认后 + 才进入取消终态,不会继续在后台点击。 4. 重新打开终态任务可以看到相同结果和证据。 ## US-007 建立受控会话和设备身份 diff --git a/docs/08-interaction-checklist.md b/docs/08-interaction-checklist.md index 1601ef0..3cc63d1 100644 --- a/docs/08-interaction-checklist.md +++ b/docs/08-interaction-checklist.md @@ -150,7 +150,8 @@ - 无任务:显示安静的空状态和手动刷新,不循环弹错。 - 权限缺失:提供打开对应系统设置的命令。 - 已有任务:显示“继续任务”,不再领取。 -- 请求超时:先查询当前设备活跃任务,再允许重试。 +- 请求超时:保留请求前已安全保存的 claim token/幂等 key,先用同一请求重放并通过 + heartbeat 对比服务端活跃任务,再允许生成新请求。 - 租约到期:预览页提示任务已释放,返回任务页。 **可访问性** diff --git a/docs/api.md b/docs/api.md index e37dc0a..0d4f357 100644 --- a/docs/api.md +++ b/docs/api.md @@ -254,8 +254,10 @@ T-204 已实现的 `TASK_CREATED`、`TASK_CANCELED` 事件包含可空 } ``` -T-203 只实现 `PENDING -> CANCELED`;其他状态返回 `409`。执行中设置取消请求及 App -安全检查点响应属于 T-205。 +`PENDING/CLAIMED` 立即进入 `CANCELED`。`RUNNING/WAITING_CONFIRMATION` 只记录 +取消请求并保持原状态;重复请求不重复写事件。App 的任务 heartbeat 会返回 +`cancel_requested=true`,只有 App 在安全检查点调用 `cancel-ack` 后才进入 +`CANCELED` 并结束 execution。 ## 设备与领取 @@ -265,8 +267,8 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专 ```json { - "device_id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8", "app_version": "0.1.0", + "android_version": "16", "pdd_version": "device-observed-value", "readiness": { "accessibility_enabled": true, @@ -276,14 +278,20 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专 } ``` +`device_id` 可以省略;若提供,必须与 Bearer token 中的设备一致。服务端时间是唯一 +租约时钟。响应返回服务端认定的 `active_task_id`、`client_state_matches`、 +readiness 上报时间和 `server_time`。heartbeat 超过配置 TTL 或任一就绪位为 false +时,claim 返回 `409 DEVICE_NOT_READY`。 + ### `POST /api/v1/tasks/claim-next` -由用户点击触发,在单个数据库事务中领取下一条任务。必须带 `Idempotency-Key`。 +由用户点击触发,在单个 SQLite immediate transaction 中领取下一条任务。App 必须 +在请求前生成并安全保存相互独立的 256 bit Raw URL `X-Claim-Token` 和 +`Idempotency-Key`;网络结果不确定时复用同一组值。服务端只保存 token 的 +SHA-256,响应和日志都不回显原 token 或 hash。 ```json -{ - "device_id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8" -} +{} ``` 有任务时: @@ -297,22 +305,50 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专 "sku": "BLACK-20L", "description": "容量约20L,外观接近参考图", "image_asset_id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3", + "reference_image_url": "/api/v1/tasks/37c9c715-9b51-4ed5-984e-66dad2710c71/reference-image?claim_generation=1", "quantity": 2, - "max_budget": "200.00" + "max_budget": "200.00", + "currency": "CNY", + "version": 2, + "claim_generation": 1, + "claim_issued_at": "2026-07-25T08:30:00Z", + "claim_expires_at": "2026-07-25T08:40:00Z" }, - "claim_token": "returned-once", - "claim_expires_at": "2026-07-25T08:40:00Z" + "replayed": false, + "server_time": "2026-07-25T08:30:00Z" } ``` -没有任务返回 `204`。设备未就绪或已有活跃任务返回 `409`。幂等重放返回同一领取 -结果;claim token 的安全重放形式在实现前通过测试固定。 +当前设备自己有已过期 `CLAIMED` 时优先回收该任务,避免设备唯一归属冲突;否则按 +`created_at ASC, id ASC` 选择 `PENDING` 或已过期 `CLAIMED`。没有任务返回 `204`; +同 key 的无任务重放始终保持 `204`。同 key、同 token 的活跃 claim 重放返回同一 +任务,即使状态已进入 `RUNNING/WAITING_CONFIRMATION`;请求不同返回 +`409 IDEMPOTENCY_CONFLICT`;原 claim 已释放、取消或被其他领取回收时返回 +`409 CLAIM_REPLAY_EXPIRED`。同一设备不能再领取第二条活跃任务。 + +### `GET /api/v1/tasks/{task_id}/reference-image` + +claim 响应给出的受保护参考图地址。请求使用 BUYER Bearer token、 +`X-Claim-Token` 和 URL 中的 `claim_generation`;只有当前用户、当前设备、匹配 +generation/token 且租约未过期的活跃任务可以读取。成功返回匿名规范化 JPEG, +设置 `private, no-store`、`nosniff`、长度和 ETag,不返回存储路径。 ### `POST /api/v1/tasks/{task_id}/start` 把当前设备持有的 `CLAIMED` 任务改为 `RUNNING` 并创建 execution。请求带 `X-Claim-Token` 和 `Idempotency-Key`。 +```json +{ + "claim_generation": 1, + "expected_version": 2 +} +``` + +同 key、同请求重放返回同一 execution;错误用户/设备/token 返回 `403`,过期租约、 +状态或版本冲突返回 `409`。成功响应包含更新后的 `task`、`execution`、`replayed` +和 `server_time`,运行租约使用配置的 running lease。 + ### `POST /api/v1/tasks/{task_id}/heartbeat` 运行时续租并返回是否请求取消: @@ -320,21 +356,56 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专 ```json { "execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f", - "step": "SCAN_RESULTS", - "client_time": "2026-07-25T08:33:30Z" + "claim_generation": 1, + "step": "SCAN_RESULTS" } ``` ```json { - "claim_expires_at": "2026-07-25T08:43:30Z", - "cancel_requested": false + "task": { + "id": "37c9c715-9b51-4ed5-984e-66dad2710c71", + "status": "RUNNING", + "version": 4, + "claim_generation": 1, + "claim_expires_at": "2026-07-25T08:35:00Z" + }, + "execution": { + "id": "e3190742-a24b-441e-b5c1-c7ed10ed342f", + "current_step": "SCAN_RESULTS", + "order_submitted": false + }, + "cancel_requested": false, + "server_time": "2026-07-25T08:33:30Z" } ``` +只接受 1 至 64 字节的大写 ASCII step。heartbeat 使用服务端 UTC 更新 execution、 +设备最近在线时间、任务 version 和租约,不写高频任务事件;过期运行租约拒绝续租, +任务保持原非终态且绝不回到领取队列。 + ### `POST /api/v1/tasks/{task_id}/release` 只允许尚未开始的 `CLAIMED` 任务释放回 `PENDING`。运行中使用取消/失败流程。 +请求带 `X-Claim-Token`、`Idempotency-Key`,body 与 start 相同。成功后清除当前 +用户、设备、token hash 和租约,但保留递增过的 `claim_generation` 供审计。 + +### `POST /api/v1/tasks/{task_id}/cancel-ack` + +App 收到取消请求并在安全检查点停止后调用: + +```json +{ + "execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f", + "claim_generation": 1, + "expected_version": 5 +} +``` + +请求带 `X-Claim-Token` 和 `Idempotency-Key`。只有匹配的未结束 execution、有效 +运行租约和已存在的管理取消请求可以确认;成功把任务置为 `CANCELED`、结束 +execution、清除 claim 秘密并追加带用户/设备 actor 的事件。同 key 重放不产生第二 +个事件。 ## AI 合约 @@ -488,7 +559,5 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专 ## 待实现前固定 - 上传大小、像素和保留期限的具体数值。 -- access token、管理会话和 claim lease 的最终时长。 -- claim token 幂等重放与轮换细节。 - VLM `confidence` 阈值、模型和提示词版本记录格式。 - 外部管理后台的服务账号认证方式和调用频率。 diff --git a/docs/current-state.md b/docs/current-state.md index 5b5071a..020ee0f 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,30 +5,36 @@ ## 当前快照 - 日期:2026-07-26 -- 阶段:T-204 用户与设备最小鉴权完成,下一步 T-205 +- 阶段:T-205 原子领取、租约和状态机完成,下一步 T-206 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-203 - 均已纳入 Git 历史;T-204 与本文同次提交 + 均已纳入 Git 历史;T-204 已提交,T-205 与本文同次提交 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 - 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、 - SSR 管理 Web、ADMIN 会话、BUYER/预授权设备联合认证和 `authctl` + SSR 管理 Web、ADMIN/BUYER 联合认证、设备 readiness、原子 claim、租约状态机、 + task-scoped 参考图和 `authctl` - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器 共 26 份报告、166 次测试,0 failure、0 error、0 skipped -- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 142 个测试通过; +- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 192 个测试通过; 全包 race、`go vet ./...`、API/migration/authctl Windows 构建和根 `init.ps1` 均通过 - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 脚本错误或外部请求,Android 可见交互控件均不小于 44px - 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和 - 待领取取消;ADMIN 登录/退出和安全返回路径已接入,同三种视口无横向溢出, - 可见操作控件不小于 44px + 非终态取消;执行中只显示“请求安全停止”,ADMIN 登录/退出和安全返回路径已接入, + 同三种视口无横向溢出,可见任务操作控件不小于 44px - 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret 均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App 登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停 +- 生命周期:设备 heartbeat 记录三个版本、两个就绪位和服务端活跃任务;claim 使用 + client-generated 256 bit token + 独立幂等 key、SQLite immediate transaction、 + 10 分钟 CLAIMED/90 秒运行租约和 2 分钟 readiness TTL。start/heartbeat/release/ + cancel-ack、过期回收、跨连接并发唯一领取、管理安全停止与 task-scoped 参考图均 + 已实现;原 claim token/hash 不进入响应或日志 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture @@ -65,6 +71,7 @@ | `docs/tasks/T-202.md` | DONE | 生成并确认 P0 Web/App 低保真原型 | | `docs/tasks/T-203.md` | DONE | 图片/任务 API、SQLite 业务层和 SSR 管理 Web | | `docs/tasks/T-204.md` | DONE | 用户、管理会话和预授权设备联合身份 | +| `docs/tasks/T-205.md` | DONE | 原子 claim、租约、execution 和取消安全确认 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -75,9 +82,9 @@ ## 任务摘要 -- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-204。 +- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-205。 - 正在进行:无。 -- 下一个可领取任务:T-205 原子领取、租约和状态机。 +- 下一个可领取任务:T-206 App 接入手动领取和执行进度。 ## 当前可运行内容 @@ -132,6 +139,15 @@ TLS。真实 SQLite/Gin smoke 验证 migration up/down/up、账号/设备预置 验证无横向溢出,发现并修复退出路由漏装配及 T-203 旧 CSRF Cookie 路径兼容问题。 142 个 Go 测试、全包 race/vet、Windows 三入口构建与根 `init.ps1` 均通过。 +2026-07-26 完成 T-205:新增设备 readiness、claim/execution/lifecycle migration, +实现 client-generated claim token 安全重放、跨连接原子领取、CLAIMED/运行租约、 +start/heartbeat/release、管理取消请求、App cancel-ack 和当前 claim 参考图授权。 +真实 TCP 并发测试验证同设备同时 claim 只有一个成功;迁移 CLI 完成 +`status/up/up/down/up`。Playwright 连接真实服务验证管理任务详情在 1440×900、 +390×844、360×800 无横向溢出、破图或取消弹窗重叠;执行中安全停止文案由 SSR +测试覆盖。192 个 Go 测试、全包 race/vet、Windows 三入口构建和根 `init.ps1` +全部通过。 + ## 维护规则 发生以下变化时覆盖更新本文: diff --git a/docs/routes.md b/docs/routes.md index 3077a39..fa46c2a 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -30,6 +30,23 @@ Android 导航名称是逻辑目的地,具体 Compose/Fragment 形式待接入 | `task/{id}/result` | 任务结果 | 查看终态和同步状态 | US-002、US-006 | IX-007、IX-008 | | `settings` | 设备设置 | 查看权限、版本、后端和模型连接状态 | US-003、US-007 | IX-004、IX-005 | +## App 后端路由 + +以下路由只接受有效 BUYER Bearer token,认证 principal 中的用户和设备是权威身份: + +| 路由 | 职责 | +| --- | --- | +| `POST /api/v1/devices/heartbeat` | 上报版本、就绪位并核对服务端活跃任务 | +| `POST /api/v1/tasks/claim-next` | 用户点击后原子领取或重放领取结果 | +| `GET /api/v1/tasks/{id}/reference-image` | 用当前 claim 读取该任务匿名参考图 | +| `POST /api/v1/tasks/{id}/start` | `CLAIMED -> RUNNING` 并创建 execution | +| `POST /api/v1/tasks/{id}/heartbeat` | 更新 step、设备在线时间和运行租约 | +| `POST /api/v1/tasks/{id}/release` | 未开始时 `CLAIMED -> PENDING` | +| `POST /api/v1/tasks/{id}/cancel-ack` | 安全停止后确认 `CANCELED` 并结束 execution | + +claim/start/release/cancel-ack 使用相应幂等规则;参考图、start、task heartbeat、 +release 和 cancel-ack 都必须匹配当前 `X-Claim-Token` 与 `claim_generation`。 + ## 导航规则 - App 有活跃任务时启动后优先恢复该任务,不允许直接领取下一条。 diff --git a/docs/tasks/T-205.md b/docs/tasks/T-205.md new file mode 100644 index 0000000..9792360 --- /dev/null +++ b/docs/tasks/T-205.md @@ -0,0 +1,151 @@ +--- +id: T-205 +title: 实现原子 claim、租约和状态机 +phase: 2 +deps: + - T-203 + - T-204 +status: DONE +created: 2026-07-26 +context_ref: 49db5b8 +work_branch: main +write_paths: + - README.md + - backend-api/** + - docs/00-ai-start-here.md + - docs/02-requirements.md + - docs/03-tech-stack.md + - docs/04-architecture.md + - docs/05-coding-rules.md + - docs/07-user-stories.md + - docs/08-interaction-checklist.md + - docs/api.md + - docs/current-state.md + - docs/routes.md + - docs/tasks/T-205.md + - progress.md +--- + +## 问题 / 背景 + +T-203 已能创建 `PENDING` 任务,T-204 已建立 BUYER + 预授权设备联合身份,但 App +还不能安全领取任务。现有正式文档只给出接口草图,未解决 claim token 只返回一次时 +如何在响应丢失后安全重放,也没有固定设备就绪新鲜度、运行中租约过期、取消确认和 +并发领取的精确语义。T-206 在这些合约固定前不能接入 HTTP TaskSource。 + +## 关联需求与交互 + +- 功能:F-003、F-007。 +- 用户故事:US-003、US-006。 +- 交互:IX-005、IX-008;本任务只实现后端,Android 页面接入属于 T-206。 +- 架构/API:任务状态机、`devices/heartbeat`、`claim-next`、`start`、任务 heartbeat、 + `reference-image`、`release` 和取消安全停止。 + +## 已定合约 + +1. 所有设备接口只接受有效 BUYER Bearer token;认证上下文中的 `user_id/device_id` + 是权威身份。请求中的 `device_id` 必须与上下文一致,不能代表其他设备。 +2. App 在发起 claim 前生成独立的 256 bit Raw URL `X-Claim-Token` 并先安全保存, + 同时生成独立 `Idempotency-Key`。服务只保存 claim token 的 SHA-256,响应不回显 + 原值;网络结果不确定时必须复用同一组值。 +3. claim 幂等记录按用户、设备、操作和 key 隔离,请求 hash 包含 claim token hash。 + 每次成功领取递增 `claim_generation`。同 key 同请求在同一 generation 的租约仍 + 属于该设备时返回同一任务;同 key 不同请求返回 `409`;原领取已释放、取消或被 + 过期回收时返回稳定 `409 CLAIM_REPLAY_EXPIRED`。无任务的首次结果记录为 + `NO_TASK`,同 key 重放仍返回 `204`。 +4. 设备 heartbeat 记录服务端时间、App/Android/拼多多版本和两个确定性就绪位: + 无障碍已连接、拼多多已安装。客户端上报的活跃任务只用于一致性检查,服务端任务 + 归属是权威。heartbeat 超过 2 分钟或任一就绪位为 false 时禁止领取。 +5. 领取在单个 SQLite immediate transaction 内完成。若当前设备自己仍有一条已过期 + `CLAIMED`,先回收该任务,避免设备唯一归属冲突;否则在 `PENDING` 或已过期 + `CLAIMED` 中按创建时间最早、ID 最小选择。一个任务只能归属一台设备;同一设备 + 只允许一个未过期 `CLAIMED` 或任意 `RUNNING/WAITING_CONFIRMATION` 任务。 +6. 初始 claim 租约默认 10 分钟。只有当前用户、当前设备、匹配 token hash 且租约 + 未过期时可以 start、release、heartbeat 或确认取消。所有到期计算只使用服务端 + UTC 时钟,不信任 `client_time`。 +7. `start` 只允许 `CLAIMED -> RUNNING`,在同一事务创建唯一 execution attempt, + 并把运行租约改为默认 90 秒;请求携带 claim 响应的 `expected_version`, + `Idempotency-Key` 同请求重放返回同一 execution。 +8. 运行 heartbeat 每 30 秒计划调用,只有 `RUNNING/WAITING_CONFIRMATION` 且 + execution/generation 匹配时才更新 step、`last_seen_at` 和 90 秒运行租约。 + 过期运行租约拒绝续租和后续自动操作,任务保持当前非终态且绝不自动回到队列, + 防止失联设备与新设备重复执行;恢复/失败归档后置。 +9. `release` 只允许未过期 `CLAIMED -> PENDING`,请求必须匹配 `expected_version`, + 并清除用户、设备、token 和租约。已开始任务不能 release。过期 `CLAIMED` 可由 + 下一次 claim 原子回收。 +10. 管理取消 `PENDING/CLAIMED` 时立即进入 `CANCELED`;`RUNNING` 或 + `WAITING_CONFIRMATION` 只设置取消请求,不伪装为已停止。任务 heartbeat 返回 + `cancel_requested=true`,App 在安全检查点以 `expected_version` 调用 + `cancel-ack` 后才进入 `CANCELED` 并结束 execution。 +11. 每次状态迁移递增 `version` 并追加带 actor 的任务事件;heartbeat 只更新租约和 + 当前 step,不追加高频事件。claim/start/heartbeat/transition 响应都返回当前 + `version`、`claim_generation`、服务端时间和租约到期时间。终态不可回退,非法 + 状态、错误设备、错误 token、execution 不匹配和过期租约使用彼此稳定但不泄露 + 其他任务内容的错误码。 +12. 三个时长通过配置提供安全默认值和上下界:claim lease 10 分钟、运行 lease + 90 秒、设备 heartbeat TTL 2 分钟。T-205 不启动后台定时清理器。 +13. 参考图不暴露资产存储路径。App 只能通过 claim 响应中的 task-scoped URL, + 携带当前 BUYER token、设备身份、`X-Claim-Token` 和 `claim_generation` 读取; + 服务端同时校验当前状态和未过期租约,并返回 `private, no-store` JPEG。 + +## 方案 + +1. 新增 `00004_claims_and_lifecycle.sql`,扩展任务 claim/cancel 字段、设备就绪字段, + 增加 execution 与生命周期幂等表,并扩展任务事件枚举;迁移必须可空兼容历史数据。 +2. 在 domain 增加 claim、execution、状态迁移和就绪实体;在独立 + `LifecycleService` 中完成输入校验、token hash、服务端时钟、错误映射和响应模型。 +3. SQLite repository 使用显式事务完成 claim/reclaim、start、release、heartbeat、 + 取消请求/确认和幂等重放;所有条件更新同时校验状态、归属、token、租约和版本。 +4. Gin 增加独立 BUYER 设备路由组,复用 T-204 Bearer middleware;handler 只解析 + header/body、读取认证 principal、调用 usecase 和映射稳定响应。 +5. 管理取消从仅 PENDING 扩展为上述安全语义;管理详情返回 claim、execution 和 + 取消请求的非秘密摘要,永不返回 claim token/hash。 +6. claim 响应只给出 task-scoped 参考图 URL;图片 handler 在读取本地资产前重新 + 授权当前 claim,避免长期资产 URL 或跨任务枚举。 + +## 验收要点 + +- [x] migration 可 up/down/up,历史任务保持可读,数据库不存在原始 claim token。 +- [x] heartbeat 身份、版本长度、就绪位和新鲜度校验正确,客户端不能冒充设备。 +- [x] 多 goroutine/多连接并发 claim 同一任务时精确一个成功,不重复分配。 +- [x] 同一设备不能领取第二条活跃任务;不同设备可以领取不同任务。 +- [x] claim 首次响应丢失后用同 key/token 重放得到同一任务;冲突/过期重放被拒绝。 +- [x] 无任务的幂等重放保持 204,不因稍后新增任务改变旧请求结果。 +- [x] start 精确创建一个 execution;重复 start 同 key 返回同一 execution。 +- [x] 错误设备、错误 token、过期 CLAIMED 租约、非法状态均不能 start/release。 +- [x] 当前 claim 才能读取参考图,错误 token/generation/设备或过期租约均被拒绝。 +- [x] RUNNING heartbeat 续租并返回取消标志;过期运行租约不会回队列或被其他设备领取。 +- [x] 管理取消活跃任务只请求停止;App 安全确认后才进入 CANCELED。 +- [x] 事件 actor、version、execution attempt 和状态迁移可审计,响应/日志不泄露秘密。 +- [x] Go test/race/vet/gofmt、迁移/HTTP 并发 smoke、根脚本全部通过。 + +## 边界 + +- 不实现 Android HTTP TaskSource、token 安全存储、前台服务或页面;属于 T-206。 +- 不实现候选、执行证据、批量事件、完成/失败结果归档;属于 T-207。 +- 不实现后台通知、WebSocket、跨实例锁、自动重试或多设备调度运营页面。 +- 不把过期 `RUNNING` 自动回到 `PENDING`,也不允许任何路径提交订单或支付。 +- 不提交运行数据库、claim/access token、真实设备心跳或私有订单样本。 + +## 执行记录 + +### 2026-07-26:任务开始 + +- 基于提交 `49db5b8` 开始,工作区干净;T-203/T-204 依赖均已完成。 +- codebase-memory MCP 本轮仍未暴露 graph 工具,按项目规则回退到 `rg` 和定点读取。 +- 先固定 client-generated claim token、安全幂等重放、就绪 TTL、租约过期与取消确认 + 语义,避免在迁移和 handler 写完后再改变跨端协议。 + +### 2026-07-26:实现与验证完成 + +- 新增 migration 00004、生命周期领域/用例/SQLite 仓储和 BUYER Gin 路由;管理端 + 取消扩展为 `PENDING/CLAIMED` 立即取消、`RUNNING/WAITING_CONFIRMATION` + 请求安全停止,并在任务详情展示非秘密 claim/execution 摘要。 +- `go test -count=1 ./...` 共 192 个测试通过;全包 `go test -race -count=1 ./...`、 + `go vet ./...`、`gofmt -d .`、三入口 Windows 构建和根 `init.ps1` 均通过。 +- migration CLI 完成 `status -> up -> up -> down -> up`,第二次 up 无重复应用; + 两个独立数据库连接和真实 TCP 并发 claim 都验证同设备精确一个成功。 +- Playwright 连接真实 Gin/SQLite 流程验证任务详情在 1440x900、390x844、360x800 + 无横向溢出、破图或弹窗重叠;运行中“请求安全停止”分支由 SSR 测试覆盖。 +- 独立审查发现并修复 start 后 claim 幂等重放被误判过期的问题;同设备优先恢复 + 自己过期 CLAIMED 的亲和策略已补充文档和测试。未发现 High 严重度问题。 diff --git a/progress.md b/progress.md index db2cf11..e3fa9cf 100644 --- a/progress.md +++ b/progress.md @@ -137,3 +137,12 @@ - 影响:匿名与跨端凭证不能访问任务,新任务保留共享 `local-admin` scope 并单独记录 真实 ADMIN actor,创建/取消事件均可审计;T-205 可在稳定 BUYER + device 身份上 实现原子领取和租约。 + +## 2026-07-26 原子领取、租约和状态机 + +- 类型:阶段完成 +- 内容:完成 T-205;实现设备 readiness、client-generated claim token、SQLite + 原子领取、CLAIMED/运行租约、execution、task-scoped 参考图、管理安全停止和 + App 取消确认。 +- 影响:后端已具备 Android 手动领取和执行进度所需的稳定协议;T-206 可接入 + HTTP TaskSource、凭证/claim token 安全存储和前台执行界面。