2026-08-29 21:56:51 +08:00
package saas
import (
"context"
"errors"
"fmt"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
2026-09-06 17:04:11 +08:00
"data-terminal-cloud/internal/deviceactivation"
2026-08-29 21:56:51 +08:00
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
const (
DeviceBindingActionUnbind = "unbind"
DeviceBindingActionTransfer = "transfer"
)
type DeviceBindingImpact struct {
DeviceID string `json:"device_id"`
BindingID string `json:"binding_id"`
Action string `json:"action"`
SourceHouseholdID string `json:"source_household_id"`
SourceSubjectID string `json:"source_subject_id"`
SourceSubjectName string `json:"source_subject_name"`
TargetHouseholdID * string `json:"target_household_id,omitempty"`
TargetSubjectID * string `json:"target_subject_id,omitempty"`
TargetSubjectName * string `json:"target_subject_name,omitempty"`
ActiveConversationID * string `json:"active_conversation_id,omitempty"`
PendingCommandCount int `json:"pending_command_count"`
ActiveFirmwareTaskCount int `json:"active_firmware_task_count"`
Allowed bool `json:"allowed"`
Blockers [] string `json:"blockers"`
EvaluatedAt time . Time `json:"evaluated_at"`
}
type ChangeDeviceBindingInput struct {
Action string
ExpectedBindingID string
TargetSubjectID string
Reason string
Acknowledged bool
}
type DeviceBindingChangeResult struct {
DeviceID string `json:"device_id"`
Action string `json:"action"`
PreviousBindingID string `json:"previous_binding_id"`
BindingID * string `json:"binding_id,omitempty"`
HouseholdID * string `json:"household_id,omitempty"`
SubjectID * string `json:"subject_id,omitempty"`
ChangedAt time . Time `json:"changed_at"`
}
func ( s * Service ) GetDeviceBindingImpact ( ctx context . Context , principal identity . Principal , devicePublicID , action , targetSubjectPublicID string ) ( DeviceBindingImpact , error ) {
access , device , err := resolveDevice ( ctx , s . pool , principal , devicePublicID , identity . CapabilityDeviceManage , false )
if err != nil {
return DeviceBindingImpact {}, err
}
return s . readDeviceBindingImpact ( ctx , s . pool , principal , access , device , action , targetSubjectPublicID )
}
func ( s * Service ) ChangeDeviceBinding ( ctx context . Context , principal identity . Principal , devicePublicID , requestID string , input ChangeDeviceBindingInput ) ( DeviceBindingChangeResult , error ) {
var result DeviceBindingChangeResult
err := database . Transact ( ctx , s . pool , func ( tx pgx . Tx ) error {
access , device , err := resolveDevice ( ctx , tx , principal , devicePublicID , identity . CapabilityDeviceManage , true )
if err != nil {
return err
}
var bindingID string
if err := tx . QueryRow ( ctx , `SELECT public_id::text FROM device_binding WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND unbound_at IS NULL FOR UPDATE` , access . deviceID , access . tenantID ). Scan ( & bindingID ); errors . Is ( err , pgx . ErrNoRows ) {
return ErrDeviceBindingStale
} else if err != nil {
return fmt . Errorf ( "lock active device binding: %w" , err )
}
if bindingID != input . ExpectedBindingID {
return ErrDeviceBindingStale
}
impact , err := s . readDeviceBindingImpact ( ctx , tx , principal , access , device , input . Action , input . TargetSubjectID )
if err != nil {
return err
}
if ! impact . Allowed {
return ErrDeviceBindingBlocked
}
now := time . Now (). UTC ()
if _ , err := tx . Exec ( ctx , `UPDATE device_binding SET unbound_at=$1,unbound_by=$2::uuid,reason=$3 WHERE public_id=$4::uuid AND unbound_at IS NULL` , now , access . userID , input . Action + ":" + strings . TrimSpace ( input . Reason ), bindingID ); err != nil {
return fmt . Errorf ( "close active device binding: %w" , err )
}
result = DeviceBindingChangeResult { DeviceID : devicePublicID , Action : input . Action , PreviousBindingID : bindingID , ChangedAt : now }
if input . Action == DeviceBindingActionTransfer {
targetAccess , _ , err := resolveSubject ( ctx , tx , principal , input . TargetSubjectID , identity . CapabilityDeviceManage , true )
if err != nil || targetAccess . tenantID != access . tenantID {
return ErrAccessDenied
}
var newBindingID string
if err := tx . QueryRow ( ctx , `
INSERT INTO device_binding (tenant_id,device_id,household_id,subject_id,bound_by,reason,bound_at)
VALUES ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6,$7)
RETURNING public_id::text` , access . tenantID , access . deviceID , targetAccess . householdID , targetAccess . subjectID , access . userID , "transfer:" + strings . TrimSpace ( input . Reason ), now ). Scan ( & newBindingID ); err != nil {
return fmt . Errorf ( "create transferred device binding: %w" , err )
}
result . BindingID = & newBindingID
result . HouseholdID = & targetAccess . householdPublicID
result . SubjectID = & input . TargetSubjectID
2026-09-06 17:04:11 +08:00
} else {
var activationState string
if err := tx . QueryRow ( ctx , `SELECT activation_state FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE` , access . deviceID , access . tenantID ). Scan ( & activationState ); err != nil {
return fmt . Errorf ( "read unbound device activation: %w" , err )
}
if activationState == string ( deviceactivation . Bound ) || activationState == string ( deviceactivation . Provisioning ) ||
activationState == string ( deviceactivation . Active ) || activationState == string ( deviceactivation . Lost ) {
if _ , err := deviceactivation . Apply ( ctx , tx , deviceactivation . Input { DeviceID : access . deviceID , TenantID : access . tenantID ,
Target : deviceactivation . Claimable , Reason : "用户解除设备绑定" , Source : "user-unbind" , ActorType : "user" ,
ActorPublicID : & access . userPublicID , RequestID : requestID }); err != nil {
return fmt . Errorf ( "transition unbound device activation: %w" , err )
}
}
if _ , err := tx . Exec ( ctx , `UPDATE hardware_agent_provider_credential SET status='SUSPENDED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'` , access . deviceID ); err != nil {
return fmt . Errorf ( "suspend unbound device provider credential: %w" , err )
}
2026-08-29 21:56:51 +08:00
}
return s . audit . Record ( ctx , tx , audit . Event {
TenantID : strPtr ( access . tenantID ), ActorType : "user" , ActorPublicID : strPtr ( access . userPublicID ),
Action : "device.binding." + input . Action , ResourceType : "device" , ResourceID : strPtr ( devicePublicID ),
RequestID : requestID , Result : "success" , Detail : map [ string ] any {
"previous_binding_id" : bindingID , "new_binding_id" : result . BindingID , "target_household_id" : result . HouseholdID ,
"target_subject_id" : result . SubjectID , "reason" : strings . TrimSpace ( input . Reason ),
},
})
})
return result , err
}
func ( s * Service ) readDeviceBindingImpact ( ctx context . Context , q queryRow , principal identity . Principal , access deviceAccess , device ManagedDevice , action , targetSubjectPublicID string ) ( DeviceBindingImpact , error ) {
if action != DeviceBindingActionUnbind && action != DeviceBindingActionTransfer {
return DeviceBindingImpact {}, ErrNotFound
}
impact := DeviceBindingImpact {
DeviceID : device . ID , BindingID : device . BindingID , Action : action ,
SourceHouseholdID : device . HouseholdID , SourceSubjectID : device . SubjectID , SourceSubjectName : device . SubjectName ,
Blockers : make ([] string , 0 ), EvaluatedAt : time . Now (). UTC (),
}
if action == DeviceBindingActionTransfer {
if targetSubjectPublicID == "" || targetSubjectPublicID == device . SubjectID {
impact . Blockers = append ( impact . Blockers , "target_subject_required" )
} else {
targetAccess , target , err := resolveSubject ( ctx , q , principal , targetSubjectPublicID , identity . CapabilityDeviceManage , false )
if err != nil || targetAccess . tenantID != access . tenantID {
return DeviceBindingImpact {}, ErrAccessDenied
}
impact . TargetHouseholdID = & target . HouseholdID
impact . TargetSubjectID = & target . ID
impact . TargetSubjectName = & target . PreferredName
}
}
var activeConversation string
err := q . QueryRow ( ctx , `SELECT public_id::text FROM conversation WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED') ORDER BY created_at DESC LIMIT 1` , access . deviceID , access . tenantID ). Scan ( & activeConversation )
if err == nil {
impact . ActiveConversationID = & activeConversation
impact . Blockers = append ( impact . Blockers , "active_conversation" )
} else if ! errors . Is ( err , pgx . ErrNoRows ) {
return DeviceBindingImpact {}, fmt . Errorf ( "check active device conversation: %w" , err )
}
if err := q . QueryRow ( ctx , `SELECT count(*) FROM device_command WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now()` , access . deviceID , access . tenantID ). Scan ( & impact . PendingCommandCount ); err != nil {
return DeviceBindingImpact {}, fmt . Errorf ( "count pending device commands: %w" , err )
}
if impact . PendingCommandCount > 0 {
impact . Blockers = append ( impact . Blockers , "pending_device_commands" )
}
if err := q . QueryRow ( ctx , `SELECT count(*) FROM firmware_assignment WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('PENDING','COMMAND_QUEUED','DOWNLOADING','INSTALLING')` , access . deviceID , access . tenantID ). Scan ( & impact . ActiveFirmwareTaskCount ); err != nil {
return DeviceBindingImpact {}, fmt . Errorf ( "count active firmware assignments: %w" , err )
}
if impact . ActiveFirmwareTaskCount > 0 {
impact . Blockers = append ( impact . Blockers , "active_firmware_task" )
}
impact . Allowed = len ( impact . Blockers ) == 0
return impact , nil
}