784 lines
20 KiB
Go
784 lines
20 KiB
Go
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,
|
|
)
|
|
}
|