+371
-57
@@ -6,12 +6,14 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
@@ -21,11 +23,13 @@ import (
|
||||
)
|
||||
|
||||
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")
|
||||
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")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -35,23 +39,33 @@ type Service struct {
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Model string `json:"model"`
|
||||
HardwareRevision *string `json:"hardware_revision,omitempty"`
|
||||
Status string `json:"status"`
|
||||
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"`
|
||||
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int64 `json:"version"`
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
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 {
|
||||
@@ -62,22 +76,50 @@ type AssignmentOption struct {
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Model string
|
||||
HardwareRevision string
|
||||
MinimumSecurityVersion string
|
||||
MinimumFirmwareVersion string
|
||||
HouseholdID string
|
||||
SubjectID string
|
||||
Model string
|
||||
HardwareRevision string
|
||||
MinimumSecurityVersion string
|
||||
MinimumFirmwareVersion string
|
||||
HouseholdID string
|
||||
SubjectID string
|
||||
VoiceRuntime string
|
||||
HardwareAgentBotID string
|
||||
HardwareAgentTransport string
|
||||
HardwareAgentMinimumSDKVersion string
|
||||
InitialActivationState 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
|
||||
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 {
|
||||
@@ -161,14 +203,77 @@ func (s *Service) Options(ctx context.Context, principal identity.Principal, ten
|
||||
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)
|
||||
input.HardwareRevision = strings.TrimSpace(input.HardwareRevision)
|
||||
input.MinimumSecurityVersion = strings.TrimSpace(input.MinimumSecurityVersion)
|
||||
input.MinimumFirmwareVersion = strings.TrimSpace(input.MinimumFirmwareVersion)
|
||||
input.VoiceRuntime = strings.TrimSpace(input.VoiceRuntime)
|
||||
input.HardwareAgentBotID = strings.TrimSpace(input.HardwareAgentBotID)
|
||||
input.HardwareAgentTransport = strings.TrimSpace(input.HardwareAgentTransport)
|
||||
input.HardwareAgentMinimumSDKVersion = strings.TrimSpace(input.HardwareAgentMinimumSDKVersion)
|
||||
input.InitialActivationState = strings.ToUpper(strings.TrimSpace(input.InitialActivationState))
|
||||
if input.VoiceRuntime == "" {
|
||||
input.VoiceRuntime = "legacy_voicechat"
|
||||
}
|
||||
if input.HardwareAgentTransport == "" {
|
||||
input.HardwareAgentTransport = "high_quality"
|
||||
}
|
||||
activationState := deviceactivation.Claimable
|
||||
if input.HouseholdID != "" {
|
||||
activationState = deviceactivation.Bound
|
||||
}
|
||||
if input.InitialActivationState != "" {
|
||||
parsed, err := deviceactivation.Parse(input.InitialActivationState)
|
||||
if err != nil || (parsed != deviceactivation.Factory && parsed != activationState) {
|
||||
return CreateResult{}, ErrInvalid
|
||||
}
|
||||
activationState = parsed
|
||||
}
|
||||
if activationState == deviceactivation.Factory && input.HouseholdID != "" {
|
||||
return CreateResult{}, ErrInvalid
|
||||
}
|
||||
if input.Model == "" || len([]rune(input.Model)) > 120 || len([]rune(input.HardwareRevision)) > 80 ||
|
||||
len([]rune(input.MinimumSecurityVersion)) > 80 || len([]rune(input.MinimumFirmwareVersion)) > 80 ||
|
||||
((input.HouseholdID == "") != (input.SubjectID == "")) {
|
||||
((input.HouseholdID == "") != (input.SubjectID == "")) || !validVoiceRuntime(input.VoiceRuntime) {
|
||||
return CreateResult{}, ErrInvalid
|
||||
}
|
||||
secretBytes := make([]byte, 32)
|
||||
@@ -200,14 +305,19 @@ func (s *Service) Create(ctx context.Context, principal identity.Principal, tena
|
||||
}
|
||||
var deviceID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO device(public_id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version,minimum_firmware_version)
|
||||
VALUES($1::uuid,$2::uuid,$3,$4,NULLIF($5,''),'registered',$6,NULLIF($7,''),NULLIF($8,'')) RETURNING id::text`,
|
||||
INSERT INTO device(public_id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version,minimum_firmware_version,activation_state,activation_reason)
|
||||
VALUES($1::uuid,$2::uuid,$3,$4,NULLIF($5,''),'registered',$6,NULLIF($7,''),NULLIF($8,''),$9,$10) RETURNING id::text`,
|
||||
publicID, access.tenantID, hex.EncodeToString(serialDigest[:]), input.Model, input.HardwareRevision, credentialRef,
|
||||
input.MinimumSecurityVersion, input.MinimumFirmwareVersion).Scan(&deviceID)
|
||||
input.MinimumSecurityVersion, input.MinimumFirmwareVersion, activationState, "ops-device-create").Scan(&deviceID)
|
||||
if err != nil {
|
||||
return CreateResult{}, fmt.Errorf("insert device: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO device_twin(device_id,tenant_id) VALUES($1::uuid,$2::uuid)`, deviceID, access.tenantID); err != nil {
|
||||
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,$3,'ops-device-create','operator','user',$4::uuid,NULLIF($5,''),1)`, access.tenantID, deviceID, activationState, access.userPublicID, requestID); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("record initial device activation: %w", err)
|
||||
}
|
||||
desiredConfig, _ := json.Marshal(map[string]string{"voice_runtime": input.VoiceRuntime})
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO device_twin(device_id,tenant_id,desired_config) VALUES($1::uuid,$2::uuid,$3::jsonb)`, deviceID, access.tenantID, desiredConfig); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("insert device twin: %w", err)
|
||||
}
|
||||
if input.HouseholdID != "" {
|
||||
@@ -219,13 +329,26 @@ func (s *Service) Create(ctx context.Context, principal identity.Principal, tena
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
if activationState == deviceactivation.Bound && input.VoiceRuntime == "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: &access.userPublicID, RequestID: requestID}); err != nil {
|
||||
return CreateResult{}, 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 CreateResult{}, mapActivationError(err)
|
||||
}
|
||||
activationState = deviceactivation.Active
|
||||
}
|
||||
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{"model": input.Model, "assigned": input.HouseholdID != "", "key_version": keyVersion}}); err != nil {
|
||||
Detail: map[string]any{"model": input.Model, "assigned": input.HouseholdID != "", "key_version": keyVersion, "activation_state": activationState}}); err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
var claim *DeviceClaimIssue
|
||||
if input.HouseholdID == "" {
|
||||
if activationState == deviceactivation.Claimable {
|
||||
issued, err := s.issueDeviceClaimTx(ctx, tx, access, publicID, deviceID, requestID)
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
@@ -252,16 +375,16 @@ func (s *Service) IssueDeviceClaim(ctx context.Context, principal identity.Princ
|
||||
if err != nil {
|
||||
return DeviceClaimIssue{}, err
|
||||
}
|
||||
var deviceID, status string
|
||||
var deviceID, status, activationState string
|
||||
var bound bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT d.id::text,d.status,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, &bound); errors.Is(err, pgx.ErrNoRows) {
|
||||
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" || bound {
|
||||
if status != "registered" || activationState != string(deviceactivation.Claimable) || bound {
|
||||
return DeviceClaimIssue{}, ErrClaimUnavailable
|
||||
}
|
||||
issued, err := s.issueDeviceClaimTx(ctx, tx, access, devicePublicID, deviceID, requestID)
|
||||
@@ -299,7 +422,12 @@ func (s *Service) issueDeviceClaimTx(ctx context.Context, tx pgx.Tx, access oper
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, requestID string, expectedVersion int64, input UpdateInput) (Device, error) {
|
||||
if expectedVersion < 1 || (input.Model == nil && input.HardwareRevision == nil && input.MinimumSecurityVersion == nil && input.MinimumFirmwareVersion == nil && input.Status == nil && input.HouseholdID == nil && input.SubjectID == nil) {
|
||||
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) {
|
||||
@@ -314,9 +442,9 @@ func (s *Service) Update(ctx context.Context, principal identity.Principal, tena
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
var deviceID string
|
||||
var deviceID, currentActivationState string
|
||||
var currentVersion 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, ¤tVersion); errors.Is(err, pgx.ErrNoRows) {
|
||||
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, ¤tVersion, ¤tActivationState); errors.Is(err, pgx.ErrNoRows) {
|
||||
return Device{}, ErrNotFound
|
||||
} else if err != nil {
|
||||
return Device{}, fmt.Errorf("lock device: %w", err)
|
||||
@@ -329,8 +457,9 @@ func (s *Service) Update(ctx context.Context, principal identity.Principal, tena
|
||||
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") {
|
||||
(statusSet && status != "registered" && status != "quarantined") || (voiceRuntimeSet && !validVoiceRuntime(voiceRuntime)) {
|
||||
return Device{}, ErrInvalid
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device SET
|
||||
@@ -363,11 +492,84 @@ func (s *Service) Update(ctx context.Context, principal identity.Principal, tena
|
||||
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": currentVersion + 1}}); err != nil {
|
||||
Detail: map[string]any{"from_version": currentVersion, "to_version": finalVersion}}); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
item, err := readDevice(ctx, tx, access.tenantID, devicePublicID)
|
||||
@@ -400,7 +602,12 @@ func (s *Service) Delete(ctx context.Context, principal identity.Principal, tena
|
||||
if version != expectedVersion {
|
||||
return Device{}, ErrVersionConflict
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device SET status='revoked',revoked_at=COALESCE(revoked_at,now()),version=version+1,updated_at=now() WHERE id=$1::uuid`, deviceID); err != nil {
|
||||
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 {
|
||||
@@ -412,6 +619,12 @@ func (s *Service) Delete(ctx context.Context, principal identity.Principal, tena
|
||||
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 {
|
||||
@@ -427,6 +640,86 @@ func (s *Service) Delete(ctx context.Context, principal identity.Principal, tena
|
||||
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, ¤tState, &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 {
|
||||
@@ -510,24 +803,45 @@ func pointerValue(value *string) (string, bool) {
|
||||
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,d.model,d.hardware_revision,d.status,d.minimum_security_version,d.minimum_firmware_version,
|
||||
SELECT d.public_id::text,t.public_id::text,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(dc.status,'missing'),COALESCE(dc.key_version,0),
|
||||
dt.last_reported_at,d.registered_at,d.updated_at,d.version
|
||||
dt.firmware_version,dt.last_reported_at,d.registered_at,d.updated_at,d.version,
|
||||
COALESCE(dt.desired_config->>'voice_runtime','legacy_voicechat'),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_credential dc ON dc.device_id=d.id AND dc.tenant_id=d.tenant_id
|
||||
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.Model, &item.HardwareRevision, &item.Status, &item.MinimumSecurityVersion, &item.MinimumFirmwareVersion,
|
||||
if err := row.Scan(&item.ID, &item.TenantID, &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.LastReportedAt, &item.RegisteredAt, &item.UpdatedAt, &item.Version); err != nil {
|
||||
&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
|
||||
|
||||
@@ -40,6 +40,7 @@ func TestOperationsDeviceLifecycle(t *testing.T) {
|
||||
created, err := service.Create(ctx, principal, tenantID, "ops-device-integration-create", CreateInput{
|
||||
Model: "integration-terminal", HardwareRevision: "rev-test",
|
||||
HouseholdID: "10000000-0000-0000-0000-000000000211", SubjectID: "10000000-0000-0000-0000-000000000411",
|
||||
VoiceRuntime: "volc_hardware_agent",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -47,12 +48,16 @@ func TestOperationsDeviceLifecycle(t *testing.T) {
|
||||
deviceID := created.Device.ID
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_activation_transition WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM hardware_agent_device_binding WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_binding WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_credential WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_twin WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device WHERE public_id=$1::uuid`, deviceID)
|
||||
}()
|
||||
if len(created.Credential.Secret) != 64 || created.Credential.KeyVersion != 1 || created.Device.SubjectName == nil || *created.Device.SubjectName != "测试长者" {
|
||||
if len(created.Credential.Secret) != 64 || created.Credential.KeyVersion != 1 || created.Device.SubjectName == nil || *created.Device.SubjectName != "测试长者" ||
|
||||
created.Device.ActivationState != "BOUND" ||
|
||||
created.Device.VoiceRuntime != "volc_hardware_agent" || created.Device.HardwareAgentBotID != "" || created.Device.HardwareAgentTransport != "" || created.Device.HardwareAgentMinimumSDKVersion != "" || created.Device.HardwareAgentStatus != "" {
|
||||
t.Fatalf("unexpected created result: %+v", created)
|
||||
}
|
||||
if created.Claim != nil {
|
||||
@@ -80,7 +85,7 @@ func TestOperationsDeviceLifecycle(t *testing.T) {
|
||||
t.Fatalf("rotated credential did not resolve: err=%v", err)
|
||||
}
|
||||
retired, err := service.Delete(ctx, principal, tenantID, deviceID, "ops-device-integration-delete", updated.Version)
|
||||
if err != nil || retired.Status != "revoked" || retired.CredentialStatus != "revoked" || retired.HouseholdID != nil {
|
||||
if err != nil || retired.Status != "revoked" || retired.ActivationState != "REVOKED" || retired.CredentialStatus != "revoked" || retired.HouseholdID != nil || retired.HardwareAgentStatus != "" {
|
||||
t.Fatalf("unexpected delete result: %+v err=%v", retired, err)
|
||||
}
|
||||
if _, err := resolver.Resolve(ctx, "device-secret://"+deviceID); err == nil {
|
||||
@@ -116,12 +121,13 @@ func TestOperationsDeviceClaimIssueAndRotation(t *testing.T) {
|
||||
deviceID := created.Device.ID
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_activation_transition WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_claim WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_credential WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_twin WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device WHERE public_id=$1::uuid`, deviceID)
|
||||
}()
|
||||
if created.Claim == nil || len(created.Claim.ClaimCode) != 19 || !created.Claim.ExpiresAt.After(created.Claim.IssuedAt) {
|
||||
if created.Device.ActivationState != "CLAIMABLE" || created.Claim == nil || len(created.Claim.ClaimCode) != 19 || !created.Claim.ExpiresAt.After(created.Claim.IssuedAt) {
|
||||
t.Fatalf("unexpected initial claim: %+v", created.Claim)
|
||||
}
|
||||
firstDigest := sha256.Sum256([]byte(created.Claim.ClaimCode))
|
||||
|
||||
Reference in New Issue
Block a user