Files
story/internal/opsdevice/service.go
T

858 lines
44 KiB
Go
Raw Normal View History

2026-08-29 21:56:51 +08:00
package opsdevice
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/hex"
2026-09-06 17:04:11 +08:00
"encoding/json"
2026-08-29 21:56:51 +08:00
"errors"
"fmt"
"net/url"
2026-08-29 21:56:51 +08:00
"strings"
"time"
"data-terminal-cloud/internal/audit"
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/deviceauth"
"data-terminal-cloud/internal/devicebinding"
2026-08-29 21:56:51 +08:00
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/onboarding"
2026-08-29 21:56:51 +08:00
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrAccessDenied = errors.New("operator access denied")
ErrInvalid = errors.New("invalid device management input")
ErrNotFound = errors.New("device not found")
ErrVersionConflict = errors.New("device version conflict")
ErrClaimUnavailable = errors.New("device claim is unavailable")
ErrActivationConflict = errors.New("device activation transition conflict")
ErrActivationNotReady = errors.New("device activation prerequisites are not ready")
ErrProvisioningUnavailable = errors.New("device provisioning is unavailable")
2026-08-29 21:56:51 +08:00
)
type Service struct {
pool *pgxpool.Pool
credential *deviceauth.DatabaseSecretResolver
audit *audit.Repository
onboardingMasterKey string
deviceOrigin string
}
type Options struct {
OnboardingMasterKey string
DeviceOrigin string
2026-08-29 21:56:51 +08:00
}
type Device struct {
2026-09-06 17:04:11 +08:00
ID string `json:"id"`
TenantID string `json:"tenant_id"`
FactoryID string `json:"factory_id"`
EnrollmentMode string `json:"enrollment_mode"`
Bound bool `json:"bound"`
2026-09-06 17:04:11 +08:00
Model string `json:"model"`
HardwareRevision *string `json:"hardware_revision,omitempty"`
Status string `json:"status"`
ActivationState string `json:"activation_state"`
ActivationRevision int64 `json:"activation_revision"`
ActivationReason *string `json:"activation_reason,omitempty"`
ActivationUpdatedAt time.Time `json:"activation_updated_at"`
MinimumSecurityVersion *string `json:"minimum_security_version,omitempty"`
MinimumFirmwareVersion *string `json:"minimum_firmware_version,omitempty"`
HouseholdID *string `json:"household_id,omitempty"`
HouseholdName *string `json:"household_name,omitempty"`
SubjectID *string `json:"subject_id,omitempty"`
SubjectName *string `json:"subject_name,omitempty"`
CredentialStatus string `json:"credential_status"`
KeyVersion int64 `json:"key_version"`
FirmwareVersion *string `json:"firmware_version,omitempty"`
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
RegisteredAt time.Time `json:"registered_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int64 `json:"version"`
VoiceRuntime string `json:"voice_runtime"`
HardwareAgentBotID string `json:"hardware_agent_bot_id,omitempty"`
HardwareAgentTransport string `json:"hardware_agent_transport,omitempty"`
HardwareAgentMinimumSDKVersion string `json:"hardware_agent_minimum_sdk_version,omitempty"`
HardwareAgentStatus string `json:"hardware_agent_status,omitempty"`
2026-08-29 21:56:51 +08:00
}
type AssignmentOption struct {
HouseholdID string `json:"household_id"`
HouseholdName string `json:"household_name"`
SubjectID string `json:"subject_id"`
SubjectName string `json:"subject_name"`
}
type CreateInput struct{ Model string }
2026-09-06 17:04:11 +08:00
type ActivationTransitionInput struct {
TargetState string
Reason string
ExpectedRevision int64
}
type ActivationTransition struct {
ID string `json:"id"`
FromState *string `json:"from_state,omitempty"`
ToState string `json:"to_state"`
Reason string `json:"reason"`
Source string `json:"source"`
ActorType string `json:"actor_type"`
ActorPublicID *string `json:"actor_public_id,omitempty"`
RequestID *string `json:"request_id,omitempty"`
ActivationRevision int64 `json:"activation_revision"`
CreatedAt time.Time `json:"created_at"`
2026-08-29 21:56:51 +08:00
}
type UpdateInput struct {
2026-09-06 17:04:11 +08:00
Model *string
HardwareRevision *string
MinimumSecurityVersion *string
MinimumFirmwareVersion *string
Status *string
HouseholdID *string
SubjectID *string
VoiceRuntime *string
HardwareAgentBotID *string
HardwareAgentTransport *string
HardwareAgentMinimumSDKVersion *string
2026-08-29 21:56:51 +08:00
}
type CredentialIssue struct {
FactoryID string `json:"factory_id"`
Model string `json:"model"`
Origin string `json:"origin"`
DeviceKeyHex string `json:"device_key_hex"`
KeyVersion int64 `json:"key_version"`
2026-08-29 21:56:51 +08:00
}
type CreateResult struct {
Device Device `json:"device"`
Credential CredentialIssue `json:"credential"`
2026-08-29 21:56:51 +08:00
}
type DeviceClaimIssue struct {
DeviceID string `json:"device_id"`
ClaimCode string `json:"claim_code"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type operatorAccess struct {
tenantID, tenantPublicID, userID, userPublicID, role string
}
type queryRow interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
func NewService(pool *pgxpool.Pool, credential *deviceauth.DatabaseSecretResolver, auditRepository *audit.Repository, option ...Options) (*Service, error) {
2026-08-29 21:56:51 +08:00
if pool == nil || credential == nil || auditRepository == nil {
return nil, errors.New("operations device service dependencies are required")
}
if len(option) > 1 {
return nil, errors.New("only one operations device service option is allowed")
}
var configured Options
if len(option) == 1 {
configured = option[0]
}
origin := strings.TrimSpace(configured.DeviceOrigin)
if origin != "" {
parsed, err := url.Parse(origin)
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
return nil, errors.New("device origin must be an absolute HTTP URL")
}
origin = parsed.Scheme + "://" + parsed.Host
}
return &Service{pool: pool, credential: credential, audit: auditRepository, onboardingMasterKey: strings.TrimSpace(configured.OnboardingMasterKey), deviceOrigin: origin}, nil
2026-08-29 21:56:51 +08:00
}
func (s *Service) List(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Device, error) {
access, err := requireAccess(ctx, s.pool, principal, tenantPublicID, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, deviceSelectSQL+` WHERE d.tenant_id=$1::uuid ORDER BY d.registered_at DESC,d.public_id`, access.tenantID)
if err != nil {
return nil, fmt.Errorf("list operations devices: %w", err)
}
defer rows.Close()
items := make([]Device, 0)
for rows.Next() {
item, scanErr := scanDevice(rows)
if scanErr != nil {
return nil, scanErr
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) Options(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]AssignmentOption, error) {
access, err := requireAccess(ctx, s.pool, principal, tenantPublicID, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT h.public_id::text,h.name,sp.public_id::text,sp.preferred_name
FROM household h JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id
WHERE h.tenant_id=$1::uuid AND h.status='active' AND sp.status IN ('active','suspended')
ORDER BY h.name,sp.preferred_name`, access.tenantID)
if err != nil {
return nil, fmt.Errorf("list device assignment options: %w", err)
}
defer rows.Close()
items := make([]AssignmentOption, 0)
for rows.Next() {
var item AssignmentOption
if err := rows.Scan(&item.HouseholdID, &item.HouseholdName, &item.SubjectID, &item.SubjectName); err != nil {
return nil, fmt.Errorf("scan device assignment option: %w", err)
}
items = append(items, item)
}
return items, rows.Err()
}
2026-09-06 17:04:11 +08:00
func (s *Service) ListActivationTransitions(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID string) ([]ActivationTransition, error) {
access, err := requireAccess(ctx, s.pool, principal, tenantPublicID, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT at.public_id::text,at.from_state,at.to_state,at.reason,at.source,at.actor_type,at.actor_public_id::text,
at.request_id,at.activation_revision,at.created_at
FROM device_activation_transition at JOIN device d ON d.id=at.device_id AND d.tenant_id=at.tenant_id
WHERE at.tenant_id=$1::uuid AND d.public_id=$2::uuid
ORDER BY at.activation_revision DESC`, access.tenantID, devicePublicID)
if err != nil {
return nil, fmt.Errorf("list device activation transitions: %w", err)
}
defer rows.Close()
items := make([]ActivationTransition, 0)
for rows.Next() {
var item ActivationTransition
if err := rows.Scan(&item.ID, &item.FromState, &item.ToState, &item.Reason, &item.Source, &item.ActorType, &item.ActorPublicID,
&item.RequestID, &item.ActivationRevision, &item.CreatedAt); err != nil {
return nil, fmt.Errorf("scan device activation transition: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(items) == 0 {
var exists bool
if err := s.pool.QueryRow(ctx, `SELECT true FROM device WHERE tenant_id=$1::uuid AND public_id=$2::uuid`, access.tenantID, devicePublicID).Scan(&exists); errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
} else if err != nil {
return nil, fmt.Errorf("resolve activation transition device: %w", err)
}
}
return items, nil
}
2026-08-29 21:56:51 +08:00
func (s *Service) Create(ctx context.Context, principal identity.Principal, tenantPublicID, requestID string, input CreateInput) (CreateResult, error) {
input.Model = strings.TrimSpace(input.Model)
if input.Model == "" || len([]rune(input.Model)) > 120 {
2026-09-06 17:04:11 +08:00
return CreateResult{}, ErrInvalid
}
if s.onboardingMasterKey == "" || s.deviceOrigin == "" {
return CreateResult{}, ErrProvisioningUnavailable
2026-08-29 21:56:51 +08:00
}
secretBytes := make([]byte, 32)
if _, err := rand.Read(secretBytes); err != nil {
return CreateResult{}, fmt.Errorf("generate device secret: %w", err)
}
factoryBytes := make([]byte, 6)
if _, err := rand.Read(factoryBytes); err != nil {
return CreateResult{}, fmt.Errorf("generate factory device ID: %w", err)
2026-08-29 21:56:51 +08:00
}
secret := hex.EncodeToString(secretBytes)
factoryMAC := strings.ToUpper(hex.EncodeToString(factoryBytes))
factoryID := "KP01-" + factoryMAC
serialDigest := sha256.Sum256([]byte(factoryID))
deviceID := uuid.NewString()
2026-08-29 21:56:51 +08:00
publicID := uuid.NewString()
credentialRef := "onboarding://" + factoryID
const keyVersion int64 = 1
ciphertext, err := onboarding.SealDeviceKey(s.onboardingMasterKey, deviceID, keyVersion, secretBytes)
if err != nil {
return CreateResult{}, fmt.Errorf("seal onboarding device key: %w", err)
}
2026-08-29 21:56:51 +08:00
tx, err := s.pool.Begin(ctx)
if err != nil {
return CreateResult{}, fmt.Errorf("begin device registration: %w", err)
}
defer tx.Rollback(ctx)
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
if err != nil {
return CreateResult{}, err
}
_, err = tx.Exec(ctx, `
INSERT INTO device(id,public_id,tenant_id,serial_digest,model,status,credential_ref,activation_state,activation_reason,enrollment_mode,factory_device_id,factory_mac,onboarding_released_at)
VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,'registered',$6,'CLAIMABLE','ops-device-create','physical_confirm',$7,$8,now())`,
deviceID, publicID, access.tenantID, hex.EncodeToString(serialDigest[:]), input.Model, credentialRef, factoryID, factoryMAC)
2026-08-29 21:56:51 +08:00
if err != nil {
return CreateResult{}, fmt.Errorf("insert device: %w", err)
}
2026-09-06 17:04:11 +08:00
if _, err := tx.Exec(ctx, `INSERT INTO device_activation_transition(tenant_id,device_id,from_state,to_state,reason,source,actor_type,actor_public_id,request_id,activation_revision)
VALUES($1::uuid,$2::uuid,NULL,'CLAIMABLE','ops-device-create','operator','user',$3::uuid,NULLIF($4,''),1)`, access.tenantID, deviceID, access.userPublicID, requestID); err != nil {
2026-09-06 17:04:11 +08:00
return CreateResult{}, fmt.Errorf("record initial device activation: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO device_twin(device_id,tenant_id,desired_config) VALUES($1::uuid,$2::uuid,'{}'::jsonb)`, deviceID, access.tenantID); err != nil {
2026-08-29 21:56:51 +08:00
return CreateResult{}, fmt.Errorf("insert device twin: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status,activated_at)
VALUES($1::uuid,$2::uuid,$3,$4,'ACTIVE',now())`, deviceID, access.tenantID, keyVersion, ciphertext); err != nil {
return CreateResult{}, fmt.Errorf("insert onboarding device key: %w", err)
2026-09-06 17:04:11 +08:00
}
2026-08-29 21:56:51 +08:00
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
Action: "ops.device.create", ResourceType: "device", ResourceID: &publicID, RequestID: requestID, Result: "success",
Detail: map[string]any{"factory_id": factoryID, "model": input.Model, "key_version": keyVersion, "enrollment_mode": "physical_confirm"}}); err != nil {
2026-08-29 21:56:51 +08:00
return CreateResult{}, err
}
item, err := readDevice(ctx, tx, access.tenantID, publicID)
if err != nil {
return CreateResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return CreateResult{}, fmt.Errorf("commit device registration: %w", err)
}
return CreateResult{Device: item, Credential: CredentialIssue{FactoryID: factoryID, Model: input.Model, Origin: s.deviceOrigin, DeviceKeyHex: secret, KeyVersion: keyVersion}}, nil
2026-08-29 21:56:51 +08:00
}
func (s *Service) IssueDeviceClaim(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, requestID string) (DeviceClaimIssue, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return DeviceClaimIssue{}, fmt.Errorf("begin device claim issue: %w", err)
}
defer tx.Rollback(ctx)
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
if err != nil {
return DeviceClaimIssue{}, err
}
2026-09-06 17:04:11 +08:00
var deviceID, status, activationState string
2026-08-29 21:56:51 +08:00
var bound bool
if err := tx.QueryRow(ctx, `
2026-09-06 17:04:11 +08:00
SELECT d.id::text,d.status,d.activation_state,EXISTS(SELECT 1 FROM device_binding b WHERE b.device_id=d.id AND b.unbound_at IS NULL)
FROM device d WHERE d.public_id=$1::uuid AND d.tenant_id=$2::uuid FOR UPDATE`, devicePublicID, access.tenantID).Scan(&deviceID, &status, &activationState, &bound); errors.Is(err, pgx.ErrNoRows) {
2026-08-29 21:56:51 +08:00
return DeviceClaimIssue{}, ErrNotFound
} else if err != nil {
return DeviceClaimIssue{}, fmt.Errorf("lock device for claim issue: %w", err)
}
2026-09-06 17:04:11 +08:00
if status != "registered" || activationState != string(deviceactivation.Claimable) || bound {
2026-08-29 21:56:51 +08:00
return DeviceClaimIssue{}, ErrClaimUnavailable
}
issued, err := s.issueDeviceClaimTx(ctx, tx, access, devicePublicID, deviceID, requestID)
if err != nil {
return DeviceClaimIssue{}, err
}
if err := tx.Commit(ctx); err != nil {
return DeviceClaimIssue{}, fmt.Errorf("commit device claim issue: %w", err)
}
return issued, nil
}
func (s *Service) issueDeviceClaimTx(ctx context.Context, tx pgx.Tx, access operatorAccess, devicePublicID, deviceID, requestID string) (DeviceClaimIssue, error) {
if err := devicebinding.RequireLegacy(ctx, tx, deviceID); err != nil {
return DeviceClaimIssue{}, err
}
2026-08-29 21:56:51 +08:00
randomBytes := make([]byte, 10)
if _, err := rand.Read(randomBytes); err != nil {
return DeviceClaimIssue{}, fmt.Errorf("generate device claim: %w", err)
}
rawCode := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes)
claimCode := strings.Join([]string{rawCode[0:4], rawCode[4:8], rawCode[8:12], rawCode[12:16]}, "-")
digest := sha256.Sum256([]byte(claimCode))
issuedAt := time.Now().UTC()
expiresAt := issuedAt.Add(24 * time.Hour)
if _, err := tx.Exec(ctx, `UPDATE device_claim SET revoked_at=$1 WHERE device_id=$2::uuid AND used_at IS NULL AND revoked_at IS NULL`, issuedAt, deviceID); err != nil {
return DeviceClaimIssue{}, fmt.Errorf("revoke previous device claim: %w", err)
}
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)`, access.tenantID, deviceID, hex.EncodeToString(digest[:]), expiresAt, issuedAt); err != nil {
return DeviceClaimIssue{}, fmt.Errorf("insert device claim: %w", err)
}
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
Action: "ops.device.claim.issue", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Result: "success",
Detail: map[string]any{"expires_at": expiresAt}}); err != nil {
return DeviceClaimIssue{}, err
}
return DeviceClaimIssue{DeviceID: devicePublicID, ClaimCode: claimCode, IssuedAt: issuedAt, ExpiresAt: expiresAt}, nil
}
func (s *Service) Update(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, requestID string, expectedVersion int64, input UpdateInput) (Device, error) {
2026-09-06 17:04:11 +08:00
if input.HardwareAgentBotID != nil || input.HardwareAgentTransport != nil || input.HardwareAgentMinimumSDKVersion != nil {
// Bot, transport and SDK policy are product defaults resolved during
// provider provisioning, never per-device operator input.
return Device{}, ErrInvalid
}
if expectedVersion < 1 || (input.Model == nil && input.HardwareRevision == nil && input.MinimumSecurityVersion == nil && input.MinimumFirmwareVersion == nil && input.Status == nil && input.HouseholdID == nil && input.SubjectID == nil && input.VoiceRuntime == nil) {
2026-08-29 21:56:51 +08:00
return Device{}, ErrInvalid
}
if (input.HouseholdID == nil) != (input.SubjectID == nil) {
return Device{}, ErrInvalid
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Device{}, fmt.Errorf("begin device update: %w", err)
}
defer tx.Rollback(ctx)
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
if err != nil {
return Device{}, err
}
2026-09-06 17:04:11 +08:00
var deviceID, currentActivationState string
2026-08-29 21:56:51 +08:00
var currentVersion int64
2026-09-06 17:04:11 +08:00
if err := tx.QueryRow(ctx, `SELECT id::text,version,activation_state FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, devicePublicID, access.tenantID).Scan(&deviceID, &currentVersion, &currentActivationState); errors.Is(err, pgx.ErrNoRows) {
2026-08-29 21:56:51 +08:00
return Device{}, ErrNotFound
} else if err != nil {
return Device{}, fmt.Errorf("lock device: %w", err)
}
if currentVersion != expectedVersion {
return Device{}, ErrVersionConflict
}
model, modelSet := pointerValue(input.Model)
if input.Model != nil || input.HardwareRevision != nil || input.VoiceRuntime != nil || (input.HouseholdID != nil && strings.TrimSpace(*input.HouseholdID) != "") {
if err := devicebinding.RequireLegacy(ctx, tx, deviceID); err != nil {
return Device{}, err
}
}
2026-08-29 21:56:51 +08:00
hardware, hardwareSet := pointerValue(input.HardwareRevision)
security, securitySet := pointerValue(input.MinimumSecurityVersion)
firmware, firmwareSet := pointerValue(input.MinimumFirmwareVersion)
status, statusSet := pointerValue(input.Status)
2026-09-06 17:04:11 +08:00
voiceRuntime, voiceRuntimeSet := pointerValue(input.VoiceRuntime)
2026-08-29 21:56:51 +08:00
if (modelSet && (model == "" || len([]rune(model)) > 120)) || len([]rune(hardware)) > 80 || len([]rune(security)) > 80 || len([]rune(firmware)) > 80 ||
2026-09-06 17:04:11 +08:00
(statusSet && status != "registered" && status != "quarantined") || (voiceRuntimeSet && !validVoiceRuntime(voiceRuntime)) {
2026-08-29 21:56:51 +08:00
return Device{}, ErrInvalid
}
if _, err := tx.Exec(ctx, `UPDATE device SET
model=CASE WHEN $3 THEN $4 ELSE model END,
hardware_revision=CASE WHEN $5 THEN NULLIF($6,'') ELSE hardware_revision END,
minimum_security_version=CASE WHEN $7 THEN NULLIF($8,'') ELSE minimum_security_version END,
minimum_firmware_version=CASE WHEN $9 THEN NULLIF($10,'') ELSE minimum_firmware_version END,
status=CASE WHEN $11 THEN $12 ELSE status END,version=version+1,updated_at=now()
WHERE id=$1::uuid AND tenant_id=$2::uuid`, deviceID, access.tenantID, modelSet, model, hardwareSet, hardware, securitySet, security, firmwareSet, firmware, statusSet, status); err != nil {
return Device{}, fmt.Errorf("update device: %w", err)
}
if input.HouseholdID != nil {
householdID := strings.TrimSpace(*input.HouseholdID)
subjectID := strings.TrimSpace(*input.SubjectID)
if (householdID == "") != (subjectID == "") {
return Device{}, ErrInvalid
}
if householdID != "" {
if err := validateAssignment(ctx, tx, access.tenantID, householdID, subjectID); err != nil {
return Device{}, err
}
}
if _, err := tx.Exec(ctx, `UPDATE device_binding SET unbound_at=now(),unbound_by=$2::uuid,reason='运维端变更归属' WHERE device_id=$1::uuid AND unbound_at IS NULL`, deviceID, access.userID); err != nil {
return Device{}, fmt.Errorf("close device binding: %w", err)
}
if householdID != "" {
if err := insertBinding(ctx, tx, access, deviceID, householdID, subjectID, "运维端变更归属"); err != nil {
return Device{}, err
}
if _, err := tx.Exec(ctx, `UPDATE device_claim SET revoked_at=now() WHERE device_id=$1::uuid AND used_at IS NULL AND revoked_at IS NULL`, deviceID); err != nil {
return Device{}, fmt.Errorf("revoke device claims after assignment: %w", err)
}
2026-09-06 17:04:11 +08:00
if currentActivationState == string(deviceactivation.Claimable) {
transition, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
Target: deviceactivation.Bound, Reason: "运维端绑定设备", Source: "operator-assignment", ActorType: "user",
ActorPublicID: &access.userPublicID, RequestID: requestID})
if err != nil {
return Device{}, mapActivationError(err)
}
currentActivationState = string(transition.To)
}
} else if currentActivationState == string(deviceactivation.Bound) || currentActivationState == string(deviceactivation.Provisioning) ||
currentActivationState == string(deviceactivation.Active) || currentActivationState == string(deviceactivation.Lost) {
transition, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
Target: deviceactivation.Claimable, Reason: "运维端解绑设备", Source: "operator-unbind", ActorType: "user",
ActorPublicID: &access.userPublicID, RequestID: requestID})
if err != nil {
return Device{}, mapActivationError(err)
}
currentActivationState = string(transition.To)
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_provider_credential SET status='SUSPENDED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`, deviceID); err != nil {
return Device{}, fmt.Errorf("suspend provider credential after unbind: %w", err)
}
2026-08-29 21:56:51 +08:00
}
}
2026-09-06 17:04:11 +08:00
if voiceRuntimeSet {
desiredConfig, _ := json.Marshal(map[string]string{"voice_runtime": voiceRuntime})
if voiceRuntime == "volc_hardware_agent" {
if _, err := tx.Exec(ctx, `UPDATE device_twin SET desired_revision=desired_revision+1,desired_config=desired_config||$2::jsonb,updated_at=now() WHERE device_id=$1::uuid`, deviceID, desiredConfig); err != nil {
return Device{}, err
}
} else {
if _, err := tx.Exec(ctx, `UPDATE device_twin SET desired_revision=desired_revision+1,desired_config=(desired_config||$2::jsonb)-'hardware_agent_transport'-'minimum_sdk_version',updated_at=now() WHERE device_id=$1::uuid`, deviceID, desiredConfig); err != nil {
return Device{}, err
}
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=now() WHERE device_id=$1::uuid`, deviceID); err != nil {
return Device{}, err
}
}
}
if currentActivationState == string(deviceactivation.Bound) {
var runtime string
var hasBinding bool
if err := tx.QueryRow(ctx, `SELECT COALESCE(desired_config->>'voice_runtime','legacy_voicechat'),EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL) FROM device_twin WHERE device_id=$1::uuid`, deviceID).Scan(&runtime, &hasBinding); err != nil {
return Device{}, fmt.Errorf("read activation runtime: %w", err)
}
if runtime == "legacy_voicechat" && hasBinding {
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
Target: deviceactivation.Provisioning, Reason: "旧版语音运行时平台凭据预配", Source: "platform-provisioning", ActorType: "user",
ActorPublicID: &access.userPublicID, RequestID: requestID}); err != nil {
return Device{}, mapActivationError(err)
}
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
Target: deviceactivation.Active, Reason: "旧版语音运行时已具备绑定与平台凭据", Source: "platform-provisioning", ActorType: "user",
ActorPublicID: &access.userPublicID, RequestID: requestID}); err != nil {
return Device{}, mapActivationError(err)
}
}
}
if currentActivationState == string(deviceactivation.Active) {
var runtime string
var providerReady bool
if err := tx.QueryRow(ctx, `SELECT COALESCE(desired_config->>'voice_runtime','legacy_voicechat'),EXISTS(SELECT 1 FROM hardware_agent_provider_credential WHERE device_id=$1::uuid AND status='ACTIVE') FROM device_twin WHERE device_id=$1::uuid`, deviceID).Scan(&runtime, &providerReady); err != nil {
return Device{}, fmt.Errorf("read active runtime readiness: %w", err)
}
if runtime == "volc_hardware_agent" && !providerReady {
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
Target: deviceactivation.Provisioning, Reason: "语音运行时切换为火山硬件智能体,等待一机一密", Source: "runtime-change", ActorType: "user",
ActorPublicID: &access.userPublicID, RequestID: requestID}); err != nil {
return Device{}, mapActivationError(err)
}
}
}
var finalVersion int64
if err := tx.QueryRow(ctx, `SELECT version FROM device WHERE id=$1::uuid`, deviceID).Scan(&finalVersion); err != nil {
return Device{}, fmt.Errorf("read updated device version: %w", err)
}
2026-08-29 21:56:51 +08:00
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
Action: "ops.device.update", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Result: "success",
2026-09-06 17:04:11 +08:00
Detail: map[string]any{"from_version": currentVersion, "to_version": finalVersion}}); err != nil {
2026-08-29 21:56:51 +08:00
return Device{}, err
}
item, err := readDevice(ctx, tx, access.tenantID, devicePublicID)
if err != nil {
return Device{}, err
}
if err := tx.Commit(ctx); err != nil {
return Device{}, fmt.Errorf("commit device update: %w", err)
}
return item, nil
}
func (s *Service) Delete(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, requestID string, expectedVersion int64) (Device, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return Device{}, fmt.Errorf("begin device retirement: %w", err)
}
defer tx.Rollback(ctx)
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
if err != nil {
return Device{}, err
}
var deviceID string
var version int64
if err := tx.QueryRow(ctx, `SELECT id::text,version FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, devicePublicID, access.tenantID).Scan(&deviceID, &version); errors.Is(err, pgx.ErrNoRows) {
return Device{}, ErrNotFound
} else if err != nil {
return Device{}, fmt.Errorf("lock device for retirement: %w", err)
}
if version != expectedVersion {
return Device{}, ErrVersionConflict
}
2026-09-06 17:04:11 +08:00
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
Target: deviceactivation.Revoked, Reason: "运维端注销设备", Source: "operator-retirement", ActorType: "user",
ActorPublicID: &access.userPublicID, RequestID: requestID}); err != nil {
return Device{}, mapActivationError(err)
}
if _, err := tx.Exec(ctx, `UPDATE device SET status='revoked',revoked_at=COALESCE(revoked_at,now()),updated_at=now() WHERE id=$1::uuid`, deviceID); err != nil {
2026-08-29 21:56:51 +08:00
return Device{}, fmt.Errorf("retire device: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE device_credential SET status='revoked',revoked_at=COALESCE(revoked_at,now()) WHERE device_id=$1::uuid`, deviceID); err != nil {
return Device{}, fmt.Errorf("revoke device credential: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE device_onboarding_key SET status='REVOKED' WHERE device_id=$1::uuid AND status IN ('ACTIVE','PENDING')`, deviceID); err != nil {
return Device{}, fmt.Errorf("revoke onboarding device key: %w", err)
}
2026-08-29 21:56:51 +08:00
if _, err := tx.Exec(ctx, `UPDATE device_binding SET unbound_at=now(),unbound_by=$2::uuid,reason='运维端删除设备' WHERE device_id=$1::uuid AND unbound_at IS NULL`, deviceID, access.userID); err != nil {
return Device{}, fmt.Errorf("close retired device binding: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE device_account_binding SET released_at=now() WHERE device_id=$1::uuid AND released_at IS NULL`, deviceID); err != nil {
return Device{}, fmt.Errorf("close retired device account binding: %w", err)
}
2026-08-29 21:56:51 +08:00
if _, err := tx.Exec(ctx, `UPDATE device_claim SET revoked_at=now() WHERE device_id=$1::uuid AND used_at IS NULL AND revoked_at IS NULL`, deviceID); err != nil {
return Device{}, fmt.Errorf("revoke retired device claims: %w", err)
}
2026-09-06 17:04:11 +08:00
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_device_binding SET status='REVOKED',updated_at=now() WHERE device_id=$1::uuid`, deviceID); err != nil {
return Device{}, fmt.Errorf("revoke hardware agent binding: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_provider_credential SET status='REVOKED',revoked_at=COALESCE(revoked_at,now()),updated_at=now() WHERE device_id=$1::uuid`, deviceID); err != nil {
return Device{}, fmt.Errorf("revoke hardware provider credential: %w", err)
}
2026-08-29 21:56:51 +08:00
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
Action: "ops.device.delete", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Result: "success",
Detail: map[string]any{"soft_delete": true, "credential_revoked": true}}); err != nil {
return Device{}, err
}
item, err := readDevice(ctx, tx, access.tenantID, devicePublicID)
if err != nil {
return Device{}, err
}
if err := tx.Commit(ctx); err != nil {
return Device{}, fmt.Errorf("commit device retirement: %w", err)
}
return item, nil
}
2026-09-06 17:04:11 +08:00
func (s *Service) TransitionActivation(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, requestID string, input ActivationTransitionInput) (Device, error) {
input.Reason = strings.TrimSpace(input.Reason)
target, err := deviceactivation.Parse(input.TargetState)
if err != nil || input.Reason == "" || len([]rune(input.Reason)) > 500 || input.ExpectedRevision < 1 || target == deviceactivation.Revoked {
return Device{}, ErrInvalid
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Device{}, fmt.Errorf("begin device activation transition: %w", err)
}
defer tx.Rollback(ctx)
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
if err != nil {
return Device{}, err
}
var deviceID, currentState, runtime string
if err := tx.QueryRow(ctx, `
SELECT d.id::text,d.activation_state,COALESCE(dt.desired_config->>'voice_runtime','legacy_voicechat')
FROM device d LEFT JOIN device_twin dt ON dt.device_id=d.id
WHERE d.public_id=$1::uuid AND d.tenant_id=$2::uuid FOR UPDATE OF d`, devicePublicID, access.tenantID).
Scan(&deviceID, &currentState, &runtime); errors.Is(err, pgx.ErrNoRows) {
return Device{}, ErrNotFound
} else if err != nil {
return Device{}, fmt.Errorf("lock device activation target: %w", err)
}
// Binding is a business operation, retirement is DELETE, and provisioning
// completion normally comes from the device provisioning endpoint.
if target == deviceactivation.Bound || (target == deviceactivation.Claimable && currentState != string(deviceactivation.Factory)) {
return Device{}, ErrInvalid
}
if target == deviceactivation.Active {
var bindingReady, platformCredentialReady, providerCredentialReady bool
if err := tx.QueryRow(ctx, `SELECT
EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL),
EXISTS(SELECT 1 FROM device_credential WHERE device_id=$1::uuid AND status='active'),
EXISTS(SELECT 1 FROM hardware_agent_provider_credential WHERE device_id=$1::uuid AND status IN ('ACTIVE','SUSPENDED'))`, deviceID).
Scan(&bindingReady, &platformCredentialReady, &providerCredentialReady); err != nil {
return Device{}, fmt.Errorf("check activation readiness: %w", err)
}
if !bindingReady || !platformCredentialReady || (runtime == "volc_hardware_agent" && !providerCredentialReady) {
return Device{}, ErrActivationNotReady
}
}
expected := input.ExpectedRevision
transition, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
Target: target, Reason: input.Reason, Source: "operator-transition", ActorType: "user", ActorPublicID: &access.userPublicID,
RequestID: requestID, ExpectedRevision: &expected})
if err != nil {
return Device{}, mapActivationError(err)
}
if target == deviceactivation.Lost {
if _, err := tx.Exec(ctx, `UPDATE device SET status='quarantined',updated_at=now() WHERE id=$1::uuid`, deviceID); err != nil {
return Device{}, fmt.Errorf("quarantine lost device: %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'`, deviceID); err != nil {
return Device{}, fmt.Errorf("suspend lost device provider credential: %w", err)
}
} else if target == deviceactivation.Active {
if _, err := tx.Exec(ctx, `UPDATE device SET status='registered',updated_at=now() WHERE id=$1::uuid`, deviceID); err != nil {
return Device{}, fmt.Errorf("restore active device: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_provider_credential SET status='ACTIVE',revoked_at=NULL,updated_at=now() WHERE device_id=$1::uuid AND status='SUSPENDED'`, deviceID); err != nil {
return Device{}, fmt.Errorf("restore provider credential: %w", err)
}
}
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
Action: "ops.device.activation.transition", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Result: "success",
Detail: map[string]any{"from_state": transition.From, "to_state": transition.To, "activation_revision": transition.Revision, "reason": input.Reason}}); err != nil {
return Device{}, err
}
item, err := readDevice(ctx, tx, access.tenantID, devicePublicID)
if err != nil {
return Device{}, err
}
if err := tx.Commit(ctx); err != nil {
return Device{}, fmt.Errorf("commit device activation transition: %w", err)
}
return item, nil
}
2026-08-29 21:56:51 +08:00
func (s *Service) RotateCredential(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, requestID string) (CredentialIssue, error) {
secretBytes := make([]byte, 32)
if _, err := rand.Read(secretBytes); err != nil {
return CredentialIssue{}, fmt.Errorf("generate device credential: %w", err)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return CredentialIssue{}, fmt.Errorf("begin credential rotation: %w", err)
}
defer tx.Rollback(ctx)
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
if err != nil {
return CredentialIssue{}, err
}
var rotationDeviceID, enrollmentMode, factoryID, model string
if err := tx.QueryRow(ctx, `SELECT id::text,enrollment_mode,COALESCE(factory_device_id,''),model FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND status='registered' AND activation_state NOT IN ('LOST','REVOKED') FOR UPDATE`, devicePublicID, access.tenantID).Scan(&rotationDeviceID, &enrollmentMode, &factoryID, &model); err != nil {
return CredentialIssue{}, ErrNotFound
}
if enrollmentMode != "physical_confirm" || factoryID == "" || s.onboardingMasterKey == "" || s.deviceOrigin == "" {
return CredentialIssue{}, ErrProvisioningUnavailable
}
var version int64
if err := tx.QueryRow(ctx, `SELECT COALESCE(max(key_version),0)+1 FROM device_onboarding_key WHERE device_id=$1::uuid`, rotationDeviceID).Scan(&version); err != nil {
return CredentialIssue{}, fmt.Errorf("read onboarding key version: %w", err)
2026-08-29 21:56:51 +08:00
}
ciphertext, err := onboarding.SealDeviceKey(s.onboardingMasterKey, rotationDeviceID, version, secretBytes)
2026-08-29 21:56:51 +08:00
if err != nil {
return CredentialIssue{}, fmt.Errorf("seal rotated onboarding device key: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE device_onboarding_key SET status='REVOKED' WHERE device_id=$1::uuid AND status='ACTIVE'`, rotationDeviceID); err != nil {
return CredentialIssue{}, fmt.Errorf("revoke previous onboarding device key: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status,activated_at) VALUES($1::uuid,$2::uuid,$3,$4,'ACTIVE',now())`, rotationDeviceID, access.tenantID, version, ciphertext); err != nil {
return CredentialIssue{}, fmt.Errorf("insert rotated onboarding device key: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE device SET onboarding_revision=onboarding_revision+1,version=version+1,updated_at=now() WHERE id=$1::uuid`, rotationDeviceID); err != nil {
return CredentialIssue{}, fmt.Errorf("update onboarding key revision: %w", err)
2026-08-29 21:56:51 +08:00
}
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
Action: "ops.device.credential.rotate", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Result: "success",
Detail: map[string]any{"key_version": version}}); err != nil {
return CredentialIssue{}, err
}
if err := tx.Commit(ctx); err != nil {
return CredentialIssue{}, fmt.Errorf("commit credential rotation: %w", err)
}
return CredentialIssue{FactoryID: factoryID, Model: model, Origin: s.deviceOrigin, DeviceKeyHex: hex.EncodeToString(secretBytes), KeyVersion: version}, nil
2026-08-29 21:56:51 +08:00
}
func requireAccess(ctx context.Context, q queryRow, principal identity.Principal, tenantPublicID string, mutate bool) (operatorAccess, error) {
var access operatorAccess
err := q.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active' AND ua.account_kind='operator'
WHERE t.public_id=$1::uuid AND t.status='active' AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, tenantPublicID, principal.Issuer, principal.Subject).
Scan(&access.tenantID, &access.tenantPublicID, &access.userID, &access.userPublicID, &access.role)
if errors.Is(err, pgx.ErrNoRows) {
return operatorAccess{}, ErrAccessDenied
}
if err != nil {
return operatorAccess{}, fmt.Errorf("resolve operations device access: %w", err)
}
if mutate && access.role != "ops_operator" && access.role != "ops_admin" {
return operatorAccess{}, ErrAccessDenied
}
return access, nil
}
func validateAssignment(ctx context.Context, q queryRow, tenantID, householdPublicID, subjectPublicID string) error {
var exists bool
err := q.QueryRow(ctx, `SELECT true FROM household h JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id
WHERE h.public_id=$1::uuid AND sp.public_id=$2::uuid AND h.tenant_id=$3::uuid AND h.status='active' AND sp.status IN ('active','suspended')`,
householdPublicID, subjectPublicID, tenantID).Scan(&exists)
if errors.Is(err, pgx.ErrNoRows) {
return ErrInvalid
}
if err != nil {
return fmt.Errorf("validate device assignment: %w", err)
}
return nil
}
func insertBinding(ctx context.Context, tx pgx.Tx, access operatorAccess, deviceID, householdPublicID, subjectPublicID, reason string) error {
if err := devicebinding.RequireLegacy(ctx, tx, deviceID); err != nil {
return err
}
2026-08-29 21:56:51 +08:00
_, err := tx.Exec(ctx, `INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason)
SELECT $1::uuid,$2::uuid,h.id,sp.id,$5::uuid,$6 FROM household h JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id
WHERE h.public_id=$3::uuid AND sp.public_id=$4::uuid AND h.tenant_id=$1::uuid`,
access.tenantID, deviceID, householdPublicID, subjectPublicID, access.userID, reason)
if err != nil {
return fmt.Errorf("insert device binding: %w", err)
}
return nil
}
func pointerValue(value *string) (string, bool) {
if value == nil {
return "", false
}
return strings.TrimSpace(*value), true
}
2026-09-06 17:04:11 +08:00
func validVoiceRuntime(value string) bool {
return value == "legacy_voicechat" || value == "volc_hardware_agent"
}
func validHardwareTransport(value string) bool {
return value == "high_quality" || value == "low_load"
}
func mapActivationError(err error) error {
if errors.Is(err, deviceactivation.ErrRevisionConflict) || errors.Is(err, deviceactivation.ErrTransitionRejected) {
return ErrActivationConflict
}
if errors.Is(err, deviceactivation.ErrInvalidState) {
return ErrInvalid
}
return err
}
2026-08-29 21:56:51 +08:00
const deviceSelectSQL = `
SELECT d.public_id::text,t.public_id::text,COALESCE(d.factory_device_id,''),d.enrollment_mode,
(EXISTS(SELECT 1 FROM device_binding current_binding WHERE current_binding.device_id=d.id AND current_binding.unbound_at IS NULL)
OR EXISTS(SELECT 1 FROM device_account_binding account_binding WHERE account_binding.device_id=d.id AND account_binding.released_at IS NULL AND account_binding.generation=d.binding_generation)),
d.model,d.hardware_revision,d.status,d.activation_state,d.activation_revision,d.activation_reason,d.activation_updated_at,d.minimum_security_version,d.minimum_firmware_version,
h.public_id::text,h.name,sp.public_id::text,sp.preferred_name,COALESCE(lower(ok.status),dc.status,'missing'),COALESCE(ok.key_version,dc.key_version,0),
2026-09-06 17:04:11 +08:00
dt.firmware_version,dt.last_reported_at,d.registered_at,d.updated_at,d.version,
COALESCE(dt.desired_config->>'voice_runtime',CASE WHEN d.enrollment_mode='physical_confirm' THEN 'volc_hardware_agent' ELSE 'legacy_voicechat' END),COALESCE(hb.bot_id,''),COALESCE(hb.transport,''),COALESCE(hb.minimum_sdk_version,''),COALESCE(hb.status,'')
2026-08-29 21:56:51 +08:00
FROM device d JOIN tenant t ON t.id=d.tenant_id
LEFT JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
LEFT JOIN device_binding b ON b.device_id=d.id AND b.tenant_id=d.tenant_id AND b.unbound_at IS NULL
LEFT JOIN household h ON h.id=b.household_id AND h.tenant_id=b.tenant_id
LEFT JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id
2026-09-06 17:04:11 +08:00
LEFT JOIN device_credential dc ON dc.device_id=d.id AND dc.tenant_id=d.tenant_id
LEFT JOIN device_onboarding_key ok ON ok.device_id=d.id AND ok.tenant_id=d.tenant_id AND ok.status='ACTIVE'
2026-09-06 17:04:11 +08:00
LEFT JOIN hardware_agent_device_binding hb ON hb.device_id=d.id AND hb.tenant_id=d.tenant_id`
2026-08-29 21:56:51 +08:00
type scanner interface{ Scan(...any) error }
func scanDevice(row scanner) (Device, error) {
var item Device
if err := row.Scan(&item.ID, &item.TenantID, &item.FactoryID, &item.EnrollmentMode, &item.Bound, &item.Model, &item.HardwareRevision, &item.Status, &item.ActivationState, &item.ActivationRevision, &item.ActivationReason, &item.ActivationUpdatedAt, &item.MinimumSecurityVersion, &item.MinimumFirmwareVersion,
2026-08-29 21:56:51 +08:00
&item.HouseholdID, &item.HouseholdName, &item.SubjectID, &item.SubjectName, &item.CredentialStatus, &item.KeyVersion,
2026-09-06 17:04:11 +08:00
&item.FirmwareVersion, &item.LastReportedAt, &item.RegisteredAt, &item.UpdatedAt, &item.Version,
&item.VoiceRuntime, &item.HardwareAgentBotID, &item.HardwareAgentTransport, &item.HardwareAgentMinimumSDKVersion, &item.HardwareAgentStatus); err != nil {
2026-08-29 21:56:51 +08:00
return Device{}, fmt.Errorf("scan operations device: %w", err)
}
return item, nil
}
func readDevice(ctx context.Context, q queryRow, tenantID, publicID string) (Device, error) {
item, err := scanDevice(q.QueryRow(ctx, deviceSelectSQL+` WHERE d.tenant_id=$1::uuid AND d.public_id=$2::uuid`, tenantID, publicID))
if errors.Is(err, pgx.ErrNoRows) {
return Device{}, ErrNotFound
}
return item, err
}