2026-08-26 06:41:07 +08:00
package saas
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"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-26 06:41:07 +08:00
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
type DeviceClaimResult struct {
BindingID string `json:"binding_id"`
DeviceID string `json:"device_id"`
HouseholdID string `json:"household_id"`
SubjectID string `json:"subject_id"`
BoundAt time . Time `json:"bound_at"`
}
func ( s * Service ) ClaimDevice ( ctx context . Context , principal identity . Principal , claimCode , householdPublicID , subjectPublicID , requestID string ) ( DeviceClaimResult , bool , error ) {
var result DeviceClaimResult
var replayed bool
digestBytes := sha256 . Sum256 ([] byte ( claimCode ))
digest := hex . EncodeToString ( digestBytes [:])
err := database . Transact ( ctx , s . pool , func ( tx pgx . Tx ) error {
access , err := resolveHouseholdAccess ( ctx , tx , principal , householdPublicID , identity . CapabilityDeviceManage , true )
if err != nil {
return err
}
var subjectID string
if err := tx . QueryRow ( ctx , `
SELECT id::text FROM subject_profile
WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND household_id=$3::uuid AND status <> 'deleted'
FOR SHARE` , subjectPublicID , access . tenantID , access . householdID ). Scan ( & subjectID ); errors . Is ( err , pgx . ErrNoRows ) {
return ErrAccessDenied
} else if err != nil {
return fmt . Errorf ( "resolve claim subject: %w" , err )
}
2026-09-06 17:04:11 +08:00
var claimID , deviceID , devicePublicID , activationState string
2026-08-26 06:41:07 +08:00
var expiresAt time . Time
var revokedAt , usedAt * time . Time
var usedBindingID * string
if err := tx . QueryRow ( ctx , `
2026-09-06 17:04:11 +08:00
SELECT dc.id::text,dc.device_id::text,d.public_id::text,d.activation_state,dc.expires_at,dc.revoked_at,dc.used_at,dc.used_binding_id::text
2026-08-26 06:41:07 +08:00
FROM device_claim dc JOIN device d ON d.id=dc.device_id AND d.tenant_id=dc.tenant_id
WHERE dc.token_digest=$1 AND dc.tenant_id=$2::uuid AND d.status='registered'
2026-09-06 17:04:11 +08:00
FOR UPDATE OF d,dc` , digest , access . tenantID ). Scan ( & claimID , & deviceID , & devicePublicID , & activationState , & expiresAt , & revokedAt , & usedAt , & usedBindingID ); errors . Is ( err , pgx . ErrNoRows ) {
2026-08-26 06:41:07 +08:00
return ErrDeviceClaimInvalid
} else if err != nil {
return fmt . Errorf ( "resolve device claim: %w" , err )
}
if revokedAt != nil || ! expiresAt . After ( time . Now ()) {
return ErrDeviceClaimInvalid
}
if usedAt != nil {
if usedBindingID == nil {
return ErrDeviceClaimInvalid
}
if err := tx . QueryRow ( ctx , `
SELECT b.public_id::text,d.public_id::text,h.public_id::text,sp.public_id::text,b.bound_at
FROM device_binding b
JOIN device d ON d.id=b.device_id AND d.tenant_id=b.tenant_id
JOIN household h ON h.id=b.household_id AND h.tenant_id=b.tenant_id
JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id
WHERE b.id=$1::uuid` , * usedBindingID ). Scan ( & result . BindingID , & result . DeviceID , & result . HouseholdID , & result . SubjectID , & result . BoundAt ); err != nil {
return fmt . Errorf ( "read used device claim binding: %w" , err )
}
if result . HouseholdID != householdPublicID || result . SubjectID != subjectPublicID {
return ErrDeviceClaimUsed
}
replayed = true
return nil
}
2026-09-06 17:04:11 +08:00
if activationState != string ( deviceactivation . Claimable ) {
return ErrDeviceClaimInvalid
}
2026-08-26 06:41:07 +08:00
var activeBinding bool
if err := tx . QueryRow ( ctx , `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)` , deviceID ). Scan ( & activeBinding ); err != nil {
return fmt . Errorf ( "check active device binding: %w" , err )
}
if activeBinding {
return ErrDeviceAlreadyBound
}
var bindingInternalID string
if err := tx . QueryRow ( ctx , `
INSERT INTO device_binding (tenant_id,device_id,household_id,subject_id,bound_by,reason)
VALUES ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,'one_time_claim')
RETURNING id::text,public_id::text,$6::text,$7::text,$8::text,bound_at` ,
access . tenantID , deviceID , access . householdID , subjectID , access . userID ,
devicePublicID , householdPublicID , subjectPublicID ). Scan ( & bindingInternalID , & result . BindingID , & result . DeviceID , & result . HouseholdID , & result . SubjectID , & result . BoundAt ); err != nil {
return fmt . Errorf ( "insert claimed device binding: %w" , err )
}
if _ , err := tx . Exec ( ctx , `UPDATE device_claim SET used_at=now(),used_binding_id=$1::uuid WHERE id=$2::uuid` , bindingInternalID , claimID ); err != nil {
return fmt . Errorf ( "mark device claim used: %w" , err )
}
2026-09-06 17:04:11 +08:00
if _ , err := deviceactivation . Apply ( ctx , tx , deviceactivation . Input { DeviceID : deviceID , TenantID : access . tenantID ,
Target : deviceactivation . Bound , Reason : "用户通过一次性绑定码认领设备" , Source : "device-claim" , ActorType : "user" ,
ActorPublicID : strPtr ( access . userPublicID ), RequestID : requestID }); err != nil {
return fmt . Errorf ( "activate claimed device binding: %w" , err )
}
var runtime string
if err := tx . QueryRow ( ctx , `SELECT COALESCE(desired_config->>'voice_runtime','legacy_voicechat') FROM device_twin WHERE device_id=$1::uuid` , deviceID ). Scan ( & runtime ); err != nil {
return fmt . Errorf ( "read claimed device runtime: %w" , err )
}
if runtime == "legacy_voicechat" {
if _ , err := deviceactivation . Apply ( ctx , tx , deviceactivation . Input { DeviceID : deviceID , TenantID : access . tenantID ,
Target : deviceactivation . Provisioning , Reason : "旧版语音运行时平台凭据预配" , Source : "platform-provisioning" , ActorType : "user" ,
ActorPublicID : strPtr ( access . userPublicID ), RequestID : requestID }); err != nil {
return fmt . Errorf ( "provision claimed legacy device: %w" , err )
}
if _ , err := deviceactivation . Apply ( ctx , tx , deviceactivation . Input { DeviceID : deviceID , TenantID : access . tenantID ,
Target : deviceactivation . Active , Reason : "旧版语音运行时已具备绑定与平台凭据" , Source : "platform-provisioning" , ActorType : "user" ,
ActorPublicID : strPtr ( access . userPublicID ), RequestID : requestID }); err != nil {
return fmt . Errorf ( "activate claimed legacy device: %w" , err )
}
}
2026-08-26 06:41:07 +08:00
return s . audit . Record ( ctx , tx , audit . Event {
TenantID : strPtr ( access . tenantID ), ActorType : "user" , ActorPublicID : strPtr ( access . userPublicID ),
Action : "device.claim" , ResourceType : "device_binding" , ResourceID : strPtr ( result . BindingID ),
RequestID : requestID , Result : "success" , Detail : map [ string ] any { "device_id" : devicePublicID , "household_id" : householdPublicID , "subject_id" : subjectPublicID },
})
})
return result , replayed , err
}