diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go
index 61628d6..abf51f0 100644
--- a/backend-api/internal/transport/webui/types.go
+++ b/backend-api/internal/transport/webui/types.go
@@ -23,7 +23,7 @@ type Service interface {
GetTask(context.Context, string) (Task, error)
UploadReference(context.Context, UploadReferenceInput) (UploadedAsset, error)
CreateTask(context.Context, CreateTaskInput) (Task, error)
- CancelPending(context.Context, CancelPendingInput) (Task, error)
+ CancelTask(context.Context, CancelTaskInput) (Task, error)
}
type ListTasksInput struct {
@@ -81,7 +81,7 @@ type CreateTaskInput struct {
ImageAssetID string
}
-type CancelPendingInput struct {
+type CancelTaskInput struct {
TaskID string
IdempotencyKey string
}
diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go
index d43af47..c592522 100644
--- a/backend-api/internal/transport/webui/usecase_adapter.go
+++ b/backend-api/internal/transport/webui/usecase_adapter.go
@@ -128,9 +128,9 @@ func actorUserID(ctx context.Context) string {
return principal.UserID
}
-func (adapter *UsecaseAdapter) CancelPending(
+func (adapter *UsecaseAdapter) CancelTask(
ctx context.Context,
- input CancelPendingInput,
+ input CancelTaskInput,
) (Task, error) {
task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{
CreatorSubject: localAdminSubject,
diff --git a/backend-api/internal/usecase/lifecycle_errors.go b/backend-api/internal/usecase/lifecycle_errors.go
new file mode 100644
index 0000000..8912dc0
--- /dev/null
+++ b/backend-api/internal/usecase/lifecycle_errors.go
@@ -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)
+ }
+}
diff --git a/backend-api/internal/usecase/lifecycle_ports.go b/backend-api/internal/usecase/lifecycle_ports.go
new file mode 100644
index 0000000..90ecd6c
--- /dev/null
+++ b/backend-api/internal/usecase/lifecycle_ports.go
@@ -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)
+}
diff --git a/backend-api/internal/usecase/lifecycle_service.go b/backend-api/internal/usecase/lifecycle_service.go
new file mode 100644
index 0000000..9358240
--- /dev/null
+++ b/backend-api/internal/usecase/lifecycle_service.go
@@ -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,
+ )
+}
diff --git a/backend-api/internal/usecase/lifecycle_service_test.go b/backend-api/internal/usecase/lifecycle_service_test.go
new file mode 100644
index 0000000..c26cf29
--- /dev/null
+++ b/backend-api/internal/usecase/lifecycle_service_test.go
@@ -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)
diff --git a/backend-api/internal/usecase/ports.go b/backend-api/internal/usecase/ports.go
index 7045bed..516edda 100644
--- a/backend-api/internal/usecase/ports.go
+++ b/backend-api/internal/usecase/ports.go
@@ -82,7 +82,7 @@ type TaskRepository interface {
string,
string,
) (domain.TaskDetail, error)
- CancelPendingTask(
+ CancelTask(
context.Context,
string,
string,
diff --git a/backend-api/internal/usecase/task_service.go b/backend-api/internal/usecase/task_service.go
index 0d14fbb..a41528c 100644
--- a/backend-api/internal/usecase/task_service.go
+++ b/backend-api/internal/usecase/task_service.go
@@ -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,
diff --git a/backend-api/internal/usecase/task_service_test.go b/backend-api/internal/usecase/task_service_test.go
index a9ee021..469440d 100644
--- a/backend-api/internal/usecase/task_service_test.go
+++ b/backend-api/internal/usecase/task_service_test.go
@@ -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,
diff --git a/backend-api/migrations/00004_claims_and_lifecycle.sql b/backend-api/migrations/00004_claims_and_lifecycle.sql
new file mode 100644
index 0000000..53e04b9
--- /dev/null
+++ b/backend-api/migrations/00004_claims_and_lifecycle.sql
@@ -0,0 +1,289 @@
+-- +goose Up
+ALTER TABLE devices
+ ADD COLUMN accessibility_enabled INTEGER NOT NULL DEFAULT 0
+ CHECK (accessibility_enabled IN (0, 1));
+
+ALTER TABLE devices
+ ADD COLUMN pdd_installed INTEGER NOT NULL DEFAULT 0
+ CHECK (pdd_installed IN (0, 1));
+
+ALTER TABLE devices
+ ADD COLUMN readiness_reported_at TEXT;
+
+ALTER TABLE purchase_tasks
+ ADD COLUMN claimed_by_user_id TEXT
+ REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
+
+ALTER TABLE purchase_tasks
+ ADD COLUMN claimed_by_device_id TEXT
+ REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
+
+ALTER TABLE purchase_tasks
+ ADD COLUMN claim_generation INTEGER NOT NULL DEFAULT 0
+ CHECK (claim_generation >= 0);
+
+ALTER TABLE purchase_tasks
+ ADD COLUMN claim_token_hash TEXT
+ CHECK (
+ claim_token_hash IS NULL
+ OR (
+ length(claim_token_hash) = 64
+ AND claim_token_hash NOT GLOB '*[^0-9a-f]*'
+ )
+ );
+
+ALTER TABLE purchase_tasks
+ ADD COLUMN claim_issued_at TEXT;
+
+ALTER TABLE purchase_tasks
+ ADD COLUMN claim_expires_at TEXT;
+
+ALTER TABLE purchase_tasks
+ ADD COLUMN cancel_requested_at TEXT;
+
+ALTER TABLE purchase_tasks
+ ADD COLUMN cancel_requested_by_user_id TEXT
+ REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
+
+CREATE UNIQUE INDEX purchase_tasks_active_device_idx
+ ON purchase_tasks (claimed_by_device_id)
+ WHERE claimed_by_device_id IS NOT NULL
+ AND status IN ('CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION');
+
+CREATE TABLE task_executions (
+ id TEXT PRIMARY KEY NOT NULL
+ CHECK (length(id) = 36),
+ task_id TEXT NOT NULL
+ REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
+ attempt_no INTEGER NOT NULL
+ CHECK (attempt_no > 0),
+ claim_generation INTEGER NOT NULL
+ CHECK (claim_generation > 0),
+ user_id TEXT NOT NULL
+ REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
+ device_id TEXT NOT NULL
+ REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
+ current_step TEXT NOT NULL
+ CHECK (
+ length(trim(current_step)) > 0
+ AND length(CAST(current_step AS BLOB)) <= 64
+ ),
+ last_heartbeat_at TEXT NOT NULL,
+ order_submitted INTEGER NOT NULL DEFAULT 0
+ CHECK (order_submitted = 0),
+ started_at TEXT NOT NULL,
+ finished_at TEXT,
+ UNIQUE (task_id, attempt_no),
+ UNIQUE (task_id, claim_generation)
+);
+
+CREATE UNIQUE INDEX task_executions_active_task_idx
+ ON task_executions (task_id)
+ WHERE finished_at IS NULL;
+
+CREATE INDEX task_executions_device_heartbeat_idx
+ ON task_executions (device_id, last_heartbeat_at DESC);
+
+CREATE TABLE lifecycle_requests (
+ user_id TEXT NOT NULL
+ REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
+ device_id TEXT NOT NULL
+ REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
+ operation TEXT NOT NULL
+ CHECK (
+ operation IN ('CLAIM_NEXT', 'START', 'RELEASE', 'CANCEL_ACK')
+ ),
+ idempotency_key TEXT NOT NULL
+ CHECK (
+ length(trim(idempotency_key)) > 0
+ AND length(CAST(idempotency_key AS BLOB)) <= 128
+ ),
+ request_sha256 TEXT NOT NULL
+ CHECK (
+ length(request_sha256) = 64
+ AND request_sha256 NOT GLOB '*[^0-9a-f]*'
+ ),
+ result_kind TEXT NOT NULL
+ CHECK (result_kind IN ('TASK', 'NO_TASK', 'EXECUTION')),
+ task_id TEXT
+ REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
+ claim_generation INTEGER
+ CHECK (claim_generation IS NULL OR claim_generation > 0),
+ execution_id TEXT
+ REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (user_id, device_id, operation, idempotency_key),
+ CHECK (
+ (
+ operation = 'CLAIM_NEXT'
+ AND result_kind = 'NO_TASK'
+ AND task_id IS NULL
+ AND claim_generation IS NULL
+ AND execution_id IS NULL
+ )
+ OR (
+ operation = 'CLAIM_NEXT'
+ AND result_kind = 'TASK'
+ AND task_id IS NOT NULL
+ AND claim_generation IS NOT NULL
+ AND execution_id IS NULL
+ )
+ OR (
+ operation = 'START'
+ AND result_kind = 'EXECUTION'
+ AND task_id IS NOT NULL
+ AND claim_generation IS NOT NULL
+ AND execution_id IS NOT NULL
+ )
+ OR (
+ operation = 'RELEASE'
+ AND result_kind = 'TASK'
+ AND task_id IS NOT NULL
+ AND claim_generation IS NOT NULL
+ AND execution_id IS NULL
+ )
+ OR (
+ operation = 'CANCEL_ACK'
+ AND result_kind = 'TASK'
+ AND task_id IS NOT NULL
+ AND claim_generation IS NOT NULL
+ AND execution_id IS NOT NULL
+ )
+ )
+);
+
+ALTER TABLE task_events RENAME TO task_events_v3;
+
+CREATE TABLE task_events (
+ id TEXT PRIMARY KEY NOT NULL
+ CHECK (length(id) = 36),
+ task_id TEXT NOT NULL
+ REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
+ event_type TEXT NOT NULL
+ CHECK (
+ event_type IN (
+ 'TASK_CREATED',
+ 'TASK_CLAIMED',
+ 'TASK_RECLAIMED',
+ 'TASK_RELEASED',
+ 'TASK_STARTED',
+ 'TASK_CANCEL_REQUESTED',
+ 'TASK_CANCELED'
+ )
+ ),
+ message TEXT NOT NULL,
+ occurred_at TEXT NOT NULL,
+ actor_user_id TEXT
+ REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
+ actor_device_id TEXT
+ REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT
+);
+
+INSERT INTO task_events (
+ id,
+ task_id,
+ event_type,
+ message,
+ occurred_at,
+ actor_user_id,
+ actor_device_id
+)
+SELECT
+ id,
+ task_id,
+ event_type,
+ message,
+ occurred_at,
+ actor_user_id,
+ NULL
+FROM task_events_v3;
+
+DROP TABLE task_events_v3;
+
+CREATE INDEX task_events_task_occurred_idx
+ ON task_events (task_id, occurred_at ASC, id ASC);
+
+CREATE INDEX task_events_actor_user_idx
+ ON task_events (actor_user_id, occurred_at DESC, id DESC);
+
+CREATE INDEX task_events_actor_device_idx
+ ON task_events (actor_device_id, occurred_at DESC, id DESC);
+
+-- +goose Down
+CREATE TEMP TABLE task_events_v4_down_guard (
+ allowed INTEGER NOT NULL
+ CHECK (allowed = 1)
+);
+
+INSERT INTO task_events_v4_down_guard (allowed)
+SELECT CASE
+ WHEN EXISTS (
+ SELECT 1
+ FROM task_events
+ WHERE event_type NOT IN ('TASK_CREATED', 'TASK_CANCELED')
+ OR actor_device_id IS NOT NULL
+ )
+ THEN 0
+ ELSE 1
+END;
+
+DROP TABLE task_events_v4_down_guard;
+
+ALTER TABLE task_events RENAME TO task_events_v4;
+
+CREATE TABLE task_events (
+ id TEXT PRIMARY KEY NOT NULL
+ CHECK (length(id) = 36),
+ task_id TEXT NOT NULL
+ REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
+ event_type TEXT NOT NULL
+ CHECK (event_type IN ('TASK_CREATED', 'TASK_CANCELED')),
+ message TEXT NOT NULL,
+ occurred_at TEXT NOT NULL,
+ actor_user_id TEXT
+ REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT
+);
+
+INSERT INTO task_events (
+ id,
+ task_id,
+ event_type,
+ message,
+ occurred_at,
+ actor_user_id
+)
+SELECT
+ id,
+ task_id,
+ event_type,
+ message,
+ occurred_at,
+ actor_user_id
+FROM task_events_v4;
+
+DROP TABLE task_events_v4;
+
+CREATE INDEX task_events_task_occurred_idx
+ ON task_events (task_id, occurred_at ASC, id ASC);
+
+CREATE INDEX task_events_actor_user_idx
+ ON task_events (actor_user_id, occurred_at DESC, id DESC);
+
+DROP TABLE lifecycle_requests;
+DROP INDEX task_executions_device_heartbeat_idx;
+DROP INDEX task_executions_active_task_idx;
+DROP TABLE task_executions;
+DROP INDEX purchase_tasks_active_device_idx;
+
+ALTER TABLE purchase_tasks DROP COLUMN cancel_requested_by_user_id;
+ALTER TABLE purchase_tasks DROP COLUMN cancel_requested_at;
+ALTER TABLE purchase_tasks DROP COLUMN claim_expires_at;
+ALTER TABLE purchase_tasks DROP COLUMN claim_issued_at;
+ALTER TABLE purchase_tasks DROP COLUMN claim_token_hash;
+ALTER TABLE purchase_tasks DROP COLUMN claim_generation;
+ALTER TABLE purchase_tasks DROP COLUMN claimed_by_device_id;
+ALTER TABLE purchase_tasks DROP COLUMN claimed_by_user_id;
+
+ALTER TABLE devices DROP COLUMN readiness_reported_at;
+ALTER TABLE devices DROP COLUMN pdd_installed;
+ALTER TABLE devices DROP COLUMN accessibility_enabled;
diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md
index 652c5d8..77de6cb 100644
--- a/docs/00-ai-start-here.md
+++ b/docs/00-ai-start-here.md
@@ -53,8 +53,9 @@
当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、
动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已
-验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web 和 T-204 最小鉴权
-均已完成。下一步按编号开始 T-205,实现 App 原子领取、租约和任务状态机。
+验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web、T-204 最小鉴权
+和 T-205 原子领取/租约状态机均已完成。下一步按编号开始 T-206,把 Android
+`HttpTaskSource`、前台服务和已确认的任务页面接到设备 API。
严格按以下顺序推进:
diff --git a/docs/02-requirements.md b/docs/02-requirements.md
index 2c2a8b1..0d95f7a 100644
--- a/docs/02-requirements.md
+++ b/docs/02-requirements.md
@@ -15,7 +15,7 @@
## 二、用户角色
-- **采购管理员**:创建、查看、取消尚未执行的任务并查看执行结果。
+- **采购管理员**:创建、查看、取消未执行任务,或请求执行中任务安全停止,并查看结果。
- **采购执行员**:在 App 上检查设备状态、手动领取、执行和人工确认任务。
- **设备身份**:代表一台获得授权的 Android 设备领取任务和上报状态。
- **系统管理员/审核员**:目标架构角色,MVP 不提供完整管理界面。
@@ -38,7 +38,7 @@
| 功能 | 说明 | 阶段 |
| --- | --- | --- |
| 完整 RBAC | 采购管理员、执行员、审核员和系统管理员的细粒度权限。 | V2 |
-| 多设备调度 | 设备心跳、任务队列、租约回收和容量调度。 | V2 |
+| 多设备容量调度 | 在当前原子领取/租约基础上增加优先级、容量、运营监控和跨实例调度。 | V2 |
| 后台通知 | WebSocket/厂商推送只通知有任务,App 仍通过 claim 领取。 | V2 |
| 订单提交审批 | 在金额、店铺和品类规则内,经人工审批后允许提交订单。 | V2,需单独安全评审 |
| 多平台比价 | 淘宝、1688、京东等平台。 | V3 |
@@ -50,10 +50,11 @@
2. 数量必须是正整数;最高商品总预算如填写,必须大于零。总预算表示当前任务全部
数量的商品金额上限,不包含尚无法可靠确认的运费、优惠或支付金额。
3. SKU、数量和最高商品总预算以管理员输入为准,模型不得更改。
-4. 一台设备同一时间最多有一条 `CLAIMED` 或 `RUNNING` 任务。
+4. 一台设备同一时间最多有一条未过期 `CLAIMED` 或任意
+ `RUNNING/WAITING_CONFIRMATION` 任务。
5. 一条任务同一时间只能被一台设备持有;重复点击不能产生重复领取。
-6. App 未就绪时不能开始:无障碍未授权、拼多多未安装、设备离线或已有运行任务都
- 必须说明原因。
+6. App 未就绪时不能领取:无障碍未连接、拼多多未安装、设备 heartbeat 过期或已有
+ 活跃任务都必须说明原因。
7. 遇到验证码、登录失效、风控提示、页面未知、预算不满足或模型低置信度时停止,
不猜测点击。
8. MVP 的“成功”表示完成验证闭环并得到人工确认的候选结果,
@@ -67,7 +68,14 @@
普通日志;禁用用户或设备后,已有凭证必须立即失效。
13. 管理登录和 App token 登录必须在高成本密码校验前按服务端观察到的来源地址限流;
超限返回通用错误和重试时间,不得泄露账号、角色或设备是否存在。
-14. 管理员创建和取消任务时,任务事件必须记录真实 ADMIN actor;历史事件允许为空。
+14. 管理员创建/取消和 App 状态迁移必须记录真实 user actor;设备操作还必须记录
+ device actor。heartbeat 不写高频事件;历史事件允许 actor 为空。
+15. claim token 由 App 在请求前生成并安全保存,必须与幂等 key 独立;数据库只保存
+ SHA-256,API 和日志不得回显原值或 hash。
+16. `CLAIMED` 租约过期后可以被原子回收;`RUNNING/WAITING_CONFIRMATION` 租约
+ 过期后不得自动回到队列,避免失联设备与新设备重复执行。
+17. 管理取消 `PENDING/CLAIMED` 可立即终态;执行中只设置取消请求,必须等 App 在
+ 安全检查点确认停止后才能进入 `CANCELED`。
## 六、第一层本地样本约定
@@ -110,7 +118,8 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi
- F-002/US-002/IX-003:状态变化后管理页面能看到最新阶段、时间、设备、候选摘要
或结构化错误;无权限用户不可访问。
- F-003/US-003/IX-005:App 点击“获取任务”后原子领取一条任务;重复点击或多请求
- 不得领取第二条或把同一任务分配两次。
+ 不得领取第二条或把同一任务分配两次。T-205 已完成后端 heartbeat、原子 claim、
+ client-generated token 安全重放、参考图授权和租约;Android 接入属于 T-206。
- F-004/US-004/IX-006:解析结果包含搜索词、识别属性、预算、数量、置信度和警告;
原始输入保留,硬约束与输入一致。T-103 已实现版本化 schema、0.75 置信阈值、
冲突转人工以及 SKU/数量的本地确定性回填;当前样本未提供预算,因此预算保持空。
diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md
index 6009628..2ed79dd 100644
--- a/docs/03-tech-stack.md
+++ b/docs/03-tech-stack.md
@@ -27,7 +27,7 @@
| App 鉴权 | BUYER 密码 + 预授权设备 secret + 1 小时 opaque token | T-204 已验证 | 首次原子绑定空闲设备;数据库只存 token SHA-256,不提供自助登记/refresh。 |
| VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | T-103/T-104 使用严格 JSON Schema、单候选单次调用和 2048 px 图片上限;GUI-Owl/MAI-UI 动作模型不具备需求提取能力。 |
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 |
-| 后端测试 | 标准库 `testing` + `httptest` | T-204 已验证 | 当前 142 个测试覆盖配置、迁移、图片限制、任务事务、bcrypt、会话/令牌、设备原子绑定、登录限流、角色/CSRF 隔离、HTTP 生命周期和安全响应。 |
+| 后端测试 | 标准库 `testing` + `httptest` | T-205 已验证 | 当前 192 个测试覆盖配置、迁移、图片限制、任务事务、鉴权隔离、设备就绪、原子领取、幂等重放、租约状态机、取消确认、跨连接与真实 TCP 并发。 |
| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | Phase 1 探针已验证 | 166 次测试覆盖 runner、动态页面分类、受控证据、VLM schema、人工确认策略与隐私;OnePlus PKG110 上完成私有 fixture + 本机 mock 的需求提取和 5 候选评估 smoke。 |
| 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 |
@@ -127,6 +127,9 @@ docs/
T-203 已建立 `domain/usecase/repository/sqlite` 和 `transport/webui`,handler 不直接
执行 SQL,Web 与 JSON API 复用同一 usecase。
+T-205 已在该分层上增加独立 `LifecycleService` 与 SQLite immediate transaction,
+固定 client-generated claim token、设备 readiness、CLAIMED/运行租约、execution、
+安全取消和 task-scoped 参考图授权;Android HTTP TaskSource 仍由 T-206 接入。
## 构建与运行命令
@@ -137,7 +140,7 @@ T-203 已建立 `domain/usecase/repository/sqlite` 和 `transport/webui`,handl
| Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;当前全工程 166 次测试通过 |
| Android 安装/启动 | `$env:RUN_START_COMMAND="1"; .\init.ps1` | 已在 Android 16 真机验证 |
| 后端依赖 | `$env:GOTOOLCHAIN="local"; go mod download`(在 `backend-api/`) | 已验证 |
-| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go vet ./...` | 已验证;99 个测试 |
+| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go test -race ./...; go vet ./...` | 已验证;192 个测试,全包 race 通过 |
| 后端构建 | `go build -o bin/cmroubao-api.exe ./cmd/api` 与 `./cmd/migrate` | 已验证 |
| 后端启动 | `go run ./cmd/api` | 已完成本机 HTTP smoke |
| 数据迁移 | `go run ./cmd/migrate up` | 已完成 `status/up/up/down/up` smoke |
diff --git a/docs/04-architecture.md b/docs/04-architecture.md
index 0b1f67b..8368c06 100644
--- a/docs/04-architecture.md
+++ b/docs/04-architecture.md
@@ -292,10 +292,16 @@ CLAIMED/RUNNING/WAITING_CONFIRMATION
规则:
- `SUCCEEDED` 是验证结果成功,不代表已下单;`order_submitted=false`。
-- `CLAIMED` 的默认租约计划为 10 分钟,运行时每 30 秒心跳续租;数值进入配置。
+- `CLAIMED` 默认租约为 10 分钟;`RUNNING/WAITING_CONFIRMATION` 默认租约为
+ 90 秒,App 计划每 30 秒 heartbeat 续租。三个时长均由有上下界的环境变量配置。
- 只有当前设备和有效 claim token 能启动、续租、上报或结束任务。
- 终态不可回退;重试创建新的 execution attempt,不篡改历史证据。
-- 取消正在自动化的任务时,App 应在下一个安全检查点停止。
+- App 在 claim 前生成并安全保存 256 bit Raw URL token;后端只存 SHA-256,
+ token 与 `Idempotency-Key` 相互独立。
+- 过期 `CLAIMED` 可以原子回收;过期 `RUNNING/WAITING_CONFIRMATION` 保持原状态,
+ 不自动回队列。
+- 管理取消 `PENDING/CLAIMED` 立即终态;执行中只设置停止请求,App 在下一个安全
+ 检查点停止并调用 `cancel-ack` 后才进入 `CANCELED`。
### 4.2 Android 工作流状态
@@ -348,6 +354,9 @@ IDLE
| `android_version` | nullable | Android 系统版本 |
| `pdd_version` | nullable | 已验证拼多多版本 |
| `last_seen_at` | nullable | 最近心跳 |
+| `readiness_reported_at` | nullable | 最近一次就绪上报的服务端时间 |
+| `accessibility_enabled` | NOT NULL | 无障碍连接就绪位 |
+| `pdd_installed` | NOT NULL | 拼多多安装就绪位 |
| `is_enabled` | NOT NULL | 后端开关 |
设备必须先由本地管理命令预授权。首次 BUYER 联合登录可以把 `bound_user_id` 为空的
@@ -378,17 +387,28 @@ IDLE
| `quantity` | `> 0` | 权威数量 |
| `max_budget` | `> 0`, nullable | 全部数量的权威最高商品总预算,币种为 CNY |
| `status` | NOT NULL | 任务状态枚举 |
-| `created_by` | FK | 创建人 |
+| `claimed_by_user_id` | FK, nullable | 当前采购员 |
| `claimed_by_device_id` | FK, nullable | 当前设备 |
+| `claim_generation` | `>= 0` | 每次领取递增,释放后保留用于审计 |
+| `claim_token_hash` | nullable | 64 位小写 SHA-256;永不保存原 token |
+| `claim_issued_at` | nullable | 当前 claim 签发时间 |
| `claim_expires_at` | nullable | 租约到期时间 |
+| `cancel_reason` | nullable | 管理取消原因 |
+| `cancel_requested_at` | nullable | 执行中停止请求时间 |
+| `cancel_requested_by_user_id` | FK, nullable | 请求停止的 ADMIN |
+| `canceled_at` | nullable | 取消终态时间 |
| `version` | NOT NULL | 乐观锁/状态并发控制 |
| `created_at/updated_at` | NOT NULL | 审计时间 |
### `task_events`
-- 事件只追加;T-204 已实现 `TASK_CREATED`、`TASK_CANCELED`。
+- 事件只追加;T-205 已实现 `TASK_CREATED`、`TASK_CLAIMED`、
+ `TASK_RECLAIMED`、`TASK_RELEASED`、`TASK_STARTED`、
+ `TASK_CANCEL_REQUESTED`、`TASK_CANCELED`。
- `actor_user_id` 是指向 `users` 的可空外键。新管理操作必须写入真实 ADMIN,
T-203 历史事件保持为空。
+- `actor_device_id` 是指向 `devices` 的可空外键;App 状态迁移同时记录用户和设备。
+- 设备/任务 heartbeat 不写事件,避免高频审计膨胀。
- 管理任务详情 API 可返回 actor;密码、session、设备 secret 和 access token 永不
进入事件。
@@ -399,17 +419,28 @@ IDLE
| `id` | PK | 一次执行尝试 |
| `task_id` | FK | 所属任务 |
| `attempt_no` | UNIQUE(task, no) | 尝试序号 |
+| `claim_generation` | UNIQUE(task, generation) | 对应的 claim 代次 |
| `device_id/user_id` | FK | 执行设备和人员 |
-| `extracted_requirements` | JSON | 已校验的模型结果 |
-| `candidate_result` | JSON, nullable | 候选及匹配理由 |
-| `outcome` | nullable | `CANDIDATE_ACCEPTED`、`CANDIDATE_REJECTED`、`NO_MATCH` 或 `MANUAL_REQUIRED` |
+| `current_step` | 1-64 bytes | 最近 heartbeat 的执行步骤 |
+| `last_heartbeat_at` | NOT NULL | 最近运行 heartbeat |
| `order_submitted` | NOT NULL, false | MVP 数据库约束必须为 false |
-| `error_code/error_message` | nullable | 结构化失败 |
-| `started_at/finished_at` | nullable | 执行耗时 |
+| `started_at/finished_at` | started 必填 | 执行耗时和安全结束 |
-### `execution_events` 与 `assets`
+候选、模型派生结果、outcome、结构化失败和 execution evidence 属于 T-207,不在
+T-205 的最小 execution 表中提前伪造。
-- `execution_events` 只追加,记录 step、事件类型、可读消息和时间;不保存密码或 token。
+### `lifecycle_requests`
+
+- 主键为 `user_id + device_id + operation + idempotency_key`。
+- operation 为 `CLAIM_NEXT`、`START`、`RELEASE`、`CANCEL_ACK`;保存请求 SHA-256
+ 与 task/generation/execution 结果引用。
+- `NO_TASK` 也是稳定结果;同 key 重放不会因后来新增任务而改变。
+- claim 原 token 不进入该表,请求 hash 只包含 token hash。
+
+### `assets` 与后续 `execution_events`
+
+- T-207 的 `execution_events` 只追加,记录 step、事件类型、可读消息和时间;不保存
+ 密码或 token。
- `assets` 保存文件相对路径、媒体类型、大小、哈希、创建者和保留时间。
- 原图和截图必须通过鉴权接口读取,文件名不能直接作为公开 URL。
- 任务参考图上传可接受 JPEG/PNG/WebP,但后端必须先真实解码、限制字节与像素,再
@@ -419,10 +450,16 @@ IDLE
- 合约以 [`api.md`](api.md) 为准。
- `claim-next` 必须由 usecase 调用 repository,在一个数据库事务内完成
- “选取 + 校验设备空闲 + 更新状态”。
-- 创建、领取和完成接口支持 `Idempotency-Key`。
+ “幂等检查 + 就绪/单活校验 + FIFO 选取/过期回收 + 更新 + 事件”。
+- 当前设备若有自己的过期 `CLAIMED`,优先原子回收该任务;没有时才在全局候选中
+ 按 `created_at ASC, id ASC` 选择。该例外保持设备唯一归属并避免静默清理审计状态。
+- SQLite DSN 使用 `_txlock=immediate`、WAL 和 busy timeout;跨两个独立数据库连接
+ 的并发测试必须精确得到一个领取者。
+- claim/start/release/cancel-ack 支持 `Idempotency-Key`;heartbeat 不需要幂等表。
- App 对网络超时不能假定失败;必须用任务详情确认服务端最终状态。
- 后端拒绝非法状态迁移,即使客户端 UI 隐藏了对应按钮。
+- App 参考图通过 task-scoped URL 读取;每次校验当前 user/device/generation/token/
+ 租约,不允许用可猜测 asset ID 越权读取其他任务。
- SQLite MVP 使用单后端进程;切换多实例前先迁移 PostgreSQL 并验证领取竞争。
- Gin handler 只做鉴权上下文、binding、调用 usecase 和响应映射。
diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md
index 6cb6fe1..fc88185 100644
--- a/docs/05-coding-rules.md
+++ b/docs/05-coding-rules.md
@@ -79,7 +79,12 @@
- 请求在边界完成类型、大小、媒体类型和业务规则校验。
- 领取任务必须使用事务;不得先 GET 再由客户端自行标记占用。
- 状态变化校验当前状态、设备归属、claim token、版本和租约。
-- 创建、领取、完成和证据上传的重试路径必须幂等。
+- App 必须在 claim 请求前生成并安全保存独立的 256 bit claim token 与幂等 key;
+ 后端只存 token SHA-256,响应、事件和日志不得返回原值或 hash。
+- claim/start/release/cancel-ack 和后续完成/证据上传的重试路径必须幂等;`NO_TASK`
+ 也必须是稳定幂等结果。
+- 设备/任务 heartbeat 使用服务端时钟且不写高频事件;过期 RUNNING 不得自动回到
+ 队列。管理取消执行中任务必须等 App 安全确认,不能提前伪装为终态。
- 不向客户端返回 `password_hash`、`token_hash`、存储绝对路径或供应商密钥。
- 管理 Cookie 与 BUYER 设备 Bearer token 使用独立 middleware,不得互相替代;
每次鉴权都重新检查用户/设备启用、绑定、撤销和过期状态。
@@ -92,7 +97,8 @@
- 创建和取消任务必须从认证上下文传入真实 ADMIN actor 并写入只追加事件;共享
`local-admin` 只用于 MVP 资源可见范围,不能冒充操作者。
- 通用错误使用稳定 code 和可读 message;内部堆栈只进受控日志。
-- 文件访问通过鉴权接口,防止路径遍历和猜测 URL。
+- 文件访问通过鉴权接口,防止路径遍历和猜测 URL;App 参考图读取必须重新校验当前
+ task/user/device/generation/token/租约,不能只凭 asset UUID 授权。
- Go 命令固定 `GOTOOLCHAIN=local`;`go.mod` 不得出现更高 Go 版本或未固定的
`@latest` 依赖。
- 后端不得自动加载 `.env`、默认开启 CORS、使用包级数据库单例,或在库、handler、
diff --git a/docs/07-user-stories.md b/docs/07-user-stories.md
index 3c7b820..e5249ac 100644
--- a/docs/07-user-stories.md
+++ b/docs/07-user-stories.md
@@ -72,7 +72,9 @@
1. 设备就绪且有待处理任务时,点击后只出现一条已领取任务。
2. 没有任务时显示空状态,不把空队列当作错误。
3. 无障碍缺失、拼多多不可用、设备禁用或已有活跃任务时,说明原因并禁止领取。
-4. 网络超时或重复点击后,刷新服务端状态不会产生重复领取。
+4. 网络超时或重复点击后,复用请求前已保存的 claim token 和幂等 key 可确认同一
+ 领取结果,不产生重复领取。
+5. 只有持有当前 claim 的用户和设备能读取该任务的参考图、开始或续租。
## US-004 自动检索并得到候选
@@ -131,7 +133,8 @@
1. 验证码、风控、登录失效、未知页面和安全边界分别产生可区分错误。
2. 网络中断时保留本地待上传证据,恢复后用幂等方式补报。
-3. 用户取消时工作流在安全检查点停止,任务不会继续在后台点击。
+3. 用户或管理员取消时,执行中任务先收到停止请求;App 在安全检查点停止并确认后
+ 才进入取消终态,不会继续在后台点击。
4. 重新打开终态任务可以看到相同结果和证据。
## US-007 建立受控会话和设备身份
diff --git a/docs/08-interaction-checklist.md b/docs/08-interaction-checklist.md
index 1601ef0..3cc63d1 100644
--- a/docs/08-interaction-checklist.md
+++ b/docs/08-interaction-checklist.md
@@ -150,7 +150,8 @@
- 无任务:显示安静的空状态和手动刷新,不循环弹错。
- 权限缺失:提供打开对应系统设置的命令。
- 已有任务:显示“继续任务”,不再领取。
-- 请求超时:先查询当前设备活跃任务,再允许重试。
+- 请求超时:保留请求前已安全保存的 claim token/幂等 key,先用同一请求重放并通过
+ heartbeat 对比服务端活跃任务,再允许生成新请求。
- 租约到期:预览页提示任务已释放,返回任务页。
**可访问性**
diff --git a/docs/api.md b/docs/api.md
index e37dc0a..0d4f357 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -254,8 +254,10 @@ T-204 已实现的 `TASK_CREATED`、`TASK_CANCELED` 事件包含可空
}
```
-T-203 只实现 `PENDING -> CANCELED`;其他状态返回 `409`。执行中设置取消请求及 App
-安全检查点响应属于 T-205。
+`PENDING/CLAIMED` 立即进入 `CANCELED`。`RUNNING/WAITING_CONFIRMATION` 只记录
+取消请求并保持原状态;重复请求不重复写事件。App 的任务 heartbeat 会返回
+`cancel_requested=true`,只有 App 在安全检查点调用 `cancel-ack` 后才进入
+`CANCELED` 并结束 execution。
## 设备与领取
@@ -265,8 +267,8 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
```json
{
- "device_id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8",
"app_version": "0.1.0",
+ "android_version": "16",
"pdd_version": "device-observed-value",
"readiness": {
"accessibility_enabled": true,
@@ -276,14 +278,20 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
}
```
+`device_id` 可以省略;若提供,必须与 Bearer token 中的设备一致。服务端时间是唯一
+租约时钟。响应返回服务端认定的 `active_task_id`、`client_state_matches`、
+readiness 上报时间和 `server_time`。heartbeat 超过配置 TTL 或任一就绪位为 false
+时,claim 返回 `409 DEVICE_NOT_READY`。
+
### `POST /api/v1/tasks/claim-next`
-由用户点击触发,在单个数据库事务中领取下一条任务。必须带 `Idempotency-Key`。
+由用户点击触发,在单个 SQLite immediate transaction 中领取下一条任务。App 必须
+在请求前生成并安全保存相互独立的 256 bit Raw URL `X-Claim-Token` 和
+`Idempotency-Key`;网络结果不确定时复用同一组值。服务端只保存 token 的
+SHA-256,响应和日志都不回显原 token 或 hash。
```json
-{
- "device_id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8"
-}
+{}
```
有任务时:
@@ -297,22 +305,50 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
"sku": "BLACK-20L",
"description": "容量约20L,外观接近参考图",
"image_asset_id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3",
+ "reference_image_url": "/api/v1/tasks/37c9c715-9b51-4ed5-984e-66dad2710c71/reference-image?claim_generation=1",
"quantity": 2,
- "max_budget": "200.00"
+ "max_budget": "200.00",
+ "currency": "CNY",
+ "version": 2,
+ "claim_generation": 1,
+ "claim_issued_at": "2026-07-25T08:30:00Z",
+ "claim_expires_at": "2026-07-25T08:40:00Z"
},
- "claim_token": "returned-once",
- "claim_expires_at": "2026-07-25T08:40:00Z"
+ "replayed": false,
+ "server_time": "2026-07-25T08:30:00Z"
}
```
-没有任务返回 `204`。设备未就绪或已有活跃任务返回 `409`。幂等重放返回同一领取
-结果;claim token 的安全重放形式在实现前通过测试固定。
+当前设备自己有已过期 `CLAIMED` 时优先回收该任务,避免设备唯一归属冲突;否则按
+`created_at ASC, id ASC` 选择 `PENDING` 或已过期 `CLAIMED`。没有任务返回 `204`;
+同 key 的无任务重放始终保持 `204`。同 key、同 token 的活跃 claim 重放返回同一
+任务,即使状态已进入 `RUNNING/WAITING_CONFIRMATION`;请求不同返回
+`409 IDEMPOTENCY_CONFLICT`;原 claim 已释放、取消或被其他领取回收时返回
+`409 CLAIM_REPLAY_EXPIRED`。同一设备不能再领取第二条活跃任务。
+
+### `GET /api/v1/tasks/{task_id}/reference-image`
+
+claim 响应给出的受保护参考图地址。请求使用 BUYER Bearer token、
+`X-Claim-Token` 和 URL 中的 `claim_generation`;只有当前用户、当前设备、匹配
+generation/token 且租约未过期的活跃任务可以读取。成功返回匿名规范化 JPEG,
+设置 `private, no-store`、`nosniff`、长度和 ETag,不返回存储路径。
### `POST /api/v1/tasks/{task_id}/start`
把当前设备持有的 `CLAIMED` 任务改为 `RUNNING` 并创建 execution。请求带
`X-Claim-Token` 和 `Idempotency-Key`。
+```json
+{
+ "claim_generation": 1,
+ "expected_version": 2
+}
+```
+
+同 key、同请求重放返回同一 execution;错误用户/设备/token 返回 `403`,过期租约、
+状态或版本冲突返回 `409`。成功响应包含更新后的 `task`、`execution`、`replayed`
+和 `server_time`,运行租约使用配置的 running lease。
+
### `POST /api/v1/tasks/{task_id}/heartbeat`
运行时续租并返回是否请求取消:
@@ -320,21 +356,56 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
- "step": "SCAN_RESULTS",
- "client_time": "2026-07-25T08:33:30Z"
+ "claim_generation": 1,
+ "step": "SCAN_RESULTS"
}
```
```json
{
- "claim_expires_at": "2026-07-25T08:43:30Z",
- "cancel_requested": false
+ "task": {
+ "id": "37c9c715-9b51-4ed5-984e-66dad2710c71",
+ "status": "RUNNING",
+ "version": 4,
+ "claim_generation": 1,
+ "claim_expires_at": "2026-07-25T08:35:00Z"
+ },
+ "execution": {
+ "id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
+ "current_step": "SCAN_RESULTS",
+ "order_submitted": false
+ },
+ "cancel_requested": false,
+ "server_time": "2026-07-25T08:33:30Z"
}
```
+只接受 1 至 64 字节的大写 ASCII step。heartbeat 使用服务端 UTC 更新 execution、
+设备最近在线时间、任务 version 和租约,不写高频任务事件;过期运行租约拒绝续租,
+任务保持原非终态且绝不回到领取队列。
+
### `POST /api/v1/tasks/{task_id}/release`
只允许尚未开始的 `CLAIMED` 任务释放回 `PENDING`。运行中使用取消/失败流程。
+请求带 `X-Claim-Token`、`Idempotency-Key`,body 与 start 相同。成功后清除当前
+用户、设备、token hash 和租约,但保留递增过的 `claim_generation` 供审计。
+
+### `POST /api/v1/tasks/{task_id}/cancel-ack`
+
+App 收到取消请求并在安全检查点停止后调用:
+
+```json
+{
+ "execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
+ "claim_generation": 1,
+ "expected_version": 5
+}
+```
+
+请求带 `X-Claim-Token` 和 `Idempotency-Key`。只有匹配的未结束 execution、有效
+运行租约和已存在的管理取消请求可以确认;成功把任务置为 `CANCELED`、结束
+execution、清除 claim 秘密并追加带用户/设备 actor 的事件。同 key 重放不产生第二
+个事件。
## AI 合约
@@ -488,7 +559,5 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
## 待实现前固定
- 上传大小、像素和保留期限的具体数值。
-- access token、管理会话和 claim lease 的最终时长。
-- claim token 幂等重放与轮换细节。
- VLM `confidence` 阈值、模型和提示词版本记录格式。
- 外部管理后台的服务账号认证方式和调用频率。
diff --git a/docs/current-state.md b/docs/current-state.md
index 5b5071a..020ee0f 100644
--- a/docs/current-state.md
+++ b/docs/current-state.md
@@ -5,30 +5,36 @@
## 当前快照
- 日期:2026-07-26
-- 阶段:T-204 用户与设备最小鉴权完成,下一步 T-205
+- 阶段:T-205 原子领取、租约和状态机完成,下一步 T-206
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-203
- 均已纳入 Git 历史;T-204 与本文同次提交
+ 均已纳入 Git 历史;T-204 已提交,T-205 与本文同次提交
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、
- SSR 管理 Web、ADMIN 会话、BUYER/预授权设备联合认证和 `authctl`
+ SSR 管理 Web、ADMIN/BUYER 联合认证、设备 readiness、原子 claim、租约状态机、
+ task-scoped 参考图和 `authctl`
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器
共 26 份报告、166 次测试,0 failure、0 error、0 skipped
-- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 142 个测试通过;
+- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 192 个测试通过;
全包 race、`go vet ./...`、API/migration/authctl Windows 构建和根 `init.ps1`
均通过
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
脚本错误或外部请求,Android 可见交互控件均不小于 44px
- 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和
- 待领取取消;ADMIN 登录/退出和安全返回路径已接入,同三种视口无横向溢出,
- 可见操作控件不小于 44px
+ 非终态取消;执行中只显示“请求安全停止”,ADMIN 登录/退出和安全返回路径已接入,
+ 同三种视口无横向溢出,可见任务操作控件不小于 44px
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
+- 生命周期:设备 heartbeat 记录三个版本、两个就绪位和服务端活跃任务;claim 使用
+ client-generated 256 bit token + 独立幂等 key、SQLite immediate transaction、
+ 10 分钟 CLAIMED/90 秒运行租约和 2 分钟 readiness TTL。start/heartbeat/release/
+ cancel-ack、过期回收、跨连接并发唯一领取、管理安全停止与 task-scoped 参考图均
+ 已实现;原 claim token/hash 不进入响应或日志
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
@@ -65,6 +71,7 @@
| `docs/tasks/T-202.md` | DONE | 生成并确认 P0 Web/App 低保真原型 |
| `docs/tasks/T-203.md` | DONE | 图片/任务 API、SQLite 业务层和 SSR 管理 Web |
| `docs/tasks/T-204.md` | DONE | 用户、管理会话和预授权设备联合身份 |
+| `docs/tasks/T-205.md` | DONE | 原子 claim、租约、execution 和取消安全确认 |
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
@@ -75,9 +82,9 @@
## 任务摘要
-- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-204。
+- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-205。
- 正在进行:无。
-- 下一个可领取任务:T-205 原子领取、租约和状态机。
+- 下一个可领取任务:T-206 App 接入手动领取和执行进度。
## 当前可运行内容
@@ -132,6 +139,15 @@ TLS。真实 SQLite/Gin smoke 验证 migration up/down/up、账号/设备预置
验证无横向溢出,发现并修复退出路由漏装配及 T-203 旧 CSRF Cookie 路径兼容问题。
142 个 Go 测试、全包 race/vet、Windows 三入口构建与根 `init.ps1` 均通过。
+2026-07-26 完成 T-205:新增设备 readiness、claim/execution/lifecycle migration,
+实现 client-generated claim token 安全重放、跨连接原子领取、CLAIMED/运行租约、
+start/heartbeat/release、管理取消请求、App cancel-ack 和当前 claim 参考图授权。
+真实 TCP 并发测试验证同设备同时 claim 只有一个成功;迁移 CLI 完成
+`status/up/up/down/up`。Playwright 连接真实服务验证管理任务详情在 1440×900、
+390×844、360×800 无横向溢出、破图或取消弹窗重叠;执行中安全停止文案由 SSR
+测试覆盖。192 个 Go 测试、全包 race/vet、Windows 三入口构建和根 `init.ps1`
+全部通过。
+
## 维护规则
发生以下变化时覆盖更新本文:
diff --git a/docs/routes.md b/docs/routes.md
index 3077a39..fa46c2a 100644
--- a/docs/routes.md
+++ b/docs/routes.md
@@ -30,6 +30,23 @@ Android 导航名称是逻辑目的地,具体 Compose/Fragment 形式待接入
| `task/{id}/result` | 任务结果 | 查看终态和同步状态 | US-002、US-006 | IX-007、IX-008 |
| `settings` | 设备设置 | 查看权限、版本、后端和模型连接状态 | US-003、US-007 | IX-004、IX-005 |
+## App 后端路由
+
+以下路由只接受有效 BUYER Bearer token,认证 principal 中的用户和设备是权威身份:
+
+| 路由 | 职责 |
+| --- | --- |
+| `POST /api/v1/devices/heartbeat` | 上报版本、就绪位并核对服务端活跃任务 |
+| `POST /api/v1/tasks/claim-next` | 用户点击后原子领取或重放领取结果 |
+| `GET /api/v1/tasks/{id}/reference-image` | 用当前 claim 读取该任务匿名参考图 |
+| `POST /api/v1/tasks/{id}/start` | `CLAIMED -> RUNNING` 并创建 execution |
+| `POST /api/v1/tasks/{id}/heartbeat` | 更新 step、设备在线时间和运行租约 |
+| `POST /api/v1/tasks/{id}/release` | 未开始时 `CLAIMED -> PENDING` |
+| `POST /api/v1/tasks/{id}/cancel-ack` | 安全停止后确认 `CANCELED` 并结束 execution |
+
+claim/start/release/cancel-ack 使用相应幂等规则;参考图、start、task heartbeat、
+release 和 cancel-ack 都必须匹配当前 `X-Claim-Token` 与 `claim_generation`。
+
## 导航规则
- App 有活跃任务时启动后优先恢复该任务,不允许直接领取下一条。
diff --git a/docs/tasks/T-205.md b/docs/tasks/T-205.md
new file mode 100644
index 0000000..9792360
--- /dev/null
+++ b/docs/tasks/T-205.md
@@ -0,0 +1,151 @@
+---
+id: T-205
+title: 实现原子 claim、租约和状态机
+phase: 2
+deps:
+ - T-203
+ - T-204
+status: DONE
+created: 2026-07-26
+context_ref: 49db5b8
+work_branch: main
+write_paths:
+ - README.md
+ - backend-api/**
+ - docs/00-ai-start-here.md
+ - docs/02-requirements.md
+ - docs/03-tech-stack.md
+ - docs/04-architecture.md
+ - docs/05-coding-rules.md
+ - docs/07-user-stories.md
+ - docs/08-interaction-checklist.md
+ - docs/api.md
+ - docs/current-state.md
+ - docs/routes.md
+ - docs/tasks/T-205.md
+ - progress.md
+---
+
+## 问题 / 背景
+
+T-203 已能创建 `PENDING` 任务,T-204 已建立 BUYER + 预授权设备联合身份,但 App
+还不能安全领取任务。现有正式文档只给出接口草图,未解决 claim token 只返回一次时
+如何在响应丢失后安全重放,也没有固定设备就绪新鲜度、运行中租约过期、取消确认和
+并发领取的精确语义。T-206 在这些合约固定前不能接入 HTTP TaskSource。
+
+## 关联需求与交互
+
+- 功能:F-003、F-007。
+- 用户故事:US-003、US-006。
+- 交互:IX-005、IX-008;本任务只实现后端,Android 页面接入属于 T-206。
+- 架构/API:任务状态机、`devices/heartbeat`、`claim-next`、`start`、任务 heartbeat、
+ `reference-image`、`release` 和取消安全停止。
+
+## 已定合约
+
+1. 所有设备接口只接受有效 BUYER Bearer token;认证上下文中的 `user_id/device_id`
+ 是权威身份。请求中的 `device_id` 必须与上下文一致,不能代表其他设备。
+2. App 在发起 claim 前生成独立的 256 bit Raw URL `X-Claim-Token` 并先安全保存,
+ 同时生成独立 `Idempotency-Key`。服务只保存 claim token 的 SHA-256,响应不回显
+ 原值;网络结果不确定时必须复用同一组值。
+3. claim 幂等记录按用户、设备、操作和 key 隔离,请求 hash 包含 claim token hash。
+ 每次成功领取递增 `claim_generation`。同 key 同请求在同一 generation 的租约仍
+ 属于该设备时返回同一任务;同 key 不同请求返回 `409`;原领取已释放、取消或被
+ 过期回收时返回稳定 `409 CLAIM_REPLAY_EXPIRED`。无任务的首次结果记录为
+ `NO_TASK`,同 key 重放仍返回 `204`。
+4. 设备 heartbeat 记录服务端时间、App/Android/拼多多版本和两个确定性就绪位:
+ 无障碍已连接、拼多多已安装。客户端上报的活跃任务只用于一致性检查,服务端任务
+ 归属是权威。heartbeat 超过 2 分钟或任一就绪位为 false 时禁止领取。
+5. 领取在单个 SQLite immediate transaction 内完成。若当前设备自己仍有一条已过期
+ `CLAIMED`,先回收该任务,避免设备唯一归属冲突;否则在 `PENDING` 或已过期
+ `CLAIMED` 中按创建时间最早、ID 最小选择。一个任务只能归属一台设备;同一设备
+ 只允许一个未过期 `CLAIMED` 或任意 `RUNNING/WAITING_CONFIRMATION` 任务。
+6. 初始 claim 租约默认 10 分钟。只有当前用户、当前设备、匹配 token hash 且租约
+ 未过期时可以 start、release、heartbeat 或确认取消。所有到期计算只使用服务端
+ UTC 时钟,不信任 `client_time`。
+7. `start` 只允许 `CLAIMED -> RUNNING`,在同一事务创建唯一 execution attempt,
+ 并把运行租约改为默认 90 秒;请求携带 claim 响应的 `expected_version`,
+ `Idempotency-Key` 同请求重放返回同一 execution。
+8. 运行 heartbeat 每 30 秒计划调用,只有 `RUNNING/WAITING_CONFIRMATION` 且
+ execution/generation 匹配时才更新 step、`last_seen_at` 和 90 秒运行租约。
+ 过期运行租约拒绝续租和后续自动操作,任务保持当前非终态且绝不自动回到队列,
+ 防止失联设备与新设备重复执行;恢复/失败归档后置。
+9. `release` 只允许未过期 `CLAIMED -> PENDING`,请求必须匹配 `expected_version`,
+ 并清除用户、设备、token 和租约。已开始任务不能 release。过期 `CLAIMED` 可由
+ 下一次 claim 原子回收。
+10. 管理取消 `PENDING/CLAIMED` 时立即进入 `CANCELED`;`RUNNING` 或
+ `WAITING_CONFIRMATION` 只设置取消请求,不伪装为已停止。任务 heartbeat 返回
+ `cancel_requested=true`,App 在安全检查点以 `expected_version` 调用
+ `cancel-ack` 后才进入 `CANCELED` 并结束 execution。
+11. 每次状态迁移递增 `version` 并追加带 actor 的任务事件;heartbeat 只更新租约和
+ 当前 step,不追加高频事件。claim/start/heartbeat/transition 响应都返回当前
+ `version`、`claim_generation`、服务端时间和租约到期时间。终态不可回退,非法
+ 状态、错误设备、错误 token、execution 不匹配和过期租约使用彼此稳定但不泄露
+ 其他任务内容的错误码。
+12. 三个时长通过配置提供安全默认值和上下界:claim lease 10 分钟、运行 lease
+ 90 秒、设备 heartbeat TTL 2 分钟。T-205 不启动后台定时清理器。
+13. 参考图不暴露资产存储路径。App 只能通过 claim 响应中的 task-scoped URL,
+ 携带当前 BUYER token、设备身份、`X-Claim-Token` 和 `claim_generation` 读取;
+ 服务端同时校验当前状态和未过期租约,并返回 `private, no-store` JPEG。
+
+## 方案
+
+1. 新增 `00004_claims_and_lifecycle.sql`,扩展任务 claim/cancel 字段、设备就绪字段,
+ 增加 execution 与生命周期幂等表,并扩展任务事件枚举;迁移必须可空兼容历史数据。
+2. 在 domain 增加 claim、execution、状态迁移和就绪实体;在独立
+ `LifecycleService` 中完成输入校验、token hash、服务端时钟、错误映射和响应模型。
+3. SQLite repository 使用显式事务完成 claim/reclaim、start、release、heartbeat、
+ 取消请求/确认和幂等重放;所有条件更新同时校验状态、归属、token、租约和版本。
+4. Gin 增加独立 BUYER 设备路由组,复用 T-204 Bearer middleware;handler 只解析
+ header/body、读取认证 principal、调用 usecase 和映射稳定响应。
+5. 管理取消从仅 PENDING 扩展为上述安全语义;管理详情返回 claim、execution 和
+ 取消请求的非秘密摘要,永不返回 claim token/hash。
+6. claim 响应只给出 task-scoped 参考图 URL;图片 handler 在读取本地资产前重新
+ 授权当前 claim,避免长期资产 URL 或跨任务枚举。
+
+## 验收要点
+
+- [x] migration 可 up/down/up,历史任务保持可读,数据库不存在原始 claim token。
+- [x] heartbeat 身份、版本长度、就绪位和新鲜度校验正确,客户端不能冒充设备。
+- [x] 多 goroutine/多连接并发 claim 同一任务时精确一个成功,不重复分配。
+- [x] 同一设备不能领取第二条活跃任务;不同设备可以领取不同任务。
+- [x] claim 首次响应丢失后用同 key/token 重放得到同一任务;冲突/过期重放被拒绝。
+- [x] 无任务的幂等重放保持 204,不因稍后新增任务改变旧请求结果。
+- [x] start 精确创建一个 execution;重复 start 同 key 返回同一 execution。
+- [x] 错误设备、错误 token、过期 CLAIMED 租约、非法状态均不能 start/release。
+- [x] 当前 claim 才能读取参考图,错误 token/generation/设备或过期租约均被拒绝。
+- [x] RUNNING heartbeat 续租并返回取消标志;过期运行租约不会回队列或被其他设备领取。
+- [x] 管理取消活跃任务只请求停止;App 安全确认后才进入 CANCELED。
+- [x] 事件 actor、version、execution attempt 和状态迁移可审计,响应/日志不泄露秘密。
+- [x] Go test/race/vet/gofmt、迁移/HTTP 并发 smoke、根脚本全部通过。
+
+## 边界
+
+- 不实现 Android HTTP TaskSource、token 安全存储、前台服务或页面;属于 T-206。
+- 不实现候选、执行证据、批量事件、完成/失败结果归档;属于 T-207。
+- 不实现后台通知、WebSocket、跨实例锁、自动重试或多设备调度运营页面。
+- 不把过期 `RUNNING` 自动回到 `PENDING`,也不允许任何路径提交订单或支付。
+- 不提交运行数据库、claim/access token、真实设备心跳或私有订单样本。
+
+## 执行记录
+
+### 2026-07-26:任务开始
+
+- 基于提交 `49db5b8` 开始,工作区干净;T-203/T-204 依赖均已完成。
+- codebase-memory MCP 本轮仍未暴露 graph 工具,按项目规则回退到 `rg` 和定点读取。
+- 先固定 client-generated claim token、安全幂等重放、就绪 TTL、租约过期与取消确认
+ 语义,避免在迁移和 handler 写完后再改变跨端协议。
+
+### 2026-07-26:实现与验证完成
+
+- 新增 migration 00004、生命周期领域/用例/SQLite 仓储和 BUYER Gin 路由;管理端
+ 取消扩展为 `PENDING/CLAIMED` 立即取消、`RUNNING/WAITING_CONFIRMATION`
+ 请求安全停止,并在任务详情展示非秘密 claim/execution 摘要。
+- `go test -count=1 ./...` 共 192 个测试通过;全包 `go test -race -count=1 ./...`、
+ `go vet ./...`、`gofmt -d .`、三入口 Windows 构建和根 `init.ps1` 均通过。
+- migration CLI 完成 `status -> up -> up -> down -> up`,第二次 up 无重复应用;
+ 两个独立数据库连接和真实 TCP 并发 claim 都验证同设备精确一个成功。
+- Playwright 连接真实 Gin/SQLite 流程验证任务详情在 1440x900、390x844、360x800
+ 无横向溢出、破图或弹窗重叠;运行中“请求安全停止”分支由 SSR 测试覆盖。
+- 独立审查发现并修复 start 后 claim 幂等重放被误判过期的问题;同设备优先恢复
+ 自己过期 CLAIMED 的亲和策略已补充文档和测试。未发现 High 严重度问题。
diff --git a/progress.md b/progress.md
index db2cf11..e3fa9cf 100644
--- a/progress.md
+++ b/progress.md
@@ -137,3 +137,12 @@
- 影响:匿名与跨端凭证不能访问任务,新任务保留共享 `local-admin` scope 并单独记录
真实 ADMIN actor,创建/取消事件均可审计;T-205 可在稳定 BUYER + device 身份上
实现原子领取和租约。
+
+## 2026-07-26 原子领取、租约和状态机
+
+- 类型:阶段完成
+- 内容:完成 T-205;实现设备 readiness、client-generated claim token、SQLite
+ 原子领取、CLAIMED/运行租约、execution、task-scoped 参考图、管理安全停止和
+ App 取消确认。
+- 影响:后端已具备 Android 手动领取和执行进度所需的稳定协议;T-206 可接入
+ HTTP TaskSource、凭证/claim token 安全存储和前台执行界面。