feat(tasks): implement atomic claims and leases
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user