feat(tasks): implement atomic claims and leases

This commit is contained in:
QiuSW
2026-07-26 16:16:36 +08:00
parent 49db5b8305
commit ce875af889
50 changed files with 7589 additions and 207 deletions
+11 -1
View File
@@ -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)。
+24 -2
View File
@@ -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)
},
+3
View File
@@ -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)
+64
View File
@@ -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 ""
@@ -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 {
+14 -11
View File
@@ -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 {
@@ -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)
}
})
}
}
+89 -27
View File
@@ -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 {
+10 -6
View File
@@ -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)
}
}
}
@@ -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,
&notNull,
&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
}
@@ -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,
})
}
@@ -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
@@ -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)
}
}
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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,
@@ -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),
@@ -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)
}
@@ -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 {
@@ -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,
}
}
File diff suppressed because it is too large Load Diff
@@ -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,
@@ -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}$`,
)
+35 -17
View File
@@ -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
}
}
@@ -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
@@ -76,26 +76,40 @@
{{if .Task.CanCancel}}
<noscript>
<section class="noscript-cancel" aria-labelledby="noscript-cancel-heading">
{{if .Task.CancelRequiresAck}}
<h2 id="noscript-cancel-heading">请求安全停止任务</h2>
<p>设备确认安全停止前,任务仍保持当前执行状态。</p>
{{else}}
<h2 id="noscript-cancel-heading">确认取消任务</h2>
<p>取消后任务不会自动恢复。</p>
{{end}}
<form method="post" action="/tasks/{{pathPart .Task.ID}}/cancel">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="cancel_key" value="{{.CancelKey}}">
<button class="button danger" type="submit">确认取消</button>
<button class="button danger" type="submit">
{{if .Task.CancelRequiresAck}}确认请求停止{{else}}确认取消{{end}}
</button>
</form>
</section>
</noscript>
<dialog class="confirm-dialog" data-cancel-dialog aria-labelledby="cancel-title">
{{if .Task.CancelRequiresAck}}
<h2 id="cancel-title">请求安全停止任务?</h2>
<p>系统将通知采购 App 在安全检查点停止;设备确认前任务仍保持当前执行状态。</p>
{{else}}
<h2 id="cancel-title">确认取消任务?</h2>
<p>任务取消后不会自动恢复,采购执行员将不能再领取。</p>
{{end}}
<div class="dialog-actions">
<button class="button" type="button" data-keep-task>保留任务</button>
<form method="post" action="/tasks/{{pathPart .Task.ID}}/cancel" data-submit-form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="cancel_key" value="{{.CancelKey}}">
<button class="button danger" type="submit" data-submit-button
data-loading-label="正在取消…">确认取消</button>
data-loading-label="{{if .Task.CancelRequiresAck}}正在请求停止…{{else}}正在取消…{{end}}">
{{if .Task.CancelRequiresAck}}确认请求停止{{else}}确认取消{{end}}
</button>
</form>
</div>
</dialog>
@@ -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
}
@@ -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,
@@ -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)
}
}
@@ -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)
}
@@ -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,
)
}
@@ -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)
+1 -1
View File
@@ -82,7 +82,7 @@ type TaskRepository interface {
string,
string,
) (domain.TaskDetail, error)
CancelPendingTask(
CancelTask(
context.Context,
string,
string,
+1 -1
View File
@@ -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,
@@ -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,
@@ -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;