Files
cmroubao/backend-api/internal/usecase/lifecycle_service.go
T

784 lines
20 KiB
Go
Raw Normal View History

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