package devicereset import ( "context" "crypto/rand" "crypto/sha256" "encoding/base32" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "strings" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/deviceactivation" "data-terminal-cloud/internal/deviceauth" "data-terminal-cloud/internal/devicebinding" "data-terminal-cloud/internal/idempotency" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ( ErrInvalid = errors.New("device factory reset input is invalid") ErrStateConflict = errors.New("device state does not allow factory reset") ErrResetCounterConflict = errors.New("device factory reset counter is stale") ErrFirmwareInProgress = errors.New("device firmware update is in progress") ErrPendingTranscripts = errors.New("device has pending transcripts") ) type Input struct { ResetID string `json:"reset_id"` ResetCounter int64 `json:"reset_counter"` OccurredAt time.Time `json:"occurred_at"` Trigger string `json:"trigger"` FirmwareVersion string `json:"firmware_version"` PendingTurnCount int `json:"pending_turn_count"` ActiveConversationID string `json:"active_conversation_id,omitempty"` } type Claim struct { Code string `json:"code"` IssuedAt time.Time `json:"issued_at"` ExpiresAt time.Time `json:"expires_at"` } type Response struct { BindingGeneration int64 `json:"binding_generation,omitempty"` ResetID string `json:"reset_id"` DeviceID string `json:"device_id"` ActivationState string `json:"activation_state"` ResetCounter int64 `json:"reset_counter"` BindingRemoved bool `json:"binding_removed"` PreviousBindingID string `json:"previous_binding_id,omitempty"` ProviderCredentialStatus string `json:"provider_credential_status"` LicenseAction string `json:"license_action"` EndedConversationCount int `json:"ended_conversation_count"` CancelledCommandCount int64 `json:"cancelled_command_count"` ConfigRevision int64 `json:"config_revision"` Claim *Claim `json:"claim,omitempty"` LocalActions []string `json:"local_actions"` ResetAt time.Time `json:"reset_at"` } type Service struct { pool *pgxpool.Pool executor *idempotency.Executor audit *audit.Repository } func NewService(pool *pgxpool.Pool, executor *idempotency.Executor, auditRepository *audit.Repository) (*Service, error) { if pool == nil || executor == nil || auditRepository == nil { return nil, errors.New("device factory reset dependencies are required") } return &Service{pool: pool, executor: executor, audit: auditRepository}, nil } func Validate(input Input) error { if _, err := uuid.Parse(strings.TrimSpace(input.ResetID)); err != nil || input.ResetCounter < 1 || input.OccurredAt.IsZero() || input.OccurredAt.After(time.Now().UTC().Add(5*time.Minute)) || len(strings.TrimSpace(input.FirmwareVersion)) < 1 || len(strings.TrimSpace(input.FirmwareVersion)) > 80 || input.PendingTurnCount < 0 { return ErrInvalid } if input.Trigger != "physical_long_press" && input.Trigger != "device_menu" && input.Trigger != "service_tool" { return ErrInvalid } if input.ActiveConversationID != "" { if _, err := uuid.Parse(input.ActiveConversationID); err != nil { return ErrInvalid } } return nil } func (s *Service) Reset(ctx context.Context, identity deviceauth.Identity, idempotencyKey, requestID string, rawRequest []byte, input Input) (Response, bool, error) { if err := Validate(input); err != nil { return Response{}, false, err } if input.PendingTurnCount > 0 { return Response{}, false, ErrPendingTranscripts } tenantID := identity.TenantID if err := devicebinding.RequireUnboundForReset(ctx, s.pool, identity.ID); err != nil { return Response{}, false, err } guard := func(ctx context.Context, tx pgx.Tx, cached *idempotency.Result) error { var mode string var generation, counter int64 if err := tx.QueryRow(ctx, `SELECT enrollment_mode,binding_generation,factory_reset_counter FROM device WHERE id=$1::uuid FOR UPDATE`, identity.ID).Scan(&mode, &generation, &counter); err != nil { return err } if mode != "physical_confirm" { return nil } if generation != identity.BindingGeneration { return ErrStateConflict } if err := devicebinding.RequireUnboundForReset(ctx, tx, identity.ID); err != nil { return err } if cached != nil { var previous Response if err := json.Unmarshal(cached.Body, &previous); err != nil { return err } if counter != input.ResetCounter || generation != previous.BindingGeneration { return ErrResetCounterConflict } } return nil } result, err := s.executor.ExecuteGuarded(ctx, idempotency.Request{ TenantID: &tenantID, ActorKey: "device:" + identity.PublicID, Scope: "POST /device/v3/factory-reset", Key: idempotencyKey, RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 7 * 24 * time.Hour, }, guard, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) { response, resetErr := s.reset(operationContext, tx, identity, requestID, input) if resetErr != nil { return idempotency.Result{}, resetErr } body, marshalErr := json.Marshal(response) if marshalErr != nil { return idempotency.Result{}, marshalErr } resourceID := identity.PublicID return idempotency.Result{StatusCode: http.StatusOK, Body: body, ResourceID: &resourceID}, nil }) if err != nil { return Response{}, false, err } var response Response if err := json.Unmarshal(result.Body, &response); err != nil { return Response{}, result.Replayed, fmt.Errorf("decode device factory reset response: %w", err) } return response, result.Replayed, nil } func (s *Service) reset(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string, input Input) (Response, error) { var activationState string var currentCounter int64 if err := tx.QueryRow(ctx, ` SELECT activation_state,factory_reset_counter FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid AND status='registered' FOR UPDATE`, identity.ID, identity.TenantID).Scan(&activationState, ¤tCounter); err != nil { return Response{}, fmt.Errorf("lock device for factory reset: %w", err) } if activationState != string(deviceactivation.Claimable) && activationState != string(deviceactivation.Bound) && activationState != string(deviceactivation.Provisioning) && activationState != string(deviceactivation.Active) { return Response{}, ErrStateConflict } if input.ResetCounter <= currentCounter { return Response{}, ErrResetCounterConflict } if err := devicebinding.RequireUnboundForReset(ctx, tx, identity.ID); err != nil { return Response{}, err } var activeFirmware int if err := tx.QueryRow(ctx, ` SELECT count(*) FROM firmware_assignment WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('ASSIGNED','DOWNLOADING','INSTALLING')`, identity.ID, identity.TenantID).Scan(&activeFirmware); err != nil { return Response{}, fmt.Errorf("check active firmware update: %w", err) } if activeFirmware > 0 { return Response{}, ErrFirmwareInProgress } now := time.Now().UTC() endedConversations, err := endActiveConversations(ctx, tx, identity, requestID, now) if err != nil { return Response{}, err } var previousBindingID, previousHouseholdID, previousSubjectID string err = tx.QueryRow(ctx, ` UPDATE device_binding SET unbound_at=$3,unbound_by=NULL,reason='device_factory_reset' WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND unbound_at IS NULL RETURNING public_id::text,household_id::text,subject_id::text`, identity.ID, identity.TenantID, now). Scan(&previousBindingID, &previousHouseholdID, &previousSubjectID) bindingRemoved := err == nil if err != nil && !errors.Is(err, pgx.ErrNoRows) { return Response{}, fmt.Errorf("close device binding for factory reset: %w", err) } if bindingRemoved { if err := notifyPreviousOwner(ctx, tx, identity, previousHouseholdID, previousSubjectID, now); err != nil { return Response{}, err } } if _, err := tx.Exec(ctx, ` UPDATE device_claim SET revoked_at=$3 WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND used_at IS NULL AND revoked_at IS NULL`, identity.ID, identity.TenantID, now); err != nil { return Response{}, fmt.Errorf("revoke old device claims: %w", err) } providerStatus := "NOT_PROVISIONED" if tag, err := tx.Exec(ctx, ` UPDATE hardware_agent_provider_credential SET status='SUSPENDED',updated_at=$3 WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status='ACTIVE'`, identity.ID, identity.TenantID, now); err != nil { return Response{}, fmt.Errorf("suspend provider credential: %w", err) } else if tag.RowsAffected() > 0 { providerStatus = "SUSPENDED" } else if err := tx.QueryRow(ctx, `SELECT status FROM hardware_agent_provider_credential WHERE device_id=$1::uuid`, identity.ID).Scan(&providerStatus); err != nil && !errors.Is(err, pgx.ErrNoRows) { return Response{}, fmt.Errorf("read provider credential status: %w", err) } if _, err := tx.Exec(ctx, `UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=$2 WHERE device_id=$1::uuid AND status='ACTIVE'`, identity.ID, now); err != nil { return Response{}, fmt.Errorf("disable hardware agent binding: %w", err) } var cancelledCommands int64 if tag, err := tx.Exec(ctx, ` UPDATE device_command SET status='CANCELLED',result_summary='{"error_code":"DEVICE_FACTORY_RESET"}'::jsonb, completed_at=$3,version=version+1,updated_at=$3 WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED')`, identity.ID, identity.TenantID, now); err != nil { return Response{}, fmt.Errorf("cancel device commands: %w", err) } else { cancelledCommands = tag.RowsAffected() } if err := transitionToClaimable(ctx, tx, identity, requestID); err != nil { return Response{}, err } var configRevision int64 if err := tx.QueryRow(ctx, ` UPDATE device_twin SET desired_revision=desired_revision+1,reported_revision=NULL,reported_config='{}'::jsonb, capabilities='{}'::jsonb,updated_at=$3 WHERE device_id=$1::uuid AND tenant_id=$2::uuid RETURNING desired_revision`, identity.ID, identity.TenantID, now).Scan(&configRevision); err != nil { return Response{}, fmt.Errorf("reset device twin: %w", err) } if _, err := tx.Exec(ctx, ` UPDATE device SET factory_reset_counter=$3,version=version+1,updated_at=$4 WHERE id=$1::uuid AND tenant_id=$2::uuid`, identity.ID, identity.TenantID, input.ResetCounter, now); err != nil { return Response{}, fmt.Errorf("record factory reset counter: %w", err) } var claim *Claim var mode string if err = tx.QueryRow(ctx, `SELECT enrollment_mode FROM device WHERE id=$1::uuid`, identity.ID).Scan(&mode); err != nil { return Response{}, err } if mode == "legacy" { value, e := issueClaim(ctx, tx, identity, now) if e != nil { return Response{}, e } claim = &value } else { _, err = tx.Exec(ctx, `UPDATE device SET binding_generation=binding_generation+1,onboarding_revision=onboarding_revision+1,onboarding_boot_id=NULL WHERE id=$1::uuid`, identity.ID) if err != nil { return Response{}, err } _, err = tx.Exec(ctx, `UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='FACTORY_RESET' WHERE device_id=$1::uuid`, identity.ID) if err != nil { return Response{}, err } _, err = tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, identity.ID) if err != nil { return Response{}, err } _, err = tx.Exec(ctx, `UPDATE onboarding_web_session SET revoked_at=COALESCE(revoked_at,now()) WHERE setup_id IN (SELECT id FROM device_setup_session WHERE device_id=$1::uuid)`, identity.ID) if err != nil { return Response{}, err } } response := Response{ ResetID: input.ResetID, DeviceID: identity.PublicID, ActivationState: string(deviceactivation.Claimable), ResetCounter: input.ResetCounter, BindingRemoved: bindingRemoved, PreviousBindingID: previousBindingID, ProviderCredentialStatus: providerStatus, LicenseAction: "RETAINED", EndedConversationCount: endedConversations, CancelledCommandCount: cancelledCommands, ConfigRevision: configRevision, Claim: claim, ResetAt: now, LocalActions: []string{"clear_volc_credential", "clear_user_context", "clear_session_state", "clear_network_credentials", "reboot_to_claimable"}, } if mode == "physical_confirm" { if err = tx.QueryRow(ctx, `SELECT binding_generation FROM device WHERE id=$1::uuid`, identity.ID).Scan(&response.BindingGeneration); err != nil { return Response{}, err } } actorID := identity.PublicID if err := s.audit.Record(ctx, tx, audit.Event{ TenantID: &identity.TenantID, ActorType: "device", ActorPublicID: &actorID, Action: "device.factory_reset", ResourceType: "device", ResourceID: &actorID, RequestID: requestID, Result: "success", Detail: map[string]any{ "reset_id": input.ResetID, "reset_counter": input.ResetCounter, "trigger": input.Trigger, "firmware_version": input.FirmwareVersion, "previous_binding_id": previousBindingID, "device_reported_conversation_id": input.ActiveConversationID, "ended_conversation_count": endedConversations, "cancelled_command_count": cancelledCommands, "provider_credential_status": providerStatus, "license_action": "RETAINED", }, }); err != nil { return Response{}, err } return response, nil } func notifyPreviousOwner(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, householdID, subjectID string, now time.Time) error { var wasOnline bool err := tx.QueryRow(ctx, ` UPDATE device_presence_state SET online=false,changed_at=$4 WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND household_id=$3::uuid AND online RETURNING true`, identity.TenantID, identity.ID, householdID, now).Scan(&wasOnline) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return fmt.Errorf("mark factory-reset device offline: %w", err) } if wasOnline { if _, err := tx.Exec(ctx, `SELECT append_web_event($1::uuid,$2::uuid,$3::uuid,'device.presence.changed','device',$4::uuid,'offline')`, identity.TenantID, householdID, subjectID, identity.PublicID); err != nil { return fmt.Errorf("publish factory-reset presence event: %w", err) } } if _, err := tx.Exec(ctx, `SELECT append_web_event($1::uuid,$2::uuid,$3::uuid,'device.binding.reset','device',$4::uuid,'CLAIMABLE')`, identity.TenantID, householdID, subjectID, identity.PublicID); err != nil { return fmt.Errorf("publish factory-reset binding event: %w", err) } return nil } func endActiveConversations(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string, now time.Time) (int, error) { rows, err := tx.Query(ctx, ` SELECT id::text,status FROM conversation WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED') FOR UPDATE`, identity.ID, identity.TenantID) if err != nil { return 0, fmt.Errorf("lock active conversations for factory reset: %w", err) } type activeConversation struct{ id, status string } items := make([]activeConversation, 0) for rows.Next() { var item activeConversation if err := rows.Scan(&item.id, &item.status); err != nil { rows.Close() return 0, err } items = append(items, item) } rows.Close() if err := rows.Err(); err != nil { return 0, err } for _, item := range items { if _, err := tx.Exec(ctx, ` UPDATE conversation SET status='FAILED',ended_at=$2,stop_reason='device_factory_reset', failure_code='DEVICE_FACTORY_RESET',version=version+1,updated_at=$2 WHERE id=$1::uuid`, item.id, now); err != nil { return 0, fmt.Errorf("fail conversation during factory reset: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,request_id,occurred_at) VALUES($1::uuid,$2,'FAILED','device factory reset','device',NULLIF($3,''),$4)`, item.id, item.status, requestID, now); err != nil { return 0, fmt.Errorf("record factory reset conversation transition: %w", err) } if _, err := tx.Exec(ctx, ` UPDATE hardware_agent_session SET status='FAILED',provider_ended_at=COALESCE(provider_ended_at,$2),updated_at=$2 WHERE conversation_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')`, item.id, now); err != nil { return 0, fmt.Errorf("fail hardware agent session during factory reset: %w", err) } if _, err := tx.Exec(ctx, ` UPDATE rtc_task SET status='FAILED',updated_at=$2 WHERE conversation_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED')`, item.id, now); err != nil { return 0, fmt.Errorf("fail rtc task during factory reset: %w", err) } } return len(items), nil } func transitionToClaimable(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string) error { var stateText string if err := tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&stateText); err != nil { return fmt.Errorf("read device activation state after reset cleanup: %w", err) } state, err := deviceactivation.Parse(stateText) if err != nil { return ErrStateConflict } actorID := identity.PublicID apply := func(target deviceactivation.State, reason string) error { _, applyErr := deviceactivation.Apply(ctx, tx, deviceactivation.Input{ DeviceID: identity.ID, TenantID: identity.TenantID, Target: target, Reason: reason, Source: "device-factory-reset", ActorType: "device", ActorPublicID: &actorID, RequestID: requestID, }) return applyErr } if state == deviceactivation.Provisioning { if err := apply(deviceactivation.Bound, "设备恢复出厂,结束预配"); err != nil { return fmt.Errorf("return provisioning device to bound state: %w", err) } state = deviceactivation.Bound } if state != deviceactivation.Claimable { if state != deviceactivation.Bound && state != deviceactivation.Active && state != deviceactivation.Lost { return ErrStateConflict } if err := apply(deviceactivation.Claimable, "设备恢复出厂并解除用户绑定"); err != nil { return fmt.Errorf("make factory-reset device claimable: %w", err) } } return nil } func issueClaim(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, issuedAt time.Time) (Claim, error) { randomBytes := make([]byte, 10) if _, err := rand.Read(randomBytes); err != nil { return Claim{}, fmt.Errorf("generate factory reset claim: %w", err) } rawCode := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes) code := strings.Join([]string{rawCode[0:4], rawCode[4:8], rawCode[8:12], rawCode[12:16]}, "-") digest := sha256.Sum256([]byte(code)) expiresAt := issuedAt.Add(24 * time.Hour) if _, err := tx.Exec(ctx, ` INSERT INTO device_claim(tenant_id,device_id,token_digest,expires_at,created_at) VALUES($1::uuid,$2::uuid,$3,$4,$5)`, identity.TenantID, identity.ID, hex.EncodeToString(digest[:]), expiresAt, issuedAt); err != nil { return Claim{}, fmt.Errorf("insert factory reset claim: %w", err) } return Claim{Code: code, IssuedAt: issuedAt, ExpiresAt: expiresAt}, nil }