chore: checkpoint 第一版
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-06 17:04:11 +08:00
parent a8cdec98d1
commit 0c2151ace9
659 changed files with 185855 additions and 554 deletions
+371 -57
View File
@@ -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, &currentVersion); 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, &currentVersion, &currentActivationState); 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, &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 {
@@ -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))