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, ) }