feat(tasks): implement atomic claims and leases

This commit is contained in:
QiuSW
2026-07-26 16:16:36 +08:00
parent 49db5b8305
commit ce875af889
50 changed files with 7589 additions and 207 deletions
@@ -0,0 +1,69 @@
package usecase
import "errors"
var (
ErrDeviceNotReady = errors.New("device readiness is missing or stale")
ErrDeviceActiveTask = errors.New("device already has an active task")
ErrClaimInvalid = errors.New("task claim is invalid")
ErrClaimExpired = errors.New("task claim lease expired")
ErrClaimReplayExpired = errors.New("claim idempotency replay is stale")
ErrTaskVersionConflict = errors.New("task version conflict")
ErrExecutionMismatch = errors.New("task execution does not match")
)
func wrapLifecycleRepositoryError(err error) error {
switch {
case errors.Is(err, ErrDeviceNotReady):
return newError(
ErrorKindConflict,
"DEVICE_NOT_READY",
"device is not ready to claim a task",
err,
)
case errors.Is(err, ErrDeviceActiveTask):
return newError(
ErrorKindConflict,
"DEVICE_ACTIVE_TASK",
"device already has an active task",
err,
)
case errors.Is(err, ErrClaimInvalid):
return newError(
ErrorKindForbidden,
"TASK_CLAIM_INVALID",
"task claim is invalid",
err,
)
case errors.Is(err, ErrClaimExpired):
return newError(
ErrorKindConflict,
"TASK_LEASE_EXPIRED",
"task claim lease expired",
err,
)
case errors.Is(err, ErrClaimReplayExpired):
return newError(
ErrorKindConflict,
"CLAIM_REPLAY_EXPIRED",
"claim replay is no longer active",
err,
)
case errors.Is(err, ErrTaskVersionConflict):
return newError(
ErrorKindConflict,
"TASK_VERSION_CONFLICT",
"task version has changed",
err,
)
case errors.Is(err, ErrExecutionMismatch):
return newError(
ErrorKindConflict,
"TASK_EXECUTION_CONFLICT",
"task execution does not match",
err,
)
default:
return wrapRepositoryError(err)
}
}
@@ -0,0 +1,148 @@
package usecase
import (
"context"
"time"
"cmroubao/backend-api/internal/domain"
)
type DeviceHeartbeatUpdate struct {
UserID string
DeviceID string
AppVersion string
AndroidVersion string
PDDVersion string
AccessibilityEnabled bool
PDDInstalled bool
ReportedAt time.Time
}
type DeviceHeartbeatRecord struct {
Device domain.Device
ActiveTaskID *string
}
type ClaimNextRepositoryRequest struct {
CreatorSubject string
UserID string
DeviceID string
IdempotencyKey string
RequestHash string
ClaimTokenHash string
Now time.Time
ExpiresAt time.Time
ReadinessAfter time.Time
Event domain.TaskEvent
}
type ClaimNextRepositoryResult struct {
Task *domain.PurchaseTask
Replayed bool
}
type StartTaskRepositoryRequest struct {
UserID string
DeviceID string
TaskID string
ClaimGeneration int64
ClaimTokenHash string
ExpectedVersion int64
IdempotencyKey string
RequestHash string
Now time.Time
ExpiresAt time.Time
Execution domain.TaskExecution
Event domain.TaskEvent
}
type StartTaskRepositoryResult struct {
Task domain.PurchaseTask
Execution domain.TaskExecution
Replayed bool
}
type TaskHeartbeatRepositoryRequest struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimTokenHash string
Step string
Now time.Time
MinimumExpiry time.Time
}
type TaskClaimRepositoryRequest struct {
UserID string
DeviceID string
TaskID string
ClaimGeneration int64
ClaimTokenHash string
Now time.Time
}
type TaskHeartbeatRepositoryResult struct {
Task domain.PurchaseTask
Execution domain.TaskExecution
CancelRequested bool
}
type ReleaseTaskRepositoryRequest struct {
UserID string
DeviceID string
TaskID string
ClaimGeneration int64
ClaimTokenHash string
ExpectedVersion int64
IdempotencyKey string
RequestHash string
Now time.Time
Event domain.TaskEvent
}
type CancelAcknowledgementRepositoryRequest struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimTokenHash string
ExpectedVersion int64
IdempotencyKey string
RequestHash string
Now time.Time
Event domain.TaskEvent
}
type LifecycleRepository interface {
RecordDeviceHeartbeat(
context.Context,
DeviceHeartbeatUpdate,
) (DeviceHeartbeatRecord, error)
ClaimNext(
context.Context,
ClaimNextRepositoryRequest,
) (ClaimNextRepositoryResult, error)
StartTask(
context.Context,
StartTaskRepositoryRequest,
) (StartTaskRepositoryResult, error)
HeartbeatTask(
context.Context,
TaskHeartbeatRepositoryRequest,
) (TaskHeartbeatRepositoryResult, error)
GetActiveClaimTask(
context.Context,
TaskClaimRepositoryRequest,
) (domain.PurchaseTask, error)
ReleaseTask(
context.Context,
ReleaseTaskRepositoryRequest,
) (domain.PurchaseTask, bool, error)
AcknowledgeTaskCancellation(
context.Context,
CancelAcknowledgementRepositoryRequest,
) (domain.PurchaseTask, bool, error)
}
@@ -0,0 +1,783 @@
package usecase
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
)
const (
lifecycleCreatorSubject = "local-admin"
maxLifecycleStepBytes = 64
)
type LifecycleService struct {
repository LifecycleRepository
clock Clock
ids IDGenerator
claimLease time.Duration
runningLease time.Duration
readinessTTL time.Duration
}
type DeviceHeartbeatCommand struct {
UserID string
DeviceID string
AppVersion string
AndroidVersion string
PDDVersion string
AccessibilityEnabled bool
PDDInstalled bool
ClientActiveTaskID *string
}
type DeviceHeartbeatResult struct {
Device domain.Device
ActiveTaskID *string
ClientStateMatches bool
ServerTime time.Time
}
type ClaimNextCommand struct {
UserID string
DeviceID string
IdempotencyKey string
ClaimToken string
}
type ClaimNextResult struct {
Task *domain.PurchaseTask
Replayed bool
ServerTime time.Time
}
type StartTaskCommand struct {
UserID string
DeviceID string
TaskID string
ClaimGeneration int64
ClaimToken string
ExpectedVersion int64
IdempotencyKey string
}
type StartTaskResult struct {
Task domain.PurchaseTask
Execution domain.TaskExecution
Replayed bool
ServerTime time.Time
}
type TaskHeartbeatCommand struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimToken string
Step string
}
type TaskHeartbeatResult struct {
Task domain.PurchaseTask
Execution domain.TaskExecution
CancelRequested bool
ServerTime time.Time
}
type ReferenceImageCommand struct {
UserID string
DeviceID string
TaskID string
ClaimGeneration int64
ClaimToken string
}
type ReleaseTaskCommand struct {
UserID string
DeviceID string
TaskID string
ClaimGeneration int64
ClaimToken string
ExpectedVersion int64
IdempotencyKey string
}
type ReleaseTaskResult struct {
Task domain.PurchaseTask
Replayed bool
ServerTime time.Time
}
type AcknowledgeCancellationCommand struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimToken string
ExpectedVersion int64
IdempotencyKey string
}
type AcknowledgeCancellationResult struct {
Task domain.PurchaseTask
Replayed bool
ServerTime time.Time
}
func NewLifecycleService(
repository LifecycleRepository,
clock Clock,
ids IDGenerator,
claimLease time.Duration,
runningLease time.Duration,
readinessTTL time.Duration,
) (*LifecycleService, error) {
if repository == nil || clock == nil || ids == nil {
return nil, errors.New("lifecycle service dependencies are required")
}
if claimLease <= 0 || runningLease <= 0 || readinessTTL <= 0 {
return nil, errors.New("lifecycle durations must be positive")
}
return &LifecycleService{
repository: repository,
clock: clock,
ids: ids,
claimLease: claimLease,
runningLease: runningLease,
readinessTTL: readinessTTL,
}, nil
}
func (service *LifecycleService) HeartbeatDevice(
ctx context.Context,
command DeviceHeartbeatCommand,
) (DeviceHeartbeatResult, error) {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
fields := lifecycleIdentityFields(command.UserID, command.DeviceID)
appVersion := strings.TrimSpace(command.AppVersion)
androidVersion := strings.TrimSpace(command.AndroidVersion)
pddVersion := strings.TrimSpace(command.PDDVersion)
validateVersionField(fields, "app_version", appVersion)
validateVersionField(fields, "android_version", androidVersion)
validateVersionField(fields, "pdd_version", pddVersion)
if command.ClientActiveTaskID != nil {
value := strings.TrimSpace(*command.ClientActiveTaskID)
command.ClientActiveTaskID = &value
if value != "" && !isUUID(value) {
fields["active_task_id"] = "must be a UUID or null"
}
}
if len(fields) > 0 {
return DeviceHeartbeatResult{}, invalidError(
"DEVICE_HEARTBEAT_INVALID",
"device heartbeat is invalid",
fields,
)
}
now := service.clock.Now().UTC()
record, err := service.repository.RecordDeviceHeartbeat(
ctx,
DeviceHeartbeatUpdate{
UserID: command.UserID,
DeviceID: command.DeviceID,
AppVersion: appVersion,
AndroidVersion: androidVersion,
PDDVersion: pddVersion,
AccessibilityEnabled: command.AccessibilityEnabled,
PDDInstalled: command.PDDInstalled,
ReportedAt: now,
},
)
if err != nil {
return DeviceHeartbeatResult{},
wrapLifecycleRepositoryError(err)
}
return DeviceHeartbeatResult{
Device: record.Device,
ActiveTaskID: record.ActiveTaskID,
ClientStateMatches: sameOptionalID(
command.ClientActiveTaskID,
record.ActiveTaskID,
),
ServerTime: now,
}, nil
}
func (service *LifecycleService) ClaimNext(
ctx context.Context,
command ClaimNextCommand,
) (ClaimNextResult, error) {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
fields := lifecycleWriteFields(
command.UserID,
command.DeviceID,
command.IdempotencyKey,
command.ClaimToken,
)
if len(fields) > 0 {
return ClaimNextResult{}, invalidError(
"TASK_CLAIM_INVALID",
"task claim request is invalid",
fields,
)
}
requestHash, err := lifecycleRequestHash(struct {
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
ClaimTokenHash string `json:"claim_token_sha256"`
}{
UserID: command.UserID,
DeviceID: command.DeviceID,
ClaimTokenHash: hashSecret(command.ClaimToken),
})
if err != nil {
return ClaimNextResult{}, internalLifecycleFailure(err)
}
event, err := service.newLifecycleEvent(
command.UserID,
command.DeviceID,
"",
"TASK_CLAIMED",
"task claimed",
)
if err != nil {
return ClaimNextResult{}, err
}
now := service.clock.Now().UTC()
result, err := service.repository.ClaimNext(
ctx,
ClaimNextRepositoryRequest{
CreatorSubject: lifecycleCreatorSubject,
UserID: command.UserID,
DeviceID: command.DeviceID,
IdempotencyKey: command.IdempotencyKey,
RequestHash: requestHash,
ClaimTokenHash: hashSecret(command.ClaimToken),
Now: now,
ExpiresAt: now.Add(service.claimLease),
ReadinessAfter: now.Add(-service.readinessTTL),
Event: event,
},
)
if err != nil {
return ClaimNextResult{}, wrapLifecycleRepositoryError(err)
}
return ClaimNextResult{
Task: result.Task,
Replayed: result.Replayed,
ServerTime: now,
}, nil
}
func (service *LifecycleService) StartTask(
ctx context.Context,
command StartTaskCommand,
) (StartTaskResult, error) {
command = normalizeStartTaskCommand(command)
fields := lifecycleTransitionFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ClaimGeneration,
command.ClaimToken,
command.ExpectedVersion,
command.IdempotencyKey,
)
if len(fields) > 0 {
return StartTaskResult{}, invalidError(
"TASK_START_INVALID",
"task start request is invalid",
fields,
)
}
executionID, err := service.ids.NewID()
if err != nil {
return StartTaskResult{}, internalLifecycleFailure(err)
}
event, err := service.newLifecycleEvent(
command.UserID,
command.DeviceID,
command.TaskID,
"TASK_STARTED",
"task started",
)
if err != nil {
return StartTaskResult{}, err
}
now := service.clock.Now().UTC()
requestHash, err := lifecycleRequestHash(commandHashView{
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
ExpectedVersion: command.ExpectedVersion,
})
if err != nil {
return StartTaskResult{}, internalLifecycleFailure(err)
}
result, err := service.repository.StartTask(
ctx,
StartTaskRepositoryRequest{
UserID: command.UserID,
DeviceID: command.DeviceID,
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
ExpectedVersion: command.ExpectedVersion,
IdempotencyKey: command.IdempotencyKey,
RequestHash: requestHash,
Now: now,
ExpiresAt: now.Add(service.runningLease),
Execution: domain.TaskExecution{
ID: executionID,
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
UserID: command.UserID,
DeviceID: command.DeviceID,
CurrentStep: "PREFLIGHT",
OrderSubmitted: false,
StartedAt: now,
},
Event: event,
},
)
if err != nil {
return StartTaskResult{}, wrapLifecycleRepositoryError(err)
}
return StartTaskResult{
Task: result.Task,
Execution: result.Execution,
Replayed: result.Replayed,
ServerTime: now,
}, nil
}
func (service *LifecycleService) HeartbeatTask(
ctx context.Context,
command TaskHeartbeatCommand,
) (TaskHeartbeatResult, error) {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
command.Step = strings.TrimSpace(command.Step)
fields := lifecycleClaimFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ClaimGeneration,
command.ClaimToken,
)
if !isUUID(command.ExecutionID) {
fields["execution_id"] = "must be a UUID"
}
if !validLifecycleStep(command.Step) {
fields["step"] = "must be 1-64 uppercase ASCII characters"
}
if len(fields) > 0 {
return TaskHeartbeatResult{}, invalidError(
"TASK_HEARTBEAT_INVALID",
"task heartbeat is invalid",
fields,
)
}
now := service.clock.Now().UTC()
result, err := service.repository.HeartbeatTask(
ctx,
TaskHeartbeatRepositoryRequest{
UserID: command.UserID,
DeviceID: command.DeviceID,
TaskID: command.TaskID,
ExecutionID: command.ExecutionID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
Step: command.Step,
Now: now,
MinimumExpiry: now.Add(service.runningLease),
},
)
if err != nil {
return TaskHeartbeatResult{},
wrapLifecycleRepositoryError(err)
}
return TaskHeartbeatResult{
Task: result.Task,
Execution: result.Execution,
CancelRequested: result.CancelRequested,
ServerTime: now,
}, nil
}
func (service *LifecycleService) AuthorizeReferenceImage(
ctx context.Context,
command ReferenceImageCommand,
) (domain.PurchaseTask, error) {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
fields := lifecycleClaimFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ClaimGeneration,
command.ClaimToken,
)
if len(fields) > 0 {
return domain.PurchaseTask{}, invalidError(
"TASK_REFERENCE_IMAGE_INVALID",
"task reference image request is invalid",
fields,
)
}
task, err := service.repository.GetActiveClaimTask(
ctx,
TaskClaimRepositoryRequest{
UserID: command.UserID,
DeviceID: command.DeviceID,
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
Now: service.clock.Now().UTC(),
},
)
if err != nil {
return domain.PurchaseTask{},
wrapLifecycleRepositoryError(err)
}
return task, nil
}
func (service *LifecycleService) ReleaseTask(
ctx context.Context,
command ReleaseTaskCommand,
) (ReleaseTaskResult, error) {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
fields := lifecycleTransitionFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ClaimGeneration,
command.ClaimToken,
command.ExpectedVersion,
command.IdempotencyKey,
)
if len(fields) > 0 {
return ReleaseTaskResult{}, invalidError(
"TASK_RELEASE_INVALID",
"task release request is invalid",
fields,
)
}
event, err := service.newLifecycleEvent(
command.UserID,
command.DeviceID,
command.TaskID,
"TASK_RELEASED",
"task released",
)
if err != nil {
return ReleaseTaskResult{}, err
}
now := service.clock.Now().UTC()
requestHash, err := lifecycleRequestHash(commandHashView{
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
ExpectedVersion: command.ExpectedVersion,
})
if err != nil {
return ReleaseTaskResult{}, internalLifecycleFailure(err)
}
task, replayed, err := service.repository.ReleaseTask(
ctx,
ReleaseTaskRepositoryRequest{
UserID: command.UserID,
DeviceID: command.DeviceID,
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
ExpectedVersion: command.ExpectedVersion,
IdempotencyKey: command.IdempotencyKey,
RequestHash: requestHash,
Now: now,
Event: event,
},
)
if err != nil {
return ReleaseTaskResult{}, wrapLifecycleRepositoryError(err)
}
return ReleaseTaskResult{
Task: task,
Replayed: replayed,
ServerTime: now,
}, nil
}
func (service *LifecycleService) AcknowledgeCancellation(
ctx context.Context,
command AcknowledgeCancellationCommand,
) (AcknowledgeCancellationResult, error) {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
fields := lifecycleTransitionFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ClaimGeneration,
command.ClaimToken,
command.ExpectedVersion,
command.IdempotencyKey,
)
if !isUUID(command.ExecutionID) {
fields["execution_id"] = "must be a UUID"
}
if len(fields) > 0 {
return AcknowledgeCancellationResult{}, invalidError(
"TASK_CANCEL_ACK_INVALID",
"task cancellation acknowledgement is invalid",
fields,
)
}
event, err := service.newLifecycleEvent(
command.UserID,
command.DeviceID,
command.TaskID,
"TASK_CANCELED",
"task cancellation acknowledged",
)
if err != nil {
return AcknowledgeCancellationResult{}, err
}
now := service.clock.Now().UTC()
requestHash, err := lifecycleRequestHash(struct {
commandHashView
ExecutionID string `json:"execution_id"`
}{
commandHashView: commandHashView{
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
ExpectedVersion: command.ExpectedVersion,
},
ExecutionID: command.ExecutionID,
})
if err != nil {
return AcknowledgeCancellationResult{},
internalLifecycleFailure(err)
}
task, replayed, err := service.repository.AcknowledgeTaskCancellation(
ctx,
CancelAcknowledgementRepositoryRequest{
UserID: command.UserID,
DeviceID: command.DeviceID,
TaskID: command.TaskID,
ExecutionID: command.ExecutionID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
ExpectedVersion: command.ExpectedVersion,
IdempotencyKey: command.IdempotencyKey,
RequestHash: requestHash,
Now: now,
Event: event,
},
)
if err != nil {
return AcknowledgeCancellationResult{},
wrapLifecycleRepositoryError(err)
}
return AcknowledgeCancellationResult{
Task: task,
Replayed: replayed,
ServerTime: now,
}, nil
}
type commandHashView struct {
TaskID string `json:"task_id"`
ClaimGeneration int64 `json:"claim_generation"`
ClaimTokenHash string `json:"claim_token_sha256"`
ExpectedVersion int64 `json:"expected_version"`
}
func normalizeStartTaskCommand(command StartTaskCommand) StartTaskCommand {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
return command
}
func lifecycleWriteFields(
userID string,
deviceID string,
idempotencyKey string,
claimToken string,
) map[string]string {
fields := lifecycleIdentityFields(userID, deviceID)
validateIdempotencyField(fields, idempotencyKey)
if !validOpaqueToken(claimToken) {
fields["claim_token"] = "must be a 256-bit Raw URL value"
}
return fields
}
func lifecycleClaimFields(
userID string,
deviceID string,
taskID string,
claimGeneration int64,
claimToken string,
) map[string]string {
fields := lifecycleIdentityFields(userID, deviceID)
if !isUUID(taskID) {
fields["task_id"] = "must be a UUID"
}
if claimGeneration < 1 {
fields["claim_generation"] = "must be positive"
}
if !validOpaqueToken(claimToken) {
fields["claim_token"] = "must be a 256-bit Raw URL value"
}
return fields
}
func lifecycleTransitionFields(
userID string,
deviceID string,
taskID string,
claimGeneration int64,
claimToken string,
expectedVersion int64,
idempotencyKey string,
) map[string]string {
fields := lifecycleClaimFields(
userID,
deviceID,
taskID,
claimGeneration,
claimToken,
)
if expectedVersion < 1 {
fields["expected_version"] = "must be positive"
}
validateIdempotencyField(fields, idempotencyKey)
return fields
}
func lifecycleIdentityFields(
userID string,
deviceID string,
) map[string]string {
fields := make(map[string]string)
if !isUUID(userID) {
fields["user_id"] = "must be a UUID"
}
if !isUUID(deviceID) {
fields["device_id"] = "must be a UUID"
}
return fields
}
func validateIdempotencyField(
fields map[string]string,
value string,
) {
if value == "" ||
len([]byte(value)) > maxIdempotencyKeyBytes ||
!isPrintableASCII(value) {
fields["idempotency_key"] = "invalid"
}
}
func validateVersionField(
fields map[string]string,
name string,
value string,
) {
if value == "" {
fields[name] = "required"
} else if !utf8.ValidString(value) ||
len([]byte(value)) > domain.MaxVersionBytes {
fields[name] = "must be valid UTF-8 up to 128 bytes"
}
}
func validLifecycleStep(value string) bool {
if value == "" || len([]byte(value)) > maxLifecycleStepBytes {
return false
}
for _, char := range value {
if (char < 'A' || char > 'Z') &&
(char < '0' || char > '9') &&
char != '_' {
return false
}
}
return true
}
func lifecycleRequestHash(value any) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:]), nil
}
func (service *LifecycleService) newLifecycleEvent(
userID string,
deviceID string,
taskID string,
eventType string,
message string,
) (domain.TaskEvent, error) {
id, err := service.ids.NewID()
if err != nil {
return domain.TaskEvent{}, internalLifecycleFailure(err)
}
actorUserID := userID
actorDeviceID := deviceID
return domain.TaskEvent{
ID: id,
TaskID: taskID,
ActorUserID: &actorUserID,
ActorDeviceID: &actorDeviceID,
Type: eventType,
Message: message,
OccurredAt: service.clock.Now().UTC(),
}, nil
}
func sameOptionalID(left, right *string) bool {
if left == nil || strings.TrimSpace(*left) == "" {
return right == nil
}
return right != nil && strings.TrimSpace(*left) == *right
}
func internalLifecycleFailure(err error) error {
return newError(
ErrorKindInternal,
"INTERNAL_ERROR",
"internal server error",
err,
)
}
@@ -0,0 +1,396 @@
package usecase
import (
"context"
"testing"
"time"
"cmroubao/backend-api/internal/domain"
)
const (
lifecycleUserID = "00000000-0000-4000-8000-000000000091"
lifecycleDeviceID = "00000000-0000-4000-8000-000000000092"
lifecycleTaskID = "00000000-0000-4000-8000-000000000093"
lifecycleExecID = "00000000-0000-4000-8000-000000000094"
)
func TestLifecycleServiceHeartbeatAndClaimUseServerIdentityAndTime(
t *testing.T,
) {
repository := &fakeLifecycleRepository{
heartbeatRecord: DeviceHeartbeatRecord{
Device: domain.Device{ID: lifecycleDeviceID},
},
claimResult: ClaimNextRepositoryResult{
Task: &domain.PurchaseTask{ID: lifecycleTaskID},
},
}
service := mustLifecycleService(t, repository)
clientActive := lifecycleTaskID
heartbeat, err := service.HeartbeatDevice(
context.Background(),
DeviceHeartbeatCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
AppVersion: "0.1.0",
AndroidVersion: "16",
PDDVersion: "8.17.0",
AccessibilityEnabled: true,
PDDInstalled: true,
ClientActiveTaskID: &clientActive,
},
)
if err != nil {
t.Fatalf("HeartbeatDevice() error = %v", err)
}
if heartbeat.ClientStateMatches ||
!repository.heartbeatUpdate.ReportedAt.Equal(fakeClock{}.Now()) {
t.Fatalf("heartbeat result/update = %+v / %+v", heartbeat, repository.heartbeatUpdate)
}
rawToken := validTestToken(30)
claim, err := service.ClaimNext(
context.Background(),
ClaimNextCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
IdempotencyKey: "claim-1",
ClaimToken: rawToken,
},
)
if err != nil {
t.Fatalf("ClaimNext() error = %v", err)
}
request := repository.claimRequest
if claim.Task == nil || claim.Task.ID != lifecycleTaskID ||
request.ClaimTokenHash == rawToken ||
request.ClaimTokenHash != hashSecret(rawToken) ||
len(request.RequestHash) != 64 ||
request.ExpiresAt.Sub(request.Now) != 10*time.Minute ||
request.Now.Sub(request.ReadinessAfter) != 2*time.Minute {
t.Fatalf("claim/request = %+v / %+v", claim, request)
}
if request.Event.ActorUserID == nil ||
*request.Event.ActorUserID != lifecycleUserID ||
request.Event.ActorDeviceID == nil ||
*request.Event.ActorDeviceID != lifecycleDeviceID {
t.Fatalf("claim event = %+v", request.Event)
}
}
func TestLifecycleServiceTransitionsHashClaimAndCreateExecution(
t *testing.T,
) {
repository := &fakeLifecycleRepository{
startResult: StartTaskRepositoryResult{
Task: domain.PurchaseTask{
ID: lifecycleTaskID,
Status: domain.TaskStatusRunning,
Version: 3,
ClaimGeneration: 1,
},
Execution: domain.TaskExecution{ID: lifecycleExecID},
},
heartbeatTaskResult: TaskHeartbeatRepositoryResult{
Task: domain.PurchaseTask{
ID: lifecycleTaskID,
Status: domain.TaskStatusRunning,
Version: 4,
ClaimGeneration: 1,
},
Execution: domain.TaskExecution{ID: lifecycleExecID},
CancelRequested: true,
},
releaseTask: domain.PurchaseTask{
ID: lifecycleTaskID,
Status: domain.TaskStatusPending,
},
cancelTask: domain.PurchaseTask{
ID: lifecycleTaskID,
Status: domain.TaskStatusCanceled,
},
}
service := mustLifecycleService(t, repository)
token := validTestToken(40)
start, err := service.StartTask(
context.Background(),
StartTaskCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
TaskID: lifecycleTaskID,
ClaimGeneration: 1,
ClaimToken: token,
ExpectedVersion: 2,
IdempotencyKey: "start-1",
},
)
if err != nil {
t.Fatalf("StartTask() error = %v", err)
}
startRequest := repository.startRequest
if start.Execution.ID != lifecycleExecID ||
startRequest.ClaimTokenHash != hashSecret(token) ||
startRequest.Execution.ID == "" ||
startRequest.Execution.CurrentStep != "PREFLIGHT" ||
startRequest.ExpiresAt.Sub(startRequest.Now) != 90*time.Second ||
startRequest.Event.Type != "TASK_STARTED" {
t.Fatalf("start/request = %+v / %+v", start, startRequest)
}
heartbeat, err := service.HeartbeatTask(
context.Background(),
TaskHeartbeatCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
TaskID: lifecycleTaskID,
ExecutionID: lifecycleExecID,
ClaimGeneration: 1,
ClaimToken: token,
Step: "SCAN_RESULTS",
},
)
if err != nil {
t.Fatalf("HeartbeatTask() error = %v", err)
}
if !heartbeat.CancelRequested ||
repository.taskHeartbeatRequest.MinimumExpiry.Sub(
repository.taskHeartbeatRequest.Now,
) != 90*time.Second {
t.Fatalf("heartbeat/request = %+v / %+v", heartbeat, repository.taskHeartbeatRequest)
}
release, err := service.ReleaseTask(
context.Background(),
ReleaseTaskCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
TaskID: lifecycleTaskID,
ClaimGeneration: 1,
ClaimToken: token,
ExpectedVersion: 2,
IdempotencyKey: "release-1",
},
)
if err != nil || release.Task.Status != domain.TaskStatusPending {
t.Fatalf("ReleaseTask() = %+v, error = %v", release, err)
}
if repository.releaseRequest.Event.Type != "TASK_RELEASED" {
t.Fatalf("release event = %+v", repository.releaseRequest.Event)
}
acknowledged, err := service.AcknowledgeCancellation(
context.Background(),
AcknowledgeCancellationCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
TaskID: lifecycleTaskID,
ExecutionID: lifecycleExecID,
ClaimGeneration: 1,
ClaimToken: token,
ExpectedVersion: 4,
IdempotencyKey: "cancel-ack-1",
},
)
if err != nil ||
acknowledged.Task.Status != domain.TaskStatusCanceled {
t.Fatalf(
"AcknowledgeCancellation() = %+v, error = %v",
acknowledged,
err,
)
}
if repository.cancelRequest.Event.Type != "TASK_CANCELED" {
t.Fatalf("cancel event = %+v", repository.cancelRequest.Event)
}
}
func TestLifecycleServiceRejectsMalformedClaimsAndMapsConflicts(
t *testing.T,
) {
repository := &fakeLifecycleRepository{
claimErr: ErrDeviceNotReady,
}
service := mustLifecycleService(t, repository)
_, err := service.ClaimNext(
context.Background(),
ClaimNextCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
IdempotencyKey: "claim-1",
ClaimToken: validTestToken(50),
},
)
assertUsecaseError(t, err, ErrorKindConflict, "DEVICE_NOT_READY")
_, err = service.ClaimNext(
context.Background(),
ClaimNextCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
IdempotencyKey: "claim-2",
ClaimToken: "not-a-token",
},
)
assertUsecaseError(t, err, ErrorKindInvalid, "TASK_CLAIM_INVALID")
_, err = service.HeartbeatTask(
context.Background(),
TaskHeartbeatCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
TaskID: lifecycleTaskID,
ExecutionID: lifecycleExecID,
ClaimGeneration: 1,
ClaimToken: validTestToken(51),
Step: "not valid",
},
)
assertUsecaseError(t, err, ErrorKindInvalid, "TASK_HEARTBEAT_INVALID")
}
func TestLifecycleServiceAuthorizesReferenceImageWithHashedClaim(
t *testing.T,
) {
task := domain.PurchaseTask{
ID: lifecycleTaskID,
ImageAssetID: "00000000-0000-4000-8000-000000000095",
}
repository := &fakeLifecycleRepository{claimedTask: task}
service := mustLifecycleService(t, repository)
rawToken := validTestToken(36)
result, err := service.AuthorizeReferenceImage(
context.Background(),
ReferenceImageCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
TaskID: lifecycleTaskID,
ClaimGeneration: 2,
ClaimToken: rawToken,
},
)
if err != nil || result.ImageAssetID != task.ImageAssetID {
t.Fatalf(
"AuthorizeReferenceImage() = %+v, error = %v",
result,
err,
)
}
if repository.claimedTaskRequest.ClaimTokenHash !=
hashSecret(rawToken) ||
repository.claimedTaskRequest.ClaimGeneration != 2 ||
!repository.claimedTaskRequest.Now.Equal(fakeClock{}.Now()) {
t.Fatalf(
"claimed task request = %+v",
repository.claimedTaskRequest,
)
}
}
type fakeLifecycleRepository struct {
heartbeatUpdate DeviceHeartbeatUpdate
heartbeatRecord DeviceHeartbeatRecord
heartbeatErr error
claimRequest ClaimNextRepositoryRequest
claimResult ClaimNextRepositoryResult
claimErr error
startRequest StartTaskRepositoryRequest
startResult StartTaskRepositoryResult
startErr error
taskHeartbeatRequest TaskHeartbeatRepositoryRequest
heartbeatTaskResult TaskHeartbeatRepositoryResult
taskHeartbeatErr error
claimedTaskRequest TaskClaimRepositoryRequest
claimedTask domain.PurchaseTask
claimedTaskErr error
releaseRequest ReleaseTaskRepositoryRequest
releaseTask domain.PurchaseTask
releaseReplayed bool
releaseErr error
cancelRequest CancelAcknowledgementRepositoryRequest
cancelTask domain.PurchaseTask
cancelReplayed bool
cancelErr error
}
func (repository *fakeLifecycleRepository) RecordDeviceHeartbeat(
_ context.Context,
update DeviceHeartbeatUpdate,
) (DeviceHeartbeatRecord, error) {
repository.heartbeatUpdate = update
return repository.heartbeatRecord, repository.heartbeatErr
}
func (repository *fakeLifecycleRepository) ClaimNext(
_ context.Context,
request ClaimNextRepositoryRequest,
) (ClaimNextRepositoryResult, error) {
repository.claimRequest = request
return repository.claimResult, repository.claimErr
}
func (repository *fakeLifecycleRepository) StartTask(
_ context.Context,
request StartTaskRepositoryRequest,
) (StartTaskRepositoryResult, error) {
repository.startRequest = request
return repository.startResult, repository.startErr
}
func (repository *fakeLifecycleRepository) HeartbeatTask(
_ context.Context,
request TaskHeartbeatRepositoryRequest,
) (TaskHeartbeatRepositoryResult, error) {
repository.taskHeartbeatRequest = request
return repository.heartbeatTaskResult, repository.taskHeartbeatErr
}
func (repository *fakeLifecycleRepository) GetActiveClaimTask(
_ context.Context,
request TaskClaimRepositoryRequest,
) (domain.PurchaseTask, error) {
repository.claimedTaskRequest = request
return repository.claimedTask, repository.claimedTaskErr
}
func (repository *fakeLifecycleRepository) ReleaseTask(
_ context.Context,
request ReleaseTaskRepositoryRequest,
) (domain.PurchaseTask, bool, error) {
repository.releaseRequest = request
return repository.releaseTask,
repository.releaseReplayed,
repository.releaseErr
}
func (repository *fakeLifecycleRepository) AcknowledgeTaskCancellation(
_ context.Context,
request CancelAcknowledgementRepositoryRequest,
) (domain.PurchaseTask, bool, error) {
repository.cancelRequest = request
return repository.cancelTask,
repository.cancelReplayed,
repository.cancelErr
}
func mustLifecycleService(
t *testing.T,
repository LifecycleRepository,
) *LifecycleService {
t.Helper()
service, err := NewLifecycleService(
repository,
fakeClock{},
&sequenceIDs{},
10*time.Minute,
90*time.Second,
2*time.Minute,
)
if err != nil {
t.Fatalf("NewLifecycleService() error = %v", err)
}
return service
}
var _ LifecycleRepository = (*fakeLifecycleRepository)(nil)
+1 -1
View File
@@ -82,7 +82,7 @@ type TaskRepository interface {
string,
string,
) (domain.TaskDetail, error)
CancelPendingTask(
CancelTask(
context.Context,
string,
string,
+1 -1
View File
@@ -378,7 +378,7 @@ func (s *TaskService) Cancel(
Message: "task canceled",
OccurredAt: now,
}
task, err := s.repository.CancelPendingTask(
task, err := s.repository.CancelTask(
ctx,
command.CreatorSubject,
command.TaskID,
@@ -203,7 +203,7 @@ func (repository *fakeTaskRepository) GetTaskDetail(
return domain.TaskDetail{}, errors.New("not implemented")
}
func (repository *fakeTaskRepository) CancelPendingTask(
func (repository *fakeTaskRepository) CancelTask(
_ context.Context,
_ string,
_ string,