Files
story/internal/opsdevice/service.go
T
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

858 lines
44 KiB
Go

package opsdevice
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"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/identity"
"data-terminal-cloud/internal/onboarding"
"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")
)
type Service struct {
pool *pgxpool.Pool
credential *deviceauth.DatabaseSecretResolver
audit *audit.Repository
onboardingMasterKey string
deviceOrigin string
}
type Options struct {
OnboardingMasterKey string
DeviceOrigin string
}
type Device struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
FactoryID string `json:"factory_id"`
EnrollmentMode string `json:"enrollment_mode"`
Bound bool `json:"bound"`
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"`
}
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 }
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"`
}
type UpdateInput struct {
Model *string
HardwareRevision *string
MinimumSecurityVersion *string
MinimumFirmwareVersion *string
Status *string
HouseholdID *string
SubjectID *string
VoiceRuntime *string
HardwareAgentBotID *string
HardwareAgentTransport *string
HardwareAgentMinimumSDKVersion *string
}
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"`
}
type CreateResult struct {
Device Device `json:"device"`
Credential CredentialIssue `json:"credential"`
}
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) {
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
}
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()
}
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
}
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 {
return CreateResult{}, ErrInvalid
}
if s.onboardingMasterKey == "" || s.deviceOrigin == "" {
return CreateResult{}, ErrProvisioningUnavailable
}
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)
}
secret := hex.EncodeToString(secretBytes)
factoryMAC := strings.ToUpper(hex.EncodeToString(factoryBytes))
factoryID := "KP01-" + factoryMAC
serialDigest := sha256.Sum256([]byte(factoryID))
deviceID := uuid.NewString()
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)
}
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)
if err != nil {
return CreateResult{}, fmt.Errorf("insert device: %w", err)
}
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 {
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 {
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)
}
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 {
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
}
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
}
var deviceID, status, activationState string
var bound bool
if err := tx.QueryRow(ctx, `
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) {
return DeviceClaimIssue{}, ErrNotFound
} else if err != nil {
return DeviceClaimIssue{}, fmt.Errorf("lock device for claim issue: %w", err)
}
if status != "registered" || activationState != string(deviceactivation.Claimable) || bound {
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
}
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) {
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) {
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
}
var deviceID, currentActivationState string
var currentVersion int64
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) {
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
}
}
hardware, hardwareSet := pointerValue(input.HardwareRevision)
security, securitySet := pointerValue(input.MinimumSecurityVersion)
firmware, firmwareSet := pointerValue(input.MinimumFirmwareVersion)
status, statusSet := pointerValue(input.Status)
voiceRuntime, voiceRuntimeSet := pointerValue(input.VoiceRuntime)
if (modelSet && (model == "" || len([]rune(model)) > 120)) || len([]rune(hardware)) > 80 || len([]rune(security)) > 80 || len([]rune(firmware)) > 80 ||
(statusSet && status != "registered" && status != "quarantined") || (voiceRuntimeSet && !validVoiceRuntime(voiceRuntime)) {
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)
}
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)
}
}
}
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)
}
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",
Detail: map[string]any{"from_version": currentVersion, "to_version": finalVersion}}); 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 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
}
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 {
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)
}
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)
}
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)
}
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)
}
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
}
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
}
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)
}
ciphertext, err := onboarding.SealDeviceKey(s.onboardingMasterKey, rotationDeviceID, version, secretBytes)
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)
}
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
}
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
}
_, 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
}
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
}
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),
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,'')
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
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'
LEFT JOIN hardware_agent_device_binding hb ON hb.device_id=d.id AND hb.tenant_id=d.tenant_id`
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,
&item.HouseholdID, &item.HouseholdName, &item.SubjectID, &item.SubjectName, &item.CredentialStatus, &item.KeyVersion,
&item.FirmwareVersion, &item.LastReportedAt, &item.RegisteredAt, &item.UpdatedAt, &item.Version,
&item.VoiceRuntime, &item.HardwareAgentBotID, &item.HardwareAgentTransport, &item.HardwareAgentMinimumSDKVersion, &item.HardwareAgentStatus); err != nil {
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
}