2026-09-06 17:04:11 +08:00
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"
2026-09-12 08:57:29 +08:00
"data-terminal-cloud/internal/devicebinding"
2026-09-06 17:04:11 +08:00
"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 {
2026-09-12 08:57:29 +08:00
BindingGeneration int64 `json:"binding_generation,omitempty"`
2026-09-06 17:04:11 +08:00
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"`
2026-09-12 08:57:29 +08:00
Claim * Claim `json:"claim,omitempty"`
2026-09-06 17:04:11 +08:00
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
2026-09-12 08:57:29 +08:00
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 {
2026-09-06 17:04:11 +08:00
TenantID : & tenantID , ActorKey : "device:" + identity . PublicID ,
Scope : "POST /device/v3/factory-reset" , Key : idempotencyKey ,
RequestHash : idempotency . Hash ( rawRequest ), ExpiresAfter : 7 * 24 * time . Hour ,
2026-09-12 08:57:29 +08:00
}, guard , func ( operationContext context . Context , tx pgx . Tx ) ( idempotency . Result , error ) {
2026-09-06 17:04:11 +08:00
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 , & currentCounter ); 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
}
2026-09-12 08:57:29 +08:00
if err := devicebinding . RequireUnboundForReset ( ctx , tx , identity . ID ); err != nil {
return Response {}, err
}
2026-09-06 17:04:11 +08:00
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 )
}
2026-09-12 08:57:29 +08:00
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 {
2026-09-06 17:04:11 +08:00
return Response {}, err
}
2026-09-12 08:57:29 +08:00
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
}
}
2026-09-06 17:04:11 +08:00
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" },
}
2026-09-12 08:57:29 +08:00
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
}
}
2026-09-06 17:04:11 +08:00
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
}