2026-07-26 16:16:36 +08:00
|
|
|
package sqlite
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"database/sql"
|
|
|
|
|
"errors"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"cmroubao/backend-api/internal/domain"
|
|
|
|
|
"cmroubao/backend-api/internal/usecase"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
lifecycleOperationClaimNext = "CLAIM_NEXT"
|
|
|
|
|
lifecycleOperationStart = "START"
|
|
|
|
|
lifecycleOperationRelease = "RELEASE"
|
|
|
|
|
lifecycleOperationCancelAck = "CANCEL_ACK"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type lifecycleRequestRecord struct {
|
|
|
|
|
RequestHash string
|
|
|
|
|
ResultKind string
|
|
|
|
|
TaskID *string
|
|
|
|
|
ClaimGeneration *int64
|
|
|
|
|
ExecutionID *string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Store) RecordDeviceHeartbeat(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
update usecase.DeviceHeartbeatUpdate,
|
|
|
|
|
) (usecase.DeviceHeartbeatRecord, error) {
|
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.DeviceHeartbeatRecord{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
|
|
|
|
|
|
device, err := getDeviceByID(ctx, tx, update.DeviceID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, usecase.ErrRepositoryNotFound) {
|
|
|
|
|
return usecase.DeviceHeartbeatRecord{}, usecase.ErrClaimInvalid
|
|
|
|
|
}
|
|
|
|
|
return usecase.DeviceHeartbeatRecord{}, err
|
|
|
|
|
}
|
|
|
|
|
if !device.IsEnabled ||
|
|
|
|
|
device.BoundUserID == nil ||
|
|
|
|
|
*device.BoundUserID != update.UserID {
|
|
|
|
|
return usecase.DeviceHeartbeatRecord{}, usecase.ErrClaimInvalid
|
|
|
|
|
}
|
|
|
|
|
_, err = tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`UPDATE devices
|
|
|
|
|
SET app_version = ?,
|
|
|
|
|
android_version = ?,
|
|
|
|
|
pdd_version = ?,
|
|
|
|
|
last_seen_at = ?,
|
|
|
|
|
readiness_reported_at = ?,
|
|
|
|
|
accessibility_enabled = ?,
|
|
|
|
|
pdd_installed = ?,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
update.AppVersion,
|
|
|
|
|
update.AndroidVersion,
|
|
|
|
|
update.PDDVersion,
|
|
|
|
|
formatTimestamp(update.ReportedAt),
|
|
|
|
|
formatTimestamp(update.ReportedAt),
|
|
|
|
|
update.AccessibilityEnabled,
|
|
|
|
|
update.PDDInstalled,
|
|
|
|
|
formatTimestamp(update.ReportedAt),
|
|
|
|
|
update.DeviceID,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.DeviceHeartbeatRecord{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
device, err = getDeviceByID(ctx, tx, update.DeviceID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.DeviceHeartbeatRecord{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var activeTaskID string
|
|
|
|
|
err = tx.QueryRowContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`SELECT id
|
|
|
|
|
FROM purchase_tasks
|
|
|
|
|
WHERE claimed_by_device_id = ?
|
|
|
|
|
AND (
|
|
|
|
|
status IN ('RUNNING', 'WAITING_CONFIRMATION')
|
|
|
|
|
OR (
|
|
|
|
|
status = 'CLAIMED'
|
|
|
|
|
AND claim_expires_at > ?
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
ORDER BY updated_at DESC, id DESC
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
update.DeviceID,
|
|
|
|
|
formatTimestamp(update.ReportedAt),
|
|
|
|
|
).Scan(&activeTaskID)
|
|
|
|
|
var activeTaskIDPointer *string
|
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
|
activeTaskIDPointer = nil
|
|
|
|
|
} else if err != nil {
|
|
|
|
|
return usecase.DeviceHeartbeatRecord{}, repositoryFailure(err)
|
|
|
|
|
} else {
|
|
|
|
|
activeTaskIDPointer = &activeTaskID
|
|
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return usecase.DeviceHeartbeatRecord{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return usecase.DeviceHeartbeatRecord{
|
|
|
|
|
Device: device,
|
|
|
|
|
ActiveTaskID: activeTaskIDPointer,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Store) ClaimNext(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
request usecase.ClaimNextRepositoryRequest,
|
|
|
|
|
) (usecase.ClaimNextRepositoryResult, error) {
|
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
|
|
|
|
|
|
record, found, err := lookupLifecycleRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
lifecycleOperationClaimNext,
|
|
|
|
|
request.IdempotencyKey,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if found {
|
|
|
|
|
return replayClaimNext(ctx, tx, request, record)
|
|
|
|
|
}
|
|
|
|
|
if err := ensureDeviceReady(ctx, tx, request); err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
active, err := hasActiveDeviceTask(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
request.Now,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if active {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{},
|
|
|
|
|
usecase.ErrDeviceActiveTask
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
candidate, err := scanTask(tx.QueryRowContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`SELECT
|
|
|
|
|
id, creator_subject, created_by_user_id, source_ref, title, description, sku,
|
|
|
|
|
image_asset_id, quantity, max_budget_cents, currency, status,
|
|
|
|
|
version, claimed_by_user_id, claimed_by_device_id, claim_generation,
|
|
|
|
|
claim_token_hash, claim_issued_at, claim_expires_at, cancel_reason,
|
|
|
|
|
cancel_requested_at, cancel_requested_by_user_id, canceled_at,
|
|
|
|
|
created_at, updated_at
|
|
|
|
|
FROM purchase_tasks
|
|
|
|
|
WHERE creator_subject = ?
|
|
|
|
|
AND (
|
|
|
|
|
status = 'PENDING'
|
|
|
|
|
OR (
|
|
|
|
|
status = 'CLAIMED'
|
|
|
|
|
AND claim_expires_at <= ?
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
ORDER BY
|
|
|
|
|
CASE
|
|
|
|
|
WHEN status = 'CLAIMED'
|
|
|
|
|
AND claimed_by_device_id = ?
|
|
|
|
|
THEN 0
|
|
|
|
|
ELSE 1
|
|
|
|
|
END,
|
|
|
|
|
created_at ASC,
|
|
|
|
|
id ASC
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
request.CreatorSubject,
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
))
|
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
|
if err := insertLifecycleRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
lifecycleOperationClaimNext,
|
|
|
|
|
request.IdempotencyKey,
|
|
|
|
|
request.RequestHash,
|
|
|
|
|
"NO_TASK",
|
|
|
|
|
nil,
|
|
|
|
|
nil,
|
|
|
|
|
nil,
|
|
|
|
|
request.Now,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{},
|
|
|
|
|
repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, nil
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event := request.Event
|
|
|
|
|
event.TaskID = candidate.ID
|
|
|
|
|
if candidate.Status == domain.TaskStatusClaimed {
|
|
|
|
|
event.Type = "TASK_RECLAIMED"
|
|
|
|
|
event.Message = "expired task claim reclaimed"
|
|
|
|
|
}
|
|
|
|
|
result, err := tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`UPDATE purchase_tasks
|
|
|
|
|
SET status = 'CLAIMED',
|
|
|
|
|
version = version + 1,
|
|
|
|
|
claimed_by_user_id = ?,
|
|
|
|
|
claimed_by_device_id = ?,
|
|
|
|
|
claim_generation = claim_generation + 1,
|
|
|
|
|
claim_token_hash = ?,
|
|
|
|
|
claim_issued_at = ?,
|
|
|
|
|
claim_expires_at = ?,
|
|
|
|
|
cancel_reason = NULL,
|
|
|
|
|
cancel_requested_at = NULL,
|
|
|
|
|
cancel_requested_by_user_id = NULL,
|
|
|
|
|
canceled_at = NULL,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
AND version = ?
|
|
|
|
|
AND (
|
|
|
|
|
status = 'PENDING'
|
|
|
|
|
OR (
|
|
|
|
|
status = 'CLAIMED'
|
|
|
|
|
AND claim_expires_at <= ?
|
|
|
|
|
)
|
|
|
|
|
)`,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
request.ClaimTokenHash,
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
formatTimestamp(request.ExpiresAt),
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
candidate.ID,
|
|
|
|
|
candidate.Version,
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if isUniqueConstraint(err, "purchase_tasks.claimed_by_device_id") {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{},
|
|
|
|
|
usecase.ErrDeviceActiveTask
|
|
|
|
|
}
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
affected, err := result.RowsAffected()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
if affected != 1 {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{},
|
|
|
|
|
usecase.ErrTaskStateConflict
|
|
|
|
|
}
|
|
|
|
|
if err := insertTaskEvent(ctx, tx, event); err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
claimed, err := getTaskByID(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.CreatorSubject,
|
|
|
|
|
candidate.ID,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
taskID := claimed.ID
|
|
|
|
|
generation := claimed.ClaimGeneration
|
|
|
|
|
if err := insertLifecycleRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
lifecycleOperationClaimNext,
|
|
|
|
|
request.IdempotencyKey,
|
|
|
|
|
request.RequestHash,
|
|
|
|
|
"TASK",
|
|
|
|
|
&taskID,
|
|
|
|
|
&generation,
|
|
|
|
|
nil,
|
|
|
|
|
request.Now,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{Task: &claimed}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Store) StartTask(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
request usecase.StartTaskRepositoryRequest,
|
|
|
|
|
) (usecase.StartTaskRepositoryResult, error) {
|
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
|
|
|
|
|
|
record, found, err := lookupLifecycleRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
lifecycleOperationStart,
|
|
|
|
|
request.IdempotencyKey,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if found {
|
|
|
|
|
if record.RequestHash != request.RequestHash {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{},
|
|
|
|
|
usecase.ErrIdempotencyConflict
|
|
|
|
|
}
|
|
|
|
|
if record.ResultKind != "EXECUTION" ||
|
|
|
|
|
record.TaskID == nil ||
|
|
|
|
|
record.ExecutionID == nil ||
|
|
|
|
|
*record.TaskID != request.TaskID {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{},
|
|
|
|
|
usecase.ErrRepositoryInvariant
|
|
|
|
|
}
|
|
|
|
|
task, err := getLifecycleTask(ctx, tx, request.TaskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
execution, err := getExecutionByID(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
*record.ExecutionID,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{},
|
|
|
|
|
repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return usecase.StartTaskRepositoryResult{
|
|
|
|
|
Task: task,
|
|
|
|
|
Execution: execution,
|
|
|
|
|
Replayed: true,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
task, err := getClaimProtectedTask(ctx, tx, request.TaskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := validateClaim(
|
|
|
|
|
task,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
request.ClaimGeneration,
|
|
|
|
|
request.ClaimTokenHash,
|
|
|
|
|
request.Now,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if !domain.CanStart(task.Status) {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{},
|
|
|
|
|
usecase.ErrTaskStateConflict
|
|
|
|
|
}
|
|
|
|
|
if task.Version != request.ExpectedVersion {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{},
|
|
|
|
|
usecase.ErrTaskVersionConflict
|
|
|
|
|
}
|
|
|
|
|
err = tx.QueryRowContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`SELECT COALESCE(MAX(attempt_no), 0) + 1
|
|
|
|
|
FROM task_executions
|
|
|
|
|
WHERE task_id = ?`,
|
|
|
|
|
request.TaskID,
|
|
|
|
|
).Scan(&request.Execution.AttemptNo)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
request.Execution.LastHeartbeatAt = &request.Now
|
|
|
|
|
_, err = tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`INSERT INTO task_executions (
|
|
|
|
|
id, task_id, attempt_no, claim_generation, user_id, device_id,
|
|
|
|
|
current_step, last_heartbeat_at, order_submitted, started_at,
|
|
|
|
|
finished_at
|
|
|
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL)`,
|
|
|
|
|
request.Execution.ID,
|
|
|
|
|
request.Execution.TaskID,
|
|
|
|
|
request.Execution.AttemptNo,
|
|
|
|
|
request.Execution.ClaimGeneration,
|
|
|
|
|
request.Execution.UserID,
|
|
|
|
|
request.Execution.DeviceID,
|
|
|
|
|
request.Execution.CurrentStep,
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
formatTimestamp(request.Execution.StartedAt),
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
result, err := tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`UPDATE purchase_tasks
|
|
|
|
|
SET status = 'RUNNING',
|
|
|
|
|
version = version + 1,
|
|
|
|
|
claim_expires_at = ?,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
AND status = 'CLAIMED'
|
|
|
|
|
AND version = ?
|
|
|
|
|
AND claimed_by_user_id = ?
|
|
|
|
|
AND claimed_by_device_id = ?
|
|
|
|
|
AND claim_generation = ?
|
|
|
|
|
AND claim_token_hash = ?
|
|
|
|
|
AND claim_expires_at > ?`,
|
|
|
|
|
formatTimestamp(request.ExpiresAt),
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
request.TaskID,
|
|
|
|
|
request.ExpectedVersion,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
request.ClaimGeneration,
|
|
|
|
|
request.ClaimTokenHash,
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
affected, err := result.RowsAffected()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
if affected != 1 {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{},
|
|
|
|
|
usecase.ErrTaskVersionConflict
|
|
|
|
|
}
|
|
|
|
|
if err := insertTaskEvent(ctx, tx, request.Event); err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
executionID := request.Execution.ID
|
|
|
|
|
taskID := request.TaskID
|
|
|
|
|
generation := request.ClaimGeneration
|
|
|
|
|
if err := insertLifecycleRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
lifecycleOperationStart,
|
|
|
|
|
request.IdempotencyKey,
|
|
|
|
|
request.RequestHash,
|
|
|
|
|
"EXECUTION",
|
|
|
|
|
&taskID,
|
|
|
|
|
&generation,
|
|
|
|
|
&executionID,
|
|
|
|
|
request.Now,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
task, err = getLifecycleTask(ctx, tx, request.TaskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return usecase.StartTaskRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return usecase.StartTaskRepositoryResult{
|
|
|
|
|
Task: task,
|
|
|
|
|
Execution: request.Execution,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Store) HeartbeatTask(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
request usecase.TaskHeartbeatRepositoryRequest,
|
|
|
|
|
) (usecase.TaskHeartbeatRepositoryResult, error) {
|
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
|
|
|
|
|
|
task, err := getClaimProtectedTask(ctx, tx, request.TaskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{}, err
|
|
|
|
|
}
|
2026-07-27 11:11:16 +08:00
|
|
|
if err := validateClaimOwner(
|
2026-07-26 16:16:36 +08:00
|
|
|
task,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
request.ClaimGeneration,
|
|
|
|
|
request.ClaimTokenHash,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if !domain.CanHeartbeat(task.Status) {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{},
|
|
|
|
|
usecase.ErrTaskStateConflict
|
|
|
|
|
}
|
2026-07-27 11:11:16 +08:00
|
|
|
expired := !task.ClaimExpiresAt.After(request.Now)
|
|
|
|
|
if expired && request.Step != "SAFE_STOPPED" {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{},
|
|
|
|
|
usecase.ErrClaimExpired
|
|
|
|
|
}
|
2026-07-26 16:16:36 +08:00
|
|
|
execution, err := getExecutionByID(ctx, tx, request.ExecutionID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if execution.TaskID != request.TaskID ||
|
|
|
|
|
execution.UserID != request.UserID ||
|
|
|
|
|
execution.DeviceID != request.DeviceID ||
|
|
|
|
|
execution.ClaimGeneration != request.ClaimGeneration ||
|
|
|
|
|
execution.FinishedAt != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{},
|
|
|
|
|
usecase.ErrExecutionMismatch
|
|
|
|
|
}
|
|
|
|
|
expiresAt := *task.ClaimExpiresAt
|
2026-07-27 11:11:16 +08:00
|
|
|
if !expired &&
|
|
|
|
|
request.MinimumExpiry.After(expiresAt) {
|
2026-07-26 16:16:36 +08:00
|
|
|
expiresAt = request.MinimumExpiry
|
|
|
|
|
}
|
|
|
|
|
result, err := tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`UPDATE task_executions
|
|
|
|
|
SET current_step = ?,
|
|
|
|
|
last_heartbeat_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
AND finished_at IS NULL`,
|
|
|
|
|
request.Step,
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
request.ExecutionID,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
affected, err := result.RowsAffected()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
if affected != 1 {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{},
|
|
|
|
|
usecase.ErrExecutionMismatch
|
|
|
|
|
}
|
|
|
|
|
result, err = tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`UPDATE purchase_tasks
|
|
|
|
|
SET version = version + 1,
|
|
|
|
|
claim_expires_at = ?,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
AND status IN ('RUNNING', 'WAITING_CONFIRMATION')
|
|
|
|
|
AND claimed_by_user_id = ?
|
|
|
|
|
AND claimed_by_device_id = ?
|
|
|
|
|
AND claim_generation = ?
|
2026-07-27 11:11:16 +08:00
|
|
|
AND claim_token_hash = ?`,
|
2026-07-26 16:16:36 +08:00
|
|
|
formatTimestamp(expiresAt),
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
request.TaskID,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
request.ClaimGeneration,
|
|
|
|
|
request.ClaimTokenHash,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
affected, err = result.RowsAffected()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
if affected != 1 {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{},
|
2026-07-27 11:11:16 +08:00
|
|
|
usecase.ErrTaskVersionConflict
|
2026-07-26 16:16:36 +08:00
|
|
|
}
|
|
|
|
|
_, err = tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`UPDATE devices
|
|
|
|
|
SET last_seen_at = ?,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
task, err = getLifecycleTask(ctx, tx, request.TaskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
execution, err = getExecutionByID(ctx, tx, request.ExecutionID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return usecase.TaskHeartbeatRepositoryResult{
|
|
|
|
|
Task: task,
|
|
|
|
|
Execution: execution,
|
|
|
|
|
CancelRequested: task.CancelRequestedAt != nil,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Store) GetActiveClaimTask(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
request usecase.TaskClaimRepositoryRequest,
|
|
|
|
|
) (domain.PurchaseTask, error) {
|
|
|
|
|
task, err := getClaimProtectedTask(ctx, s.db, request.TaskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := validateClaim(
|
|
|
|
|
task,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
request.ClaimGeneration,
|
|
|
|
|
request.ClaimTokenHash,
|
|
|
|
|
request.Now,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, err
|
|
|
|
|
}
|
|
|
|
|
if !domain.IsActiveTaskStatus(task.Status) {
|
|
|
|
|
return domain.PurchaseTask{}, usecase.ErrTaskStateConflict
|
|
|
|
|
}
|
|
|
|
|
return task, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Store) ReleaseTask(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
request usecase.ReleaseTaskRepositoryRequest,
|
|
|
|
|
) (domain.PurchaseTask, bool, error) {
|
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
|
|
|
|
|
|
record, found, err := lookupLifecycleRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
lifecycleOperationRelease,
|
|
|
|
|
request.IdempotencyKey,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
if found {
|
|
|
|
|
return replayTerminalTransition(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.RequestHash,
|
|
|
|
|
request.TaskID,
|
|
|
|
|
request.ClaimGeneration,
|
|
|
|
|
domain.TaskStatusPending,
|
|
|
|
|
record,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
task, err := getClaimProtectedTask(ctx, tx, request.TaskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
if err := validateClaim(
|
|
|
|
|
task,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
request.ClaimGeneration,
|
|
|
|
|
request.ClaimTokenHash,
|
|
|
|
|
request.Now,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
if !domain.CanRelease(task.Status) {
|
|
|
|
|
return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict
|
|
|
|
|
}
|
|
|
|
|
if task.Version != request.ExpectedVersion {
|
|
|
|
|
return domain.PurchaseTask{}, false,
|
|
|
|
|
usecase.ErrTaskVersionConflict
|
|
|
|
|
}
|
|
|
|
|
result, err := tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`UPDATE purchase_tasks
|
|
|
|
|
SET status = 'PENDING',
|
|
|
|
|
version = version + 1,
|
|
|
|
|
claimed_by_user_id = NULL,
|
|
|
|
|
claimed_by_device_id = NULL,
|
|
|
|
|
claim_token_hash = NULL,
|
|
|
|
|
claim_issued_at = NULL,
|
|
|
|
|
claim_expires_at = NULL,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
AND status = 'CLAIMED'
|
|
|
|
|
AND version = ?
|
|
|
|
|
AND claimed_by_user_id = ?
|
|
|
|
|
AND claimed_by_device_id = ?
|
|
|
|
|
AND claim_generation = ?
|
|
|
|
|
AND claim_token_hash = ?
|
|
|
|
|
AND claim_expires_at > ?`,
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
request.TaskID,
|
|
|
|
|
request.ExpectedVersion,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
request.ClaimGeneration,
|
|
|
|
|
request.ClaimTokenHash,
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
affected, err := result.RowsAffected()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
if affected != 1 {
|
|
|
|
|
return domain.PurchaseTask{}, false,
|
|
|
|
|
usecase.ErrTaskVersionConflict
|
|
|
|
|
}
|
|
|
|
|
if err := insertTaskEvent(ctx, tx, request.Event); err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
taskID := request.TaskID
|
|
|
|
|
generation := request.ClaimGeneration
|
|
|
|
|
if err := insertLifecycleRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
lifecycleOperationRelease,
|
|
|
|
|
request.IdempotencyKey,
|
|
|
|
|
request.RequestHash,
|
|
|
|
|
"TASK",
|
|
|
|
|
&taskID,
|
|
|
|
|
&generation,
|
|
|
|
|
nil,
|
|
|
|
|
request.Now,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
task, err = getLifecycleTask(ctx, tx, request.TaskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return task, false, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Store) AcknowledgeTaskCancellation(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
request usecase.CancelAcknowledgementRepositoryRequest,
|
|
|
|
|
) (domain.PurchaseTask, bool, error) {
|
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
|
|
|
|
|
|
record, found, err := lookupLifecycleRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
lifecycleOperationCancelAck,
|
|
|
|
|
request.IdempotencyKey,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
if found {
|
|
|
|
|
return replayTerminalTransition(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.RequestHash,
|
|
|
|
|
request.TaskID,
|
|
|
|
|
request.ClaimGeneration,
|
|
|
|
|
domain.TaskStatusCanceled,
|
|
|
|
|
record,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
task, err := getClaimProtectedTask(ctx, tx, request.TaskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
2026-07-27 11:11:16 +08:00
|
|
|
if err := validateClaimOwner(
|
2026-07-26 16:16:36 +08:00
|
|
|
task,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
request.ClaimGeneration,
|
|
|
|
|
request.ClaimTokenHash,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
if !domain.CanAcknowledgeCancel(task.Status) ||
|
|
|
|
|
task.CancelRequestedAt == nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict
|
|
|
|
|
}
|
|
|
|
|
if task.Version != request.ExpectedVersion {
|
|
|
|
|
return domain.PurchaseTask{}, false,
|
|
|
|
|
usecase.ErrTaskVersionConflict
|
|
|
|
|
}
|
|
|
|
|
execution, err := getExecutionByID(ctx, tx, request.ExecutionID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
if execution.TaskID != request.TaskID ||
|
|
|
|
|
execution.UserID != request.UserID ||
|
|
|
|
|
execution.DeviceID != request.DeviceID ||
|
|
|
|
|
execution.ClaimGeneration != request.ClaimGeneration ||
|
|
|
|
|
execution.FinishedAt != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false,
|
|
|
|
|
usecase.ErrExecutionMismatch
|
|
|
|
|
}
|
|
|
|
|
result, err := tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`UPDATE purchase_tasks
|
|
|
|
|
SET status = 'CANCELED',
|
|
|
|
|
version = version + 1,
|
|
|
|
|
claimed_by_user_id = NULL,
|
|
|
|
|
claimed_by_device_id = NULL,
|
|
|
|
|
claim_token_hash = NULL,
|
|
|
|
|
claim_issued_at = NULL,
|
|
|
|
|
claim_expires_at = NULL,
|
|
|
|
|
canceled_at = ?,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
AND status IN ('RUNNING', 'WAITING_CONFIRMATION')
|
|
|
|
|
AND version = ?
|
|
|
|
|
AND cancel_requested_at IS NOT NULL
|
|
|
|
|
AND claimed_by_user_id = ?
|
|
|
|
|
AND claimed_by_device_id = ?
|
|
|
|
|
AND claim_generation = ?
|
2026-07-27 11:11:16 +08:00
|
|
|
AND claim_token_hash = ?`,
|
2026-07-26 16:16:36 +08:00
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
request.TaskID,
|
|
|
|
|
request.ExpectedVersion,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
request.ClaimGeneration,
|
|
|
|
|
request.ClaimTokenHash,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
affected, err := result.RowsAffected()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
if affected != 1 {
|
|
|
|
|
return domain.PurchaseTask{}, false,
|
|
|
|
|
usecase.ErrTaskVersionConflict
|
|
|
|
|
}
|
|
|
|
|
result, err = tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`UPDATE task_executions
|
|
|
|
|
SET finished_at = ?,
|
|
|
|
|
last_heartbeat_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
AND finished_at IS NULL`,
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
formatTimestamp(request.Now),
|
|
|
|
|
request.ExecutionID,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
affected, err = result.RowsAffected()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
if affected != 1 {
|
|
|
|
|
return domain.PurchaseTask{}, false,
|
|
|
|
|
usecase.ErrExecutionMismatch
|
|
|
|
|
}
|
|
|
|
|
if err := insertTaskEvent(ctx, tx, request.Event); err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
taskID := request.TaskID
|
|
|
|
|
generation := request.ClaimGeneration
|
|
|
|
|
executionID := request.ExecutionID
|
|
|
|
|
if err := insertLifecycleRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.UserID,
|
|
|
|
|
request.DeviceID,
|
|
|
|
|
lifecycleOperationCancelAck,
|
|
|
|
|
request.IdempotencyKey,
|
|
|
|
|
request.RequestHash,
|
|
|
|
|
"TASK",
|
|
|
|
|
&taskID,
|
|
|
|
|
&generation,
|
|
|
|
|
&executionID,
|
|
|
|
|
request.Now,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
task, err = getLifecycleTask(ctx, tx, request.TaskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return task, false, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func replayClaimNext(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
tx *sql.Tx,
|
|
|
|
|
request usecase.ClaimNextRepositoryRequest,
|
|
|
|
|
record lifecycleRequestRecord,
|
|
|
|
|
) (usecase.ClaimNextRepositoryResult, error) {
|
|
|
|
|
if record.RequestHash != request.RequestHash {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{},
|
|
|
|
|
usecase.ErrIdempotencyConflict
|
|
|
|
|
}
|
|
|
|
|
if record.ResultKind == "NO_TASK" {
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{},
|
|
|
|
|
repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{Replayed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
if record.ResultKind != "TASK" ||
|
|
|
|
|
record.TaskID == nil ||
|
|
|
|
|
record.ClaimGeneration == nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{},
|
|
|
|
|
usecase.ErrRepositoryInvariant
|
|
|
|
|
}
|
|
|
|
|
task, err := getTaskByID(
|
|
|
|
|
ctx,
|
|
|
|
|
tx,
|
|
|
|
|
request.CreatorSubject,
|
|
|
|
|
*record.TaskID,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{}, err
|
|
|
|
|
}
|
|
|
|
|
if !domain.IsActiveTaskStatus(task.Status) ||
|
|
|
|
|
task.ClaimedByUserID == nil ||
|
|
|
|
|
*task.ClaimedByUserID != request.UserID ||
|
|
|
|
|
task.ClaimedByDeviceID == nil ||
|
|
|
|
|
*task.ClaimedByDeviceID != request.DeviceID ||
|
|
|
|
|
task.ClaimGeneration != *record.ClaimGeneration ||
|
|
|
|
|
task.ClaimTokenHash == nil ||
|
|
|
|
|
*task.ClaimTokenHash != request.ClaimTokenHash ||
|
|
|
|
|
task.ClaimExpiresAt == nil ||
|
|
|
|
|
!task.ClaimExpiresAt.After(request.Now) {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{},
|
|
|
|
|
usecase.ErrClaimReplayExpired
|
|
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{},
|
|
|
|
|
repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return usecase.ClaimNextRepositoryResult{
|
|
|
|
|
Task: &task,
|
|
|
|
|
Replayed: true,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ensureDeviceReady(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
tx *sql.Tx,
|
|
|
|
|
request usecase.ClaimNextRepositoryRequest,
|
|
|
|
|
) error {
|
|
|
|
|
device, err := getDeviceByID(ctx, tx, request.DeviceID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, usecase.ErrRepositoryNotFound) {
|
|
|
|
|
return usecase.ErrClaimInvalid
|
|
|
|
|
}
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if !device.IsEnabled ||
|
|
|
|
|
device.BoundUserID == nil ||
|
|
|
|
|
*device.BoundUserID != request.UserID {
|
|
|
|
|
return usecase.ErrClaimInvalid
|
|
|
|
|
}
|
|
|
|
|
if device.ReadinessAt == nil ||
|
|
|
|
|
device.ReadinessAt.Before(request.ReadinessAfter) ||
|
|
|
|
|
!device.AccessibilityEnabled ||
|
|
|
|
|
!device.PDDInstalled {
|
|
|
|
|
return usecase.ErrDeviceNotReady
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hasActiveDeviceTask(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
tx *sql.Tx,
|
|
|
|
|
deviceID string,
|
|
|
|
|
now time.Time,
|
|
|
|
|
) (bool, error) {
|
|
|
|
|
var active int
|
|
|
|
|
err := tx.QueryRowContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`SELECT EXISTS (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM purchase_tasks
|
|
|
|
|
WHERE claimed_by_device_id = ?
|
|
|
|
|
AND (
|
|
|
|
|
status IN ('RUNNING', 'WAITING_CONFIRMATION')
|
|
|
|
|
OR (
|
|
|
|
|
status = 'CLAIMED'
|
|
|
|
|
AND claim_expires_at > ?
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)`,
|
|
|
|
|
deviceID,
|
|
|
|
|
formatTimestamp(now),
|
|
|
|
|
).Scan(&active)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return active == 1, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateClaim(
|
|
|
|
|
task domain.PurchaseTask,
|
|
|
|
|
userID string,
|
|
|
|
|
deviceID string,
|
|
|
|
|
generation int64,
|
|
|
|
|
tokenHash string,
|
|
|
|
|
now time.Time,
|
2026-07-27 11:11:16 +08:00
|
|
|
) error {
|
|
|
|
|
if err := validateClaimOwner(
|
|
|
|
|
task,
|
|
|
|
|
userID,
|
|
|
|
|
deviceID,
|
|
|
|
|
generation,
|
|
|
|
|
tokenHash,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if task.ClaimExpiresAt == nil || !task.ClaimExpiresAt.After(now) {
|
|
|
|
|
return usecase.ErrClaimExpired
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateClaimOwner(
|
|
|
|
|
task domain.PurchaseTask,
|
|
|
|
|
userID string,
|
|
|
|
|
deviceID string,
|
|
|
|
|
generation int64,
|
|
|
|
|
tokenHash string,
|
2026-07-26 16:16:36 +08:00
|
|
|
) error {
|
|
|
|
|
if task.ClaimedByUserID == nil ||
|
|
|
|
|
*task.ClaimedByUserID != userID ||
|
|
|
|
|
task.ClaimedByDeviceID == nil ||
|
|
|
|
|
*task.ClaimedByDeviceID != deviceID ||
|
|
|
|
|
task.ClaimGeneration != generation ||
|
|
|
|
|
task.ClaimTokenHash == nil ||
|
|
|
|
|
*task.ClaimTokenHash != tokenHash {
|
|
|
|
|
return usecase.ErrClaimInvalid
|
|
|
|
|
}
|
2026-07-27 11:11:16 +08:00
|
|
|
if task.ClaimExpiresAt == nil {
|
2026-07-26 16:16:36 +08:00
|
|
|
return usecase.ErrClaimExpired
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func replayTerminalTransition(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
tx *sql.Tx,
|
|
|
|
|
requestHash string,
|
|
|
|
|
taskID string,
|
|
|
|
|
generation int64,
|
|
|
|
|
expectedStatus domain.TaskStatus,
|
|
|
|
|
record lifecycleRequestRecord,
|
|
|
|
|
) (domain.PurchaseTask, bool, error) {
|
|
|
|
|
if record.RequestHash != requestHash {
|
|
|
|
|
return domain.PurchaseTask{}, false,
|
|
|
|
|
usecase.ErrIdempotencyConflict
|
|
|
|
|
}
|
|
|
|
|
if record.ResultKind != "TASK" ||
|
|
|
|
|
record.TaskID == nil ||
|
|
|
|
|
record.ClaimGeneration == nil ||
|
|
|
|
|
*record.TaskID != taskID ||
|
|
|
|
|
*record.ClaimGeneration != generation {
|
|
|
|
|
return domain.PurchaseTask{}, false,
|
|
|
|
|
usecase.ErrRepositoryInvariant
|
|
|
|
|
}
|
|
|
|
|
task, err := getLifecycleTask(ctx, tx, taskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, err
|
|
|
|
|
}
|
|
|
|
|
if task.Status != expectedStatus ||
|
|
|
|
|
task.ClaimGeneration != generation {
|
|
|
|
|
return domain.PurchaseTask{}, false,
|
|
|
|
|
usecase.ErrTaskStateConflict
|
|
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return task, true, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func lookupLifecycleRequest(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
tx *sql.Tx,
|
|
|
|
|
userID string,
|
|
|
|
|
deviceID string,
|
|
|
|
|
operation string,
|
|
|
|
|
idempotencyKey string,
|
|
|
|
|
) (lifecycleRequestRecord, bool, error) {
|
|
|
|
|
var record lifecycleRequestRecord
|
|
|
|
|
var taskID sql.NullString
|
|
|
|
|
var generation sql.NullInt64
|
|
|
|
|
var executionID sql.NullString
|
|
|
|
|
err := tx.QueryRowContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`SELECT
|
|
|
|
|
request_sha256, result_kind, task_id,
|
|
|
|
|
claim_generation, execution_id
|
|
|
|
|
FROM lifecycle_requests
|
|
|
|
|
WHERE user_id = ?
|
|
|
|
|
AND device_id = ?
|
|
|
|
|
AND operation = ?
|
|
|
|
|
AND idempotency_key = ?`,
|
|
|
|
|
userID,
|
|
|
|
|
deviceID,
|
|
|
|
|
operation,
|
|
|
|
|
idempotencyKey,
|
|
|
|
|
).Scan(
|
|
|
|
|
&record.RequestHash,
|
|
|
|
|
&record.ResultKind,
|
|
|
|
|
&taskID,
|
|
|
|
|
&generation,
|
|
|
|
|
&executionID,
|
|
|
|
|
)
|
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
|
return lifecycleRequestRecord{}, false, nil
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return lifecycleRequestRecord{}, false, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
if taskID.Valid {
|
|
|
|
|
record.TaskID = &taskID.String
|
|
|
|
|
}
|
|
|
|
|
if generation.Valid {
|
|
|
|
|
record.ClaimGeneration = &generation.Int64
|
|
|
|
|
}
|
|
|
|
|
if executionID.Valid {
|
|
|
|
|
record.ExecutionID = &executionID.String
|
|
|
|
|
}
|
|
|
|
|
return record, true, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func insertLifecycleRequest(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
tx *sql.Tx,
|
|
|
|
|
userID string,
|
|
|
|
|
deviceID string,
|
|
|
|
|
operation string,
|
|
|
|
|
idempotencyKey string,
|
|
|
|
|
requestHash string,
|
|
|
|
|
resultKind string,
|
|
|
|
|
taskID *string,
|
|
|
|
|
generation *int64,
|
|
|
|
|
executionID *string,
|
|
|
|
|
now time.Time,
|
|
|
|
|
) error {
|
|
|
|
|
_, err := tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`INSERT INTO lifecycle_requests (
|
|
|
|
|
user_id, device_id, operation, idempotency_key, request_sha256,
|
|
|
|
|
result_kind, task_id, claim_generation, execution_id, created_at
|
|
|
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
userID,
|
|
|
|
|
deviceID,
|
|
|
|
|
operation,
|
|
|
|
|
idempotencyKey,
|
|
|
|
|
requestHash,
|
|
|
|
|
resultKind,
|
|
|
|
|
nullableString(taskID),
|
|
|
|
|
nullableInt64(generation),
|
|
|
|
|
nullableString(executionID),
|
|
|
|
|
formatTimestamp(now),
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getLifecycleTask(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
queryer queryRower,
|
|
|
|
|
taskID string,
|
|
|
|
|
) (domain.PurchaseTask, error) {
|
|
|
|
|
task, err := scanTask(queryer.QueryRowContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`SELECT
|
|
|
|
|
id, creator_subject, created_by_user_id, source_ref, title, description, sku,
|
|
|
|
|
image_asset_id, quantity, max_budget_cents, currency, status,
|
|
|
|
|
version, claimed_by_user_id, claimed_by_device_id, claim_generation,
|
|
|
|
|
claim_token_hash, claim_issued_at, claim_expires_at, cancel_reason,
|
|
|
|
|
cancel_requested_at, cancel_requested_by_user_id, canceled_at,
|
|
|
|
|
created_at, updated_at
|
|
|
|
|
FROM purchase_tasks
|
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
taskID,
|
|
|
|
|
))
|
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
|
return domain.PurchaseTask{}, usecase.ErrRepositoryNotFound
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return task, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getClaimProtectedTask(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
queryer queryRower,
|
|
|
|
|
taskID string,
|
|
|
|
|
) (domain.PurchaseTask, error) {
|
|
|
|
|
task, err := getLifecycleTask(ctx, queryer, taskID)
|
|
|
|
|
if errors.Is(err, usecase.ErrRepositoryNotFound) {
|
|
|
|
|
return domain.PurchaseTask{}, usecase.ErrClaimInvalid
|
|
|
|
|
}
|
|
|
|
|
return task, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getExecutionByID(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
queryer queryRower,
|
|
|
|
|
executionID string,
|
|
|
|
|
) (domain.TaskExecution, error) {
|
|
|
|
|
execution, err := scanExecution(queryer.QueryRowContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`SELECT
|
|
|
|
|
id, task_id, attempt_no, claim_generation, user_id, device_id,
|
|
|
|
|
current_step, order_submitted, started_at, last_heartbeat_at,
|
|
|
|
|
finished_at
|
|
|
|
|
FROM task_executions
|
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
executionID,
|
|
|
|
|
))
|
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
|
return domain.TaskExecution{}, usecase.ErrExecutionMismatch
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.TaskExecution{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return execution, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var _ usecase.LifecycleRepository = (*Store)(nil)
|