feat(tasks): implement atomic claims and leases
This commit is contained in:
+11
-1
@@ -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)。
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
¬Null,
|
||||
&defaultValue,
|
||||
&primaryKey,
|
||||
); err != nil {
|
||||
t.Fatalf("scan table_info(%s): %v", table, err)
|
||||
}
|
||||
if name == column {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate table_info(%s): %v", table, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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}$`,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -82,7 +82,7 @@ type TaskRepository interface {
|
||||
string,
|
||||
string,
|
||||
) (domain.TaskDetail, error)
|
||||
CancelPendingTask(
|
||||
CancelTask(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user