+90
-35
@@ -20,10 +20,12 @@ import (
|
||||
"data-terminal-cloud/internal/dataexport"
|
||||
"data-terminal-cloud/internal/device"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicereset"
|
||||
"data-terminal-cloud/internal/diagnostics"
|
||||
"data-terminal-cloud/internal/embeddingprovider"
|
||||
"data-terminal-cloud/internal/externalprobe"
|
||||
"data-terminal-cloud/internal/extraction"
|
||||
"data-terminal-cloud/internal/hardwareprovision"
|
||||
"data-terminal-cloud/internal/health"
|
||||
"data-terminal-cloud/internal/httpapi"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
@@ -46,6 +48,7 @@ import (
|
||||
"data-terminal-cloud/internal/rtc"
|
||||
"data-terminal-cloud/internal/saas"
|
||||
"data-terminal-cloud/internal/sharing"
|
||||
"data-terminal-cloud/internal/vikingmemory"
|
||||
"data-terminal-cloud/internal/voiceprovider"
|
||||
"data-terminal-cloud/internal/webevents"
|
||||
|
||||
@@ -80,7 +83,15 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
var exportStore *objectstore.Store
|
||||
var memoryEmbedding embeddingprovider.Provider
|
||||
var providerMemoryClient providermemory.Client
|
||||
var vikingMemory *vikingmemory.Service
|
||||
if serviceName == "api" || serviceName == "worker" {
|
||||
if cfg.VikingResourceID != "" {
|
||||
client, vikingErr := vikingmemory.NewClient(vikingmemory.Options{ResourceID: cfg.VikingResourceID, APIKey: cfg.VikingAPIKey, EventType: cfg.VikingEventType, ProfileType: cfg.VikingProfileType, Timeout: 8 * time.Second})
|
||||
if vikingErr != nil {
|
||||
return fmt.Errorf("configure Viking memory: %w", vikingErr)
|
||||
}
|
||||
vikingMemory = vikingmemory.NewService(databasePool, client)
|
||||
}
|
||||
memoryEmbedding, err = newMemoryEmbeddingProvider(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create memory embedding provider: %w", err)
|
||||
@@ -140,6 +151,8 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
var memoryToolSigner *memorytool.TokenSigner
|
||||
var contentModelService *contentmodel.Service
|
||||
var contentModelRegistry *contentmodel.Registry
|
||||
var hardwareProvisioningService *hardwareprovision.Service
|
||||
var deviceFactoryResetService *devicereset.Service
|
||||
if serviceName == "api" || serviceName == "worker" {
|
||||
var fallback extraction.Generator = extraction.DeterministicGenerator{}
|
||||
if cfg.ContentGenerator == "ark" {
|
||||
@@ -233,6 +246,21 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("create device verifier: %w", err)
|
||||
}
|
||||
hardwareProvisioningService, err = hardwareprovision.NewService(databasePool, audit.NewRepository(), hardwareprovision.Options{
|
||||
Enabled: cfg.VolcHardwareProvisioningEnabled, InstanceID: cfg.VolcHardwareInstanceID,
|
||||
ProductKey: cfg.VolcHardwareProductKey, BotID: cfg.VolcHardwareAgentBotID,
|
||||
Transport: cfg.VolcHardwareTransport, MinimumSDKVersion: cfg.VolcHardwareMinimumSDKVersion,
|
||||
ProductSecret: cfg.VolcHardwareProductSecret,
|
||||
CredentialMasterKey: cfg.VolcHardwareDeviceCredentialMasterKey, RegisterURL: cfg.VolcHardwareRegisterURL,
|
||||
RegisterTimeout: cfg.VolcHardwareRegisterTimeout, DeviceNamePrefix: cfg.VolcHardwareDeviceNamePrefix,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create hardware provider provisioning service: %w", err)
|
||||
}
|
||||
deviceFactoryResetService, err = devicereset.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create device factory reset service: %w", err)
|
||||
}
|
||||
deviceRepository = device.NewRepository(databasePool)
|
||||
deviceTelemetryService, err = device.NewTelemetryService(databasePool, idempotency.NewExecutor(databasePool))
|
||||
if err != nil {
|
||||
@@ -257,6 +285,8 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
orchestration.ServiceOptions{
|
||||
RTCAppID: cfg.RTCAppID, VoiceProvider: cfg.VoiceProvider, EngineMode: "s2s_hybrid", AllocationTTL: cfg.AllocationTTL,
|
||||
TokenTTL: cfg.RTCTokenTTL, HeartbeatInterval: cfg.HeartbeatInterval, RequirePublishedRouting: cfg.Environment == config.EnvironmentProd,
|
||||
HardwareBotID: cfg.VolcHardwareAgentBotID, HardwareTransport: cfg.VolcHardwareTransport,
|
||||
SubtitleCallbackURL: cfg.SubtitleCallbackURL, SubtitleCallbackSecret: []byte(cfg.SubtitleCallbackSecret), SubtitlePrimary: cfg.SubtitlePrimary,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -265,6 +295,13 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
if err = conversationCreator.ConfigureMemoryEmbedding(databasePool, memoryEmbedding); err != nil {
|
||||
return fmt.Errorf("configure conversation memory embedding: %w", err)
|
||||
}
|
||||
conversationCreator.ConfigureVikingMemory(vikingMemory)
|
||||
memoryToolService.ConfigureVikingMemory(vikingMemory)
|
||||
if cfg.MemoryToolBaseURL != "" {
|
||||
if err = conversationCreator.ConfigureHardwareMemoryTool(memoryToolSigner, cfg.MemoryToolBaseURL, cfg.MemoryToolTokenTTL); err != nil {
|
||||
return fmt.Errorf("configure hardware memory tool: %w", err)
|
||||
}
|
||||
}
|
||||
credentialRenewalWindow := cfg.RTCTokenTTL / 3
|
||||
minimumRenewalWindow := time.Duration(cfg.HeartbeatInterval*2) * time.Second
|
||||
if credentialRenewalWindow < minimumRenewalWindow {
|
||||
@@ -387,7 +424,17 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
if processorErr != nil {
|
||||
return fmt.Errorf("create content extraction processor: %w", processorErr)
|
||||
}
|
||||
outboxWorker.Register("conversation.content_extraction_requested", extractionProcessor.Handle)
|
||||
if vikingMemory != nil {
|
||||
outboxWorker.Register(vikingmemory.SyncEvent, vikingMemory.Handle)
|
||||
outboxWorker.Register("conversation.content_extraction_requested", func(ctx context.Context, event outbox.Event) error {
|
||||
if err := vikingMemory.Enqueue(ctx, event); err != nil {
|
||||
return err
|
||||
}
|
||||
return extractionProcessor.Handle(ctx, event)
|
||||
})
|
||||
} else {
|
||||
outboxWorker.Register("conversation.content_extraction_requested", extractionProcessor.Handle)
|
||||
}
|
||||
memoryIndexProcessor, processorErr := memoryindex.NewProcessor(databasePool, memoryEmbedding)
|
||||
if processorErr != nil {
|
||||
return fmt.Errorf("create memory index processor: %w", processorErr)
|
||||
@@ -415,6 +462,9 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
if processorErr != nil {
|
||||
return fmt.Errorf("create privacy delete processor: %w", processorErr)
|
||||
}
|
||||
if vikingMemory != nil {
|
||||
privacyDeleteProcessor.ConfigureVikingMemory(vikingMemory)
|
||||
}
|
||||
outboxWorker.Register("privacy.delete_requested", privacyDeleteProcessor.Handle)
|
||||
exportProcessor, processorErr := dataexport.NewProcessor(databasePool, exportStore, cfg.ExportObjectTTL)
|
||||
if processorErr != nil {
|
||||
@@ -470,40 +520,45 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
}()
|
||||
}
|
||||
router := httpapi.NewRouter(httpapi.RouterOptions{
|
||||
ServiceName: serviceName,
|
||||
Logger: logger,
|
||||
RateLimiter: rateLimiterAdapter{limiter},
|
||||
Readiness: readiness,
|
||||
TokenVerifier: tokenVerifier,
|
||||
IdentityRepository: identityRepository,
|
||||
SaaSService: saasService,
|
||||
DeviceVerifier: deviceVerifier,
|
||||
DeviceService: deviceRepository,
|
||||
DeviceTelemetrySubmitter: deviceTelemetryService,
|
||||
MediaService: mediaService,
|
||||
WebEventService: webEventService,
|
||||
SharingService: sharingService,
|
||||
OpsConfigService: opsConfigService,
|
||||
BillingService: billingService,
|
||||
AlertService: alertService,
|
||||
DiagnosticService: diagnosticService,
|
||||
OpsAccessService: opsAccessService,
|
||||
OpsDeviceService: opsDeviceService,
|
||||
ContentModelService: contentModelService,
|
||||
ConversationCreator: conversationCreator,
|
||||
ConversationActivator: conversationCreator,
|
||||
DeviceConversationStateReader: conversationCreator,
|
||||
ConversationHeartbeater: conversationHeartbeater,
|
||||
ConversationStopper: conversationCreator,
|
||||
ConversationTranscriptSubmitter: conversationTranscriptSubmitter,
|
||||
ProviderCallbackSecret: []byte(cfg.ProviderCallbackSecret),
|
||||
ProviderCallbackSkew: cfg.ProviderCallbackSkew,
|
||||
ProviderCallbackProcessor: providerEventProcessor,
|
||||
PlatformHealthCallbackSecret: []byte(cfg.PlatformHealthSecret),
|
||||
PlatformHealthCallbackSkew: cfg.PlatformHealthSkew,
|
||||
PlatformHealthProcessor: diagnosticService,
|
||||
MemoryToolService: memoryToolService,
|
||||
MemoryToolSigner: memoryToolSigner,
|
||||
ServiceName: serviceName,
|
||||
Logger: logger,
|
||||
RateLimiter: rateLimiterAdapter{limiter},
|
||||
Readiness: readiness,
|
||||
TokenVerifier: tokenVerifier,
|
||||
IdentityRepository: identityRepository,
|
||||
SaaSService: saasService,
|
||||
DeviceVerifier: deviceVerifier,
|
||||
DeviceService: deviceRepository,
|
||||
DeviceTelemetrySubmitter: deviceTelemetryService,
|
||||
MediaService: mediaService,
|
||||
WebEventService: webEventService,
|
||||
SharingService: sharingService,
|
||||
OpsConfigService: opsConfigService,
|
||||
BillingService: billingService,
|
||||
AlertService: alertService,
|
||||
DiagnosticService: diagnosticService,
|
||||
OpsAccessService: opsAccessService,
|
||||
OpsDeviceService: opsDeviceService,
|
||||
ContentModelService: contentModelService,
|
||||
ConversationCreator: conversationCreator,
|
||||
ConversationActivator: conversationCreator,
|
||||
DeviceConversationStateReader: conversationCreator,
|
||||
ConversationHeartbeater: conversationHeartbeater,
|
||||
ConversationStopper: conversationCreator,
|
||||
ConversationTranscriptSubmitter: conversationTranscriptSubmitter,
|
||||
HardwareAgentSessionService: conversationCreator,
|
||||
HardwareAgentProvisioningService: hardwareProvisioningService,
|
||||
DeviceFactoryResetService: deviceFactoryResetService,
|
||||
ProviderCallbackSecret: []byte(cfg.ProviderCallbackSecret),
|
||||
ProviderCallbackSkew: cfg.ProviderCallbackSkew,
|
||||
ProviderCallbackProcessor: providerEventProcessor,
|
||||
NativeSubtitleSecret: []byte(cfg.SubtitleCallbackSecret),
|
||||
NativeSubtitleProcessor: orchestration.NewNativeSubtitleService(databasePool),
|
||||
PlatformHealthCallbackSecret: []byte(cfg.PlatformHealthSecret),
|
||||
PlatformHealthCallbackSkew: cfg.PlatformHealthSkew,
|
||||
PlatformHealthProcessor: diagnosticService,
|
||||
MemoryToolService: memoryToolService,
|
||||
MemoryToolSigner: memoryToolSigner,
|
||||
})
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
|
||||
+241
-175
@@ -23,101 +23,119 @@ const (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServiceName string
|
||||
Environment string
|
||||
LogLevel string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
AuthIssuerURL string
|
||||
AuthJWKSURL string
|
||||
AuthAudience string
|
||||
OTLPEndpoint string
|
||||
RateLimitRPS float64
|
||||
RateLimitBurst int
|
||||
RateLimitUserRPS float64
|
||||
RateLimitUserBurst int
|
||||
RateLimitTenantRPS float64
|
||||
RateLimitTenantBurst int
|
||||
RateLimitDeviceRPS float64
|
||||
RateLimitDeviceBurst int
|
||||
ProviderStartRPS float64
|
||||
ProviderStartBurst int
|
||||
ProviderMaxActiveTasks int
|
||||
DeviceLocalSecret string
|
||||
DeviceCredentialMasterKey string
|
||||
ContentModelMasterKey string
|
||||
DeviceClockSkew time.Duration
|
||||
RTCAppID string
|
||||
RTCAppKey string
|
||||
RTCTokenIssuer string
|
||||
RTCTokenSecret string
|
||||
CursorSigningSecret string
|
||||
ShareTokenSecret string
|
||||
RTCTokenTTL time.Duration
|
||||
AllocationTTL time.Duration
|
||||
HeartbeatInterval int
|
||||
ProviderCallbackSecret string
|
||||
ProviderCallbackSkew time.Duration
|
||||
PlatformHealthSecret string
|
||||
PlatformHealthSkew time.Duration
|
||||
MockProviderBaseURL string
|
||||
MockProviderAPIKey string
|
||||
VoiceProvider string
|
||||
VolcRTCBaseURL string
|
||||
VolcAccessKeyID string
|
||||
VolcSecretAccessKey string
|
||||
VolcSessionToken string
|
||||
VolcRTCRegion string
|
||||
VolcRTCAPIVersion string
|
||||
VolcVoiceChatTemplate string
|
||||
ProviderRequestTimeout time.Duration
|
||||
ProviderFailureThreshold int
|
||||
ProviderCircuitCooldown time.Duration
|
||||
DependencyTimeout time.Duration
|
||||
ExternalProbeInterval time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
WorkerPollInterval time.Duration
|
||||
RecoveryScanInterval time.Duration
|
||||
RecoveryStartingAfter time.Duration
|
||||
RecoveryStoppingAfter time.Duration
|
||||
ObjectStoreEndpoint string
|
||||
ObjectStorePublic string
|
||||
ObjectStoreAccessKey string
|
||||
ObjectStoreSecretKey string
|
||||
ObjectStoreBucket string
|
||||
ObjectStoreRegion string
|
||||
ObjectStoreSecure bool
|
||||
ObjectStorePublicSecure bool
|
||||
ExportDownloadTTL time.Duration
|
||||
ExportObjectTTL time.Duration
|
||||
MediaLocalDeleteGrace time.Duration
|
||||
AlertWebhookURL string
|
||||
AlertWebhookSecret string
|
||||
AlertWebhookTimeout time.Duration
|
||||
ContentGenerator string
|
||||
ArkBaseURL string
|
||||
ArkAPIKey string
|
||||
ArkContentModel string
|
||||
ArkRequestTimeout time.Duration
|
||||
ContentSafetyProvider string
|
||||
ContentSafetyEndpoint string
|
||||
ContentSafetyAPIKey string
|
||||
ContentSafetyPolicyRef string
|
||||
ContentSafetyTimeout time.Duration
|
||||
EmbeddingProvider string
|
||||
ArkEmbeddingModel string
|
||||
MemoryToolBaseURL string
|
||||
MemoryToolSigningSecret string
|
||||
MemoryToolTokenTTL time.Duration
|
||||
MemoryToolRateRPS float64
|
||||
MemoryToolRateBurst int
|
||||
ProviderMemoryMode string
|
||||
ProviderMemoryBaseURL string
|
||||
ProviderMemoryAPIKey string
|
||||
ProviderMemoryTimeout time.Duration
|
||||
VikingResourceID string
|
||||
VikingAPIKey string
|
||||
VikingEventType string
|
||||
VikingProfileType string
|
||||
ServiceName string
|
||||
Environment string
|
||||
LogLevel string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
AuthIssuerURL string
|
||||
AuthJWKSURL string
|
||||
AuthAudience string
|
||||
OTLPEndpoint string
|
||||
RateLimitRPS float64
|
||||
RateLimitBurst int
|
||||
RateLimitUserRPS float64
|
||||
RateLimitUserBurst int
|
||||
RateLimitTenantRPS float64
|
||||
RateLimitTenantBurst int
|
||||
RateLimitDeviceRPS float64
|
||||
RateLimitDeviceBurst int
|
||||
ProviderStartRPS float64
|
||||
ProviderStartBurst int
|
||||
ProviderMaxActiveTasks int
|
||||
DeviceLocalSecret string
|
||||
DeviceCredentialMasterKey string
|
||||
ContentModelMasterKey string
|
||||
DeviceClockSkew time.Duration
|
||||
RTCAppID string
|
||||
RTCAppKey string
|
||||
RTCTokenIssuer string
|
||||
RTCTokenSecret string
|
||||
CursorSigningSecret string
|
||||
ShareTokenSecret string
|
||||
RTCTokenTTL time.Duration
|
||||
AllocationTTL time.Duration
|
||||
HeartbeatInterval int
|
||||
ProviderCallbackSecret string
|
||||
SubtitleCallbackURL string
|
||||
SubtitleCallbackSecret string
|
||||
SubtitlePrimary bool
|
||||
ProviderCallbackSkew time.Duration
|
||||
PlatformHealthSecret string
|
||||
PlatformHealthSkew time.Duration
|
||||
MockProviderBaseURL string
|
||||
MockProviderAPIKey string
|
||||
VoiceProvider string
|
||||
VolcRTCBaseURL string
|
||||
VolcAccessKeyID string
|
||||
VolcSecretAccessKey string
|
||||
VolcSessionToken string
|
||||
VolcRTCRegion string
|
||||
VolcRTCAPIVersion string
|
||||
VolcVoiceChatTemplate string
|
||||
VolcHardwareAgentBotID string
|
||||
VolcHardwareTransport string
|
||||
VolcHardwareMinimumSDKVersion string
|
||||
VolcHardwareProvisioningEnabled bool
|
||||
VolcHardwareInstanceID string
|
||||
VolcHardwareProductKey string
|
||||
VolcHardwareProductSecret string
|
||||
VolcHardwareDeviceCredentialMasterKey string
|
||||
VolcHardwareRegisterURL string
|
||||
VolcHardwareRegisterTimeout time.Duration
|
||||
VolcHardwareDeviceNamePrefix string
|
||||
ProviderRequestTimeout time.Duration
|
||||
ProviderFailureThreshold int
|
||||
ProviderCircuitCooldown time.Duration
|
||||
DependencyTimeout time.Duration
|
||||
ExternalProbeInterval time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
WorkerPollInterval time.Duration
|
||||
RecoveryScanInterval time.Duration
|
||||
RecoveryStartingAfter time.Duration
|
||||
RecoveryStoppingAfter time.Duration
|
||||
ObjectStoreEndpoint string
|
||||
ObjectStorePublic string
|
||||
ObjectStoreAccessKey string
|
||||
ObjectStoreSecretKey string
|
||||
ObjectStoreBucket string
|
||||
ObjectStoreRegion string
|
||||
ObjectStoreSecure bool
|
||||
ObjectStorePublicSecure bool
|
||||
ExportDownloadTTL time.Duration
|
||||
ExportObjectTTL time.Duration
|
||||
MediaLocalDeleteGrace time.Duration
|
||||
AlertWebhookURL string
|
||||
AlertWebhookSecret string
|
||||
AlertWebhookTimeout time.Duration
|
||||
ContentGenerator string
|
||||
ArkBaseURL string
|
||||
ArkAPIKey string
|
||||
ArkContentModel string
|
||||
ArkRequestTimeout time.Duration
|
||||
ContentSafetyProvider string
|
||||
ContentSafetyEndpoint string
|
||||
ContentSafetyAPIKey string
|
||||
ContentSafetyPolicyRef string
|
||||
ContentSafetyTimeout time.Duration
|
||||
EmbeddingProvider string
|
||||
ArkEmbeddingModel string
|
||||
MemoryToolBaseURL string
|
||||
MemoryToolSigningSecret string
|
||||
MemoryToolTokenTTL time.Duration
|
||||
MemoryToolRateRPS float64
|
||||
MemoryToolRateBurst int
|
||||
ProviderMemoryMode string
|
||||
ProviderMemoryBaseURL string
|
||||
ProviderMemoryAPIKey string
|
||||
ProviderMemoryTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load(serviceName string) (Config, error) {
|
||||
@@ -217,6 +235,14 @@ func Load(serviceName string) (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
volcHardwareProvisioningEnabled, err := boolEnv("VOLC_HARDWARE_PROVISIONING_ENABLED", false)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
volcHardwareRegisterTimeout, err := durationEnv("VOLC_HARDWARE_REGISTER_TIMEOUT", 10*time.Second)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
objectStoreSecure, err := boolEnv("OBJECT_STORE_SECURE", false)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
@@ -229,7 +255,9 @@ func Load(serviceName string) (Config, error) {
|
||||
secretValues, err := loadSecretValues(
|
||||
"DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "DEVICE_CREDENTIAL_MASTER_KEY", "MODEL_CREDENTIAL_MASTER_KEY", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET",
|
||||
"CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET", "PLATFORM_HEALTH_CALLBACK_SECRET",
|
||||
"VOLC_SUBTITLE_CALLBACK_SECRET", "VIKING_MEMORY_API_KEY",
|
||||
"MOCK_PROVIDER_API_KEY", "VOLC_ACCESS_KEY_ID", "VOLC_SECRET_ACCESS_KEY", "VOLC_SESSION_TOKEN",
|
||||
"VOLC_HARDWARE_PRODUCT_SECRET", "VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY",
|
||||
"OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY", "ALERT_WEBHOOK_SECRET", "ARK_API_KEY", "CONTENT_SAFETY_API_KEY", "MEMORY_TOOL_SIGNING_SECRET", "PROVIDER_MEMORY_API_KEY",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -295,86 +323,104 @@ func Load(serviceName string) (Config, error) {
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
ServiceName: serviceName,
|
||||
Environment: environment,
|
||||
LogLevel: stringEnv("LOG_LEVEL", "info"),
|
||||
HTTPAddr: stringEnv("HTTP_ADDR", defaultHTTPAddr(serviceName)),
|
||||
DatabaseURL: secretDefault(secretValues["DATABASE_URL"], "postgres://voice:voice_dev_only@localhost:55432/voice_platform?sslmode=disable"),
|
||||
RedisAddr: stringEnv("REDIS_ADDR", "localhost:56379"),
|
||||
RedisPassword: secretValues["REDIS_PASSWORD"],
|
||||
RedisDB: redisDB,
|
||||
AuthIssuerURL: stringEnv("AUTH_ISSUER_URL", "http://localhost:58180/realms/voice-local"),
|
||||
AuthJWKSURL: stringEnv("AUTH_JWKS_URL", "http://localhost:58180/realms/voice-local/protocol/openid-connect/certs"),
|
||||
AuthAudience: stringEnv("AUTH_AUDIENCE", "voice-platform-web"),
|
||||
OTLPEndpoint: stringEnvAllowEmpty("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:54317"),
|
||||
DependencyTimeout: dependencyTimeout,
|
||||
ExternalProbeInterval: externalProbeInterval,
|
||||
ShutdownTimeout: shutdownTimeout,
|
||||
WorkerPollInterval: pollInterval,
|
||||
RecoveryScanInterval: recoveryScanInterval,
|
||||
RecoveryStartingAfter: recoveryStartingAfter,
|
||||
RecoveryStoppingAfter: recoveryStoppingAfter,
|
||||
DeviceLocalSecret: deviceLocalSecret,
|
||||
DeviceCredentialMasterKey: deviceCredentialMasterKey,
|
||||
ContentModelMasterKey: contentModelMasterKey,
|
||||
DeviceClockSkew: deviceClockSkew,
|
||||
RTCAppID: stringEnv("RTC_APP_ID", "local-public-rtc-app"),
|
||||
RTCAppKey: secretValues["RTC_APP_KEY"],
|
||||
RTCTokenIssuer: stringEnv("RTC_TOKEN_ISSUER", "local"),
|
||||
RTCTokenSecret: rtcTokenSecret,
|
||||
CursorSigningSecret: cursorSigningSecret,
|
||||
ShareTokenSecret: shareTokenSecret,
|
||||
RTCTokenTTL: rtcTokenTTL,
|
||||
AllocationTTL: allocationTTL,
|
||||
ProviderCallbackSecret: providerCallbackSecret,
|
||||
ProviderCallbackSkew: providerCallbackSkew,
|
||||
PlatformHealthSecret: secretValues["PLATFORM_HEALTH_CALLBACK_SECRET"],
|
||||
PlatformHealthSkew: platformHealthSkew,
|
||||
MockProviderBaseURL: stringEnv("MOCK_PROVIDER_BASE_URL", "http://localhost:59200"),
|
||||
MockProviderAPIKey: secretDefault(secretValues["MOCK_PROVIDER_API_KEY"], "local-mock-provider-key"),
|
||||
VoiceProvider: stringEnv("VOICE_PROVIDER", "mock"),
|
||||
VolcRTCBaseURL: stringEnv("VOLC_RTC_BASE_URL", "https://rtc.volcengineapi.com"),
|
||||
VolcAccessKeyID: secretValues["VOLC_ACCESS_KEY_ID"],
|
||||
VolcSecretAccessKey: secretValues["VOLC_SECRET_ACCESS_KEY"],
|
||||
VolcSessionToken: secretValues["VOLC_SESSION_TOKEN"],
|
||||
VolcRTCRegion: stringEnv("VOLC_RTC_REGION", "cn-north-1"),
|
||||
VolcRTCAPIVersion: stringEnv("VOLC_RTC_API_VERSION", "2024-12-01"),
|
||||
VolcVoiceChatTemplate: strings.TrimSpace(os.Getenv("VOLC_VOICE_CHAT_TEMPLATE_FILE")),
|
||||
ProviderRequestTimeout: providerRequestTimeout,
|
||||
ProviderCircuitCooldown: providerCircuitCooldown,
|
||||
ObjectStoreEndpoint: objectStoreEndpoint,
|
||||
ObjectStorePublic: objectStorePublic,
|
||||
ObjectStoreAccessKey: objectStoreAccessKey,
|
||||
ObjectStoreSecretKey: objectStoreSecretKey,
|
||||
ObjectStoreBucket: objectStoreBucket,
|
||||
ObjectStoreRegion: objectStoreRegion,
|
||||
ObjectStoreSecure: objectStoreSecure,
|
||||
ObjectStorePublicSecure: objectStorePublicSecure,
|
||||
ExportDownloadTTL: exportDownloadTTL,
|
||||
ExportObjectTTL: exportObjectTTL,
|
||||
MediaLocalDeleteGrace: mediaLocalDeleteGrace,
|
||||
AlertWebhookURL: strings.TrimSpace(os.Getenv("ALERT_WEBHOOK_URL")),
|
||||
AlertWebhookSecret: secretValues["ALERT_WEBHOOK_SECRET"],
|
||||
AlertWebhookTimeout: alertWebhookTimeout,
|
||||
ContentGenerator: stringEnv("CONTENT_GENERATOR", "local"),
|
||||
ArkBaseURL: stringEnv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
|
||||
ArkAPIKey: secretValues["ARK_API_KEY"],
|
||||
ArkContentModel: strings.TrimSpace(os.Getenv("ARK_CONTENT_MODEL")),
|
||||
ArkRequestTimeout: arkRequestTimeout,
|
||||
ContentSafetyProvider: stringEnv("CONTENT_SAFETY_PROVIDER", "local"),
|
||||
ContentSafetyEndpoint: strings.TrimSpace(os.Getenv("CONTENT_SAFETY_ENDPOINT")),
|
||||
ContentSafetyAPIKey: secretValues["CONTENT_SAFETY_API_KEY"],
|
||||
ContentSafetyPolicyRef: strings.TrimSpace(os.Getenv("CONTENT_SAFETY_POLICY_REF")),
|
||||
ContentSafetyTimeout: contentSafetyTimeout,
|
||||
EmbeddingProvider: stringEnv("EMBEDDING_PROVIDER", "local"),
|
||||
ArkEmbeddingModel: strings.TrimSpace(os.Getenv("ARK_EMBEDDING_MODEL")),
|
||||
MemoryToolBaseURL: strings.TrimSpace(os.Getenv("MEMORY_TOOL_BASE_URL")),
|
||||
MemoryToolSigningSecret: memoryToolSigningSecret,
|
||||
MemoryToolTokenTTL: memoryToolTokenTTL,
|
||||
ProviderMemoryMode: stringEnv("PROVIDER_MEMORY_MODE", "disabled"),
|
||||
ProviderMemoryBaseURL: strings.TrimSpace(os.Getenv("PROVIDER_MEMORY_BASE_URL")),
|
||||
ProviderMemoryAPIKey: secretValues["PROVIDER_MEMORY_API_KEY"],
|
||||
ProviderMemoryTimeout: providerMemoryTimeout,
|
||||
ServiceName: serviceName,
|
||||
Environment: environment,
|
||||
LogLevel: stringEnv("LOG_LEVEL", "info"),
|
||||
HTTPAddr: stringEnv("HTTP_ADDR", defaultHTTPAddr(serviceName)),
|
||||
DatabaseURL: secretDefault(secretValues["DATABASE_URL"], "postgres://voice:voice_dev_only@localhost:55432/voice_platform?sslmode=disable"),
|
||||
RedisAddr: stringEnv("REDIS_ADDR", "localhost:56379"),
|
||||
RedisPassword: secretValues["REDIS_PASSWORD"],
|
||||
RedisDB: redisDB,
|
||||
AuthIssuerURL: stringEnv("AUTH_ISSUER_URL", "http://localhost:58180/realms/voice-local"),
|
||||
AuthJWKSURL: stringEnv("AUTH_JWKS_URL", "http://localhost:58180/realms/voice-local/protocol/openid-connect/certs"),
|
||||
AuthAudience: stringEnv("AUTH_AUDIENCE", "voice-platform-web"),
|
||||
OTLPEndpoint: stringEnvAllowEmpty("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:54317"),
|
||||
DependencyTimeout: dependencyTimeout,
|
||||
ExternalProbeInterval: externalProbeInterval,
|
||||
ShutdownTimeout: shutdownTimeout,
|
||||
WorkerPollInterval: pollInterval,
|
||||
RecoveryScanInterval: recoveryScanInterval,
|
||||
RecoveryStartingAfter: recoveryStartingAfter,
|
||||
RecoveryStoppingAfter: recoveryStoppingAfter,
|
||||
DeviceLocalSecret: deviceLocalSecret,
|
||||
DeviceCredentialMasterKey: deviceCredentialMasterKey,
|
||||
ContentModelMasterKey: contentModelMasterKey,
|
||||
DeviceClockSkew: deviceClockSkew,
|
||||
RTCAppID: stringEnv("RTC_APP_ID", "local-public-rtc-app"),
|
||||
RTCAppKey: secretValues["RTC_APP_KEY"],
|
||||
RTCTokenIssuer: stringEnv("RTC_TOKEN_ISSUER", "local"),
|
||||
RTCTokenSecret: rtcTokenSecret,
|
||||
CursorSigningSecret: cursorSigningSecret,
|
||||
ShareTokenSecret: shareTokenSecret,
|
||||
RTCTokenTTL: rtcTokenTTL,
|
||||
AllocationTTL: allocationTTL,
|
||||
ProviderCallbackSecret: providerCallbackSecret,
|
||||
SubtitleCallbackURL: strings.TrimSpace(os.Getenv("VOLC_SUBTITLE_CALLBACK_URL")),
|
||||
SubtitleCallbackSecret: secretValues["VOLC_SUBTITLE_CALLBACK_SECRET"],
|
||||
SubtitlePrimary: os.Getenv("VOLC_SUBTITLE_PRIMARY") == "true",
|
||||
ProviderCallbackSkew: providerCallbackSkew,
|
||||
PlatformHealthSecret: secretValues["PLATFORM_HEALTH_CALLBACK_SECRET"],
|
||||
PlatformHealthSkew: platformHealthSkew,
|
||||
MockProviderBaseURL: stringEnv("MOCK_PROVIDER_BASE_URL", "http://localhost:59200"),
|
||||
MockProviderAPIKey: secretDefault(secretValues["MOCK_PROVIDER_API_KEY"], "local-mock-provider-key"),
|
||||
VoiceProvider: stringEnv("VOICE_PROVIDER", "mock"),
|
||||
VolcRTCBaseURL: stringEnv("VOLC_RTC_BASE_URL", "https://rtc.volcengineapi.com"),
|
||||
VolcAccessKeyID: secretValues["VOLC_ACCESS_KEY_ID"],
|
||||
VolcSecretAccessKey: secretValues["VOLC_SECRET_ACCESS_KEY"],
|
||||
VolcSessionToken: secretValues["VOLC_SESSION_TOKEN"],
|
||||
VolcRTCRegion: stringEnv("VOLC_RTC_REGION", "cn-north-1"),
|
||||
VolcRTCAPIVersion: stringEnv("VOLC_RTC_API_VERSION", "2024-12-01"),
|
||||
VolcVoiceChatTemplate: strings.TrimSpace(os.Getenv("VOLC_VOICE_CHAT_TEMPLATE_FILE")),
|
||||
VolcHardwareAgentBotID: strings.TrimSpace(os.Getenv("VOLC_HARDWARE_AGENT_BOT_ID")),
|
||||
VolcHardwareTransport: stringEnv("VOLC_HARDWARE_AGENT_TRANSPORT", "high_quality"),
|
||||
VolcHardwareMinimumSDKVersion: stringEnv("VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION", "1.0.0"),
|
||||
VolcHardwareProvisioningEnabled: volcHardwareProvisioningEnabled,
|
||||
VolcHardwareInstanceID: strings.TrimSpace(os.Getenv("VOLC_HARDWARE_INSTANCE_ID")),
|
||||
VolcHardwareProductKey: strings.TrimSpace(os.Getenv("VOLC_HARDWARE_PRODUCT_KEY")),
|
||||
VolcHardwareProductSecret: secretValues["VOLC_HARDWARE_PRODUCT_SECRET"],
|
||||
VolcHardwareDeviceCredentialMasterKey: secretValues["VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY"],
|
||||
VolcHardwareRegisterURL: stringEnv("VOLC_HARDWARE_REGISTER_URL", "https://iot-cn-shanghai.iot.volces.com/2021-12-14/DynamicRegister?Action=DynamicRegister&Version=2021-12-14"),
|
||||
VolcHardwareRegisterTimeout: volcHardwareRegisterTimeout,
|
||||
VolcHardwareDeviceNamePrefix: stringEnv("VOLC_HARDWARE_DEVICE_NAME_PREFIX", "dt-"),
|
||||
ProviderRequestTimeout: providerRequestTimeout,
|
||||
ProviderCircuitCooldown: providerCircuitCooldown,
|
||||
ObjectStoreEndpoint: objectStoreEndpoint,
|
||||
ObjectStorePublic: objectStorePublic,
|
||||
ObjectStoreAccessKey: objectStoreAccessKey,
|
||||
ObjectStoreSecretKey: objectStoreSecretKey,
|
||||
ObjectStoreBucket: objectStoreBucket,
|
||||
ObjectStoreRegion: objectStoreRegion,
|
||||
ObjectStoreSecure: objectStoreSecure,
|
||||
ObjectStorePublicSecure: objectStorePublicSecure,
|
||||
ExportDownloadTTL: exportDownloadTTL,
|
||||
ExportObjectTTL: exportObjectTTL,
|
||||
MediaLocalDeleteGrace: mediaLocalDeleteGrace,
|
||||
AlertWebhookURL: strings.TrimSpace(os.Getenv("ALERT_WEBHOOK_URL")),
|
||||
AlertWebhookSecret: secretValues["ALERT_WEBHOOK_SECRET"],
|
||||
AlertWebhookTimeout: alertWebhookTimeout,
|
||||
ContentGenerator: stringEnv("CONTENT_GENERATOR", "local"),
|
||||
ArkBaseURL: stringEnv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
|
||||
ArkAPIKey: secretValues["ARK_API_KEY"],
|
||||
ArkContentModel: strings.TrimSpace(os.Getenv("ARK_CONTENT_MODEL")),
|
||||
ArkRequestTimeout: arkRequestTimeout,
|
||||
ContentSafetyProvider: stringEnv("CONTENT_SAFETY_PROVIDER", "local"),
|
||||
ContentSafetyEndpoint: strings.TrimSpace(os.Getenv("CONTENT_SAFETY_ENDPOINT")),
|
||||
ContentSafetyAPIKey: secretValues["CONTENT_SAFETY_API_KEY"],
|
||||
ContentSafetyPolicyRef: strings.TrimSpace(os.Getenv("CONTENT_SAFETY_POLICY_REF")),
|
||||
ContentSafetyTimeout: contentSafetyTimeout,
|
||||
EmbeddingProvider: stringEnv("EMBEDDING_PROVIDER", "local"),
|
||||
ArkEmbeddingModel: strings.TrimSpace(os.Getenv("ARK_EMBEDDING_MODEL")),
|
||||
MemoryToolBaseURL: strings.TrimSpace(os.Getenv("MEMORY_TOOL_BASE_URL")),
|
||||
MemoryToolSigningSecret: memoryToolSigningSecret,
|
||||
MemoryToolTokenTTL: memoryToolTokenTTL,
|
||||
VikingResourceID: strings.TrimSpace(os.Getenv("VIKING_MEMORY_RESOURCE_ID")),
|
||||
VikingAPIKey: secretValues["VIKING_MEMORY_API_KEY"],
|
||||
VikingEventType: stringEnv("VIKING_MEMORY_EVENT_TYPE", "event_v1"),
|
||||
VikingProfileType: stringEnv("VIKING_MEMORY_PROFILE_TYPE", "profile_v1"),
|
||||
ProviderMemoryMode: stringEnv("PROVIDER_MEMORY_MODE", "disabled"),
|
||||
ProviderMemoryBaseURL: strings.TrimSpace(os.Getenv("PROVIDER_MEMORY_BASE_URL")),
|
||||
ProviderMemoryAPIKey: secretValues["PROVIDER_MEMORY_API_KEY"],
|
||||
ProviderMemoryTimeout: providerMemoryTimeout,
|
||||
}
|
||||
cfg.RateLimitRPS, err = floatEnv("RATE_LIMIT_RPS", 50)
|
||||
if err != nil {
|
||||
@@ -444,6 +490,12 @@ func Load(serviceName string) (Config, error) {
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.SubtitleCallbackURL != "" || c.SubtitleCallbackSecret != "" || c.SubtitlePrimary {
|
||||
u, err := url.Parse(c.SubtitleCallbackURL)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil || len(c.SubtitleCallbackSecret) < 32 {
|
||||
return errors.New("native subtitle callback requires an HTTPS URL without query and a dedicated secret of at least 32 bytes")
|
||||
}
|
||||
}
|
||||
validEnvironment := map[string]bool{
|
||||
EnvironmentLocal: true,
|
||||
EnvironmentTest: true,
|
||||
@@ -550,6 +602,20 @@ func (c Config) Validate() error {
|
||||
if c.ProviderCallbackSkew <= 0 || c.PlatformHealthSkew <= 0 || c.ProviderRequestTimeout <= 0 || c.ProviderFailureThreshold < 1 || c.ProviderCircuitCooldown <= 0 {
|
||||
return errors.New("provider callback skew and request timeout must be positive")
|
||||
}
|
||||
if c.VolcHardwareTransport != "high_quality" && c.VolcHardwareTransport != "low_load" {
|
||||
return errors.New("VOLC_HARDWARE_AGENT_TRANSPORT must be high_quality or low_load")
|
||||
}
|
||||
if c.ServiceName == "api" && c.VolcHardwareProvisioningEnabled {
|
||||
providerKey, err := hex.DecodeString(c.VolcHardwareDeviceCredentialMasterKey)
|
||||
if err != nil || len(providerKey) != 32 {
|
||||
return errors.New("VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY must be 32 bytes encoded as 64 hexadecimal characters")
|
||||
}
|
||||
if c.VolcHardwareInstanceID == "" || c.VolcHardwareProductKey == "" || c.VolcHardwareAgentBotID == "" ||
|
||||
c.VolcHardwareMinimumSDKVersion == "" || len(c.VolcHardwareMinimumSDKVersion) > 80 ||
|
||||
len(c.VolcHardwareProductSecret) < 16 || c.VolcHardwareRegisterTimeout <= 0 {
|
||||
return errors.New("enabled hardware provisioning requires instance ID, product key, default Bot ID, minimum SDK, product secret and positive registration timeout")
|
||||
}
|
||||
}
|
||||
if c.AlertWebhookTimeout <= 0 {
|
||||
return errors.New("ALERT_WEBHOOK_TIMEOUT must be positive")
|
||||
}
|
||||
|
||||
@@ -260,3 +260,42 @@ func TestWorkerManagedContentSafetyConfiguration(t *testing.T) {
|
||||
t.Fatalf("Validate() rejected complete non-production managed content safety config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardwareProvisioningLoadsProductSecretFromFile(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "test")
|
||||
t.Setenv("VOLC_HARDWARE_PROVISIONING_ENABLED", "true")
|
||||
t.Setenv("VOLC_HARDWARE_INSTANCE_ID", "instance-1")
|
||||
t.Setenv("VOLC_HARDWARE_PRODUCT_KEY", "product-1")
|
||||
t.Setenv("VOLC_HARDWARE_AGENT_BOT_ID", "bot-test")
|
||||
t.Setenv("VOLC_HARDWARE_PRODUCT_SECRET", "")
|
||||
secretPath := filepath.Join(t.TempDir(), "product-secret")
|
||||
if err := os.WriteFile(secretPath, []byte("product-secret-at-least-16-bytes\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("VOLC_HARDWARE_PRODUCT_SECRET_FILE", secretPath)
|
||||
t.Setenv("VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY", strings.Repeat("ab", 32))
|
||||
|
||||
cfg, err := Load("api")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.VolcHardwareProductSecret != "product-secret-at-least-16-bytes" || !cfg.VolcHardwareProvisioningEnabled {
|
||||
t.Fatalf("hardware provisioning secret/config was not loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardwareProvisioningRejectsMissingProductSecret(t *testing.T) {
|
||||
cfg, err := Load("api")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.VolcHardwareProvisioningEnabled = true
|
||||
cfg.VolcHardwareInstanceID = "instance-1"
|
||||
cfg.VolcHardwareProductKey = "product-1"
|
||||
cfg.VolcHardwareAgentBotID = "bot-test"
|
||||
cfg.VolcHardwareProductSecret = ""
|
||||
cfg.VolcHardwareDeviceCredentialMasterKey = strings.Repeat("ab", 32)
|
||||
if err = cfg.Validate(); err == nil || !strings.Contains(err.Error(), "hardware provisioning") {
|
||||
t.Fatalf("Validate() error = %v, want missing product secret rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,20 @@ func TestDeviceContractCompatibilityGuardrails(t *testing.T) {
|
||||
if len(versions) < 10 {
|
||||
t.Fatalf("found only %d versioned device paths; contract scan likely stopped matching", len(versions))
|
||||
}
|
||||
v2Count, v3Count := 0, 0
|
||||
for _, match := range versions {
|
||||
if match[1] != "2" {
|
||||
t.Fatalf("device path %q bypasses the current v2 compatibility baseline", match[0])
|
||||
switch match[1] {
|
||||
case "2":
|
||||
v2Count++
|
||||
case "3":
|
||||
v3Count++
|
||||
default:
|
||||
t.Fatalf("device path %q uses an unsupported compatibility generation", match[0])
|
||||
}
|
||||
}
|
||||
if v2Count < 10 || v3Count < 2 {
|
||||
t.Fatalf("device contract must preserve v2 and publish hardware-agent v3; v2=%d v3=%d", v2Count, v3Count)
|
||||
}
|
||||
for _, required := range []string{
|
||||
"rtc_credential_renewal:",
|
||||
"省略时保持旧版v2终端行为",
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package deviceactivation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
Factory State = "FACTORY"
|
||||
Claimable State = "CLAIMABLE"
|
||||
Bound State = "BOUND"
|
||||
Provisioning State = "PROVISIONING"
|
||||
Active State = "ACTIVE"
|
||||
Lost State = "LOST"
|
||||
Revoked State = "REVOKED"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidState = errors.New("invalid device activation state")
|
||||
ErrTransitionRejected = errors.New("device activation transition rejected")
|
||||
ErrRevisionConflict = errors.New("device activation revision conflict")
|
||||
ErrDeviceNotFound = errors.New("device activation target not found")
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
DeviceID string
|
||||
TenantID string
|
||||
Target State
|
||||
Reason string
|
||||
Source string
|
||||
ActorType string
|
||||
ActorPublicID *string
|
||||
RequestID string
|
||||
ExpectedRevision *int64
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
From State
|
||||
To State
|
||||
Revision int64
|
||||
DeviceVersion int64
|
||||
UpdatedAt time.Time
|
||||
Changed bool
|
||||
}
|
||||
|
||||
func Parse(value string) (State, error) {
|
||||
state := State(strings.ToUpper(strings.TrimSpace(value)))
|
||||
switch state {
|
||||
case Factory, Claimable, Bound, Provisioning, Active, Lost, Revoked:
|
||||
return state, nil
|
||||
default:
|
||||
return "", ErrInvalidState
|
||||
}
|
||||
}
|
||||
|
||||
func AllowsDeviceAuthentication(state string) bool {
|
||||
parsed, err := Parse(state)
|
||||
if err != nil {
|
||||
// Unit-test fakes and rolling deployments may omit the new field. Database
|
||||
// rows are never empty after migration 50.
|
||||
return strings.TrimSpace(state) == ""
|
||||
}
|
||||
return parsed == Claimable || parsed == Bound || parsed == Provisioning || parsed == Active
|
||||
}
|
||||
|
||||
func CanTransition(from, to State) bool {
|
||||
if from == to {
|
||||
return true
|
||||
}
|
||||
if from == Revoked {
|
||||
return false
|
||||
}
|
||||
if to == Revoked {
|
||||
return true
|
||||
}
|
||||
switch from {
|
||||
case Factory:
|
||||
return to == Claimable
|
||||
case Claimable:
|
||||
return to == Bound
|
||||
case Bound:
|
||||
return to == Claimable || to == Provisioning
|
||||
case Provisioning:
|
||||
return to == Bound || to == Active
|
||||
case Active:
|
||||
return to == Claimable || to == Provisioning || to == Lost
|
||||
case Lost:
|
||||
return to == Claimable || to == Active
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func Apply(ctx context.Context, tx pgx.Tx, input Input) (Result, error) {
|
||||
if tx == nil || strings.TrimSpace(input.DeviceID) == "" || strings.TrimSpace(input.TenantID) == "" ||
|
||||
strings.TrimSpace(input.Reason) == "" || strings.TrimSpace(input.Source) == "" || strings.TrimSpace(input.ActorType) == "" {
|
||||
return Result{}, ErrInvalidState
|
||||
}
|
||||
target, err := Parse(string(input.Target))
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
var currentText string
|
||||
var revision, deviceVersion int64
|
||||
var updatedAt time.Time
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT activation_state,activation_revision,version,activation_updated_at
|
||||
FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, input.DeviceID, input.TenantID).
|
||||
Scan(¤tText, &revision, &deviceVersion, &updatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Result{}, ErrDeviceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("lock device activation: %w", err)
|
||||
}
|
||||
current, err := Parse(currentText)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("parse stored device activation state: %w", err)
|
||||
}
|
||||
if input.ExpectedRevision != nil && revision != *input.ExpectedRevision {
|
||||
return Result{}, ErrRevisionConflict
|
||||
}
|
||||
if !CanTransition(current, target) {
|
||||
return Result{}, ErrTransitionRejected
|
||||
}
|
||||
if current == target {
|
||||
return Result{From: current, To: target, Revision: revision, DeviceVersion: deviceVersion, UpdatedAt: updatedAt}, nil
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE device SET activation_state=$3,activation_revision=activation_revision+1,
|
||||
activation_reason=$4,activation_updated_at=now(),version=version+1,updated_at=now()
|
||||
WHERE id=$1::uuid AND tenant_id=$2::uuid
|
||||
RETURNING activation_revision,version,activation_updated_at`, input.DeviceID, input.TenantID, target, strings.TrimSpace(input.Reason)).
|
||||
Scan(&revision, &deviceVersion, &updatedAt)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("update device activation: %w", err)
|
||||
}
|
||||
_, 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,$3,$4,$5,$6,$7,NULLIF($8,'')::uuid,NULLIF($9,''),$10)`,
|
||||
input.TenantID, input.DeviceID, current, target, strings.TrimSpace(input.Reason), strings.TrimSpace(input.Source),
|
||||
strings.TrimSpace(input.ActorType), optionalString(input.ActorPublicID), strings.TrimSpace(input.RequestID), revision)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("record device activation transition: %w", err)
|
||||
}
|
||||
return Result{From: current, To: target, Revision: revision, DeviceVersion: deviceVersion, UpdatedAt: updatedAt, Changed: true}, nil
|
||||
}
|
||||
|
||||
func optionalString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package deviceactivation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCanTransition(t *testing.T) {
|
||||
allowed := [][2]State{{Factory, Claimable}, {Claimable, Bound}, {Bound, Provisioning}, {Provisioning, Active}, {Active, Lost}, {Lost, Active}, {Active, Claimable}, {Active, Provisioning}, {Bound, Revoked}}
|
||||
for _, edge := range allowed {
|
||||
if !CanTransition(edge[0], edge[1]) {
|
||||
t.Fatalf("expected %s -> %s to be allowed", edge[0], edge[1])
|
||||
}
|
||||
}
|
||||
rejected := [][2]State{{Factory, Active}, {Claimable, Active}, {Active, Bound}, {Revoked, Claimable}, {Lost, Provisioning}}
|
||||
for _, edge := range rejected {
|
||||
if CanTransition(edge[0], edge[1]) {
|
||||
t.Fatalf("expected %s -> %s to be rejected", edge[0], edge[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowsDeviceAuthentication(t *testing.T) {
|
||||
for _, state := range []string{"", "CLAIMABLE", "BOUND", "PROVISIONING", "ACTIVE"} {
|
||||
if !AllowsDeviceAuthentication(state) {
|
||||
t.Fatalf("expected %q to authenticate", state)
|
||||
}
|
||||
}
|
||||
for _, state := range []string{"FACTORY", "LOST", "REVOKED", "invalid"} {
|
||||
if AllowsDeviceAuthentication(state) {
|
||||
t.Fatalf("expected %q to be blocked", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,14 @@ type contextKey string
|
||||
const identityKey contextKey = "device-identity"
|
||||
|
||||
type Identity struct {
|
||||
ID string
|
||||
PublicID string
|
||||
TenantID string
|
||||
Status string
|
||||
CredentialRef string
|
||||
ID string
|
||||
PublicID string
|
||||
TenantID string
|
||||
Status string
|
||||
ActivationState string
|
||||
VoiceRuntime string
|
||||
HardwareLicenseReady bool
|
||||
CredentialRef string
|
||||
}
|
||||
|
||||
func WithIdentity(ctx context.Context, identity Identity) context.Context {
|
||||
|
||||
@@ -18,10 +18,10 @@ func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: po
|
||||
func (r *Repository) FindByPublicID(ctx context.Context, publicID string) (Identity, error) {
|
||||
var identity Identity
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id::text, public_id::text, tenant_id::text, status, credential_ref
|
||||
FROM device
|
||||
WHERE public_id = $1::uuid`, publicID,
|
||||
).Scan(&identity.ID, &identity.PublicID, &identity.TenantID, &identity.Status, &identity.CredentialRef)
|
||||
SELECT d.id::text,d.public_id::text,d.tenant_id::text,d.status,d.activation_state,d.credential_ref
|
||||
FROM device d
|
||||
WHERE d.public_id = $1::uuid`, publicID,
|
||||
).Scan(&identity.ID, &identity.PublicID, &identity.TenantID, &identity.Status, &identity.ActivationState, &identity.CredentialRef)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Identity{}, ErrDeviceNotFound
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -95,7 +97,7 @@ func (v *Verifier) Verify(request *http.Request) (Identity, error) {
|
||||
}
|
||||
return Identity{}, fmt.Errorf("lookup device identity: %w", err)
|
||||
}
|
||||
if identity.Status != "registered" || identity.TenantID == "" {
|
||||
if identity.Status != "registered" || identity.TenantID == "" || !deviceactivation.AllowsDeviceAuthentication(identity.ActivationState) {
|
||||
return Identity{}, ErrDeviceInactive
|
||||
}
|
||||
secret, err := v.secrets.Resolve(request.Context(), identity.CredentialRef)
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
package devicereset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("device factory reset input is invalid")
|
||||
ErrStateConflict = errors.New("device state does not allow factory reset")
|
||||
ErrResetCounterConflict = errors.New("device factory reset counter is stale")
|
||||
ErrFirmwareInProgress = errors.New("device firmware update is in progress")
|
||||
ErrPendingTranscripts = errors.New("device has pending transcripts")
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
ResetID string `json:"reset_id"`
|
||||
ResetCounter int64 `json:"reset_counter"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Trigger string `json:"trigger"`
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
PendingTurnCount int `json:"pending_turn_count"`
|
||||
ActiveConversationID string `json:"active_conversation_id,omitempty"`
|
||||
}
|
||||
|
||||
type Claim struct {
|
||||
Code string `json:"code"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ResetID string `json:"reset_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ActivationState string `json:"activation_state"`
|
||||
ResetCounter int64 `json:"reset_counter"`
|
||||
BindingRemoved bool `json:"binding_removed"`
|
||||
PreviousBindingID string `json:"previous_binding_id,omitempty"`
|
||||
ProviderCredentialStatus string `json:"provider_credential_status"`
|
||||
LicenseAction string `json:"license_action"`
|
||||
EndedConversationCount int `json:"ended_conversation_count"`
|
||||
CancelledCommandCount int64 `json:"cancelled_command_count"`
|
||||
ConfigRevision int64 `json:"config_revision"`
|
||||
Claim Claim `json:"claim"`
|
||||
LocalActions []string `json:"local_actions"`
|
||||
ResetAt time.Time `json:"reset_at"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
executor *idempotency.Executor
|
||||
audit *audit.Repository
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, executor *idempotency.Executor, auditRepository *audit.Repository) (*Service, error) {
|
||||
if pool == nil || executor == nil || auditRepository == nil {
|
||||
return nil, errors.New("device factory reset dependencies are required")
|
||||
}
|
||||
return &Service{pool: pool, executor: executor, audit: auditRepository}, nil
|
||||
}
|
||||
|
||||
func Validate(input Input) error {
|
||||
if _, err := uuid.Parse(strings.TrimSpace(input.ResetID)); err != nil || input.ResetCounter < 1 || input.OccurredAt.IsZero() ||
|
||||
input.OccurredAt.After(time.Now().UTC().Add(5*time.Minute)) || len(strings.TrimSpace(input.FirmwareVersion)) < 1 ||
|
||||
len(strings.TrimSpace(input.FirmwareVersion)) > 80 || input.PendingTurnCount < 0 {
|
||||
return ErrInvalid
|
||||
}
|
||||
if input.Trigger != "physical_long_press" && input.Trigger != "device_menu" && input.Trigger != "service_tool" {
|
||||
return ErrInvalid
|
||||
}
|
||||
if input.ActiveConversationID != "" {
|
||||
if _, err := uuid.Parse(input.ActiveConversationID); err != nil {
|
||||
return ErrInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Reset(ctx context.Context, identity deviceauth.Identity, idempotencyKey, requestID string, rawRequest []byte, input Input) (Response, bool, error) {
|
||||
if err := Validate(input); err != nil {
|
||||
return Response{}, false, err
|
||||
}
|
||||
if input.PendingTurnCount > 0 {
|
||||
return Response{}, false, ErrPendingTranscripts
|
||||
}
|
||||
tenantID := identity.TenantID
|
||||
result, err := s.executor.Execute(ctx, idempotency.Request{
|
||||
TenantID: &tenantID, ActorKey: "device:" + identity.PublicID,
|
||||
Scope: "POST /device/v3/factory-reset", Key: idempotencyKey,
|
||||
RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 7 * 24 * time.Hour,
|
||||
}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
response, resetErr := s.reset(operationContext, tx, identity, requestID, input)
|
||||
if resetErr != nil {
|
||||
return idempotency.Result{}, resetErr
|
||||
}
|
||||
body, marshalErr := json.Marshal(response)
|
||||
if marshalErr != nil {
|
||||
return idempotency.Result{}, marshalErr
|
||||
}
|
||||
resourceID := identity.PublicID
|
||||
return idempotency.Result{StatusCode: http.StatusOK, Body: body, ResourceID: &resourceID}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return Response{}, false, err
|
||||
}
|
||||
var response Response
|
||||
if err := json.Unmarshal(result.Body, &response); err != nil {
|
||||
return Response{}, result.Replayed, fmt.Errorf("decode device factory reset response: %w", err)
|
||||
}
|
||||
return response, result.Replayed, nil
|
||||
}
|
||||
|
||||
func (s *Service) reset(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string, input Input) (Response, error) {
|
||||
var activationState string
|
||||
var currentCounter int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT activation_state,factory_reset_counter
|
||||
FROM device
|
||||
WHERE id=$1::uuid AND tenant_id=$2::uuid AND status='registered'
|
||||
FOR UPDATE`, identity.ID, identity.TenantID).Scan(&activationState, ¤tCounter); err != nil {
|
||||
return Response{}, fmt.Errorf("lock device for factory reset: %w", err)
|
||||
}
|
||||
if activationState != string(deviceactivation.Claimable) && activationState != string(deviceactivation.Bound) &&
|
||||
activationState != string(deviceactivation.Provisioning) && activationState != string(deviceactivation.Active) {
|
||||
return Response{}, ErrStateConflict
|
||||
}
|
||||
if input.ResetCounter <= currentCounter {
|
||||
return Response{}, ErrResetCounterConflict
|
||||
}
|
||||
|
||||
var activeFirmware int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT count(*) FROM firmware_assignment
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('ASSIGNED','DOWNLOADING','INSTALLING')`,
|
||||
identity.ID, identity.TenantID).Scan(&activeFirmware); err != nil {
|
||||
return Response{}, fmt.Errorf("check active firmware update: %w", err)
|
||||
}
|
||||
if activeFirmware > 0 {
|
||||
return Response{}, ErrFirmwareInProgress
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
endedConversations, err := endActiveConversations(ctx, tx, identity, requestID, now)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
var previousBindingID, previousHouseholdID, previousSubjectID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE device_binding
|
||||
SET unbound_at=$3,unbound_by=NULL,reason='device_factory_reset'
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND unbound_at IS NULL
|
||||
RETURNING public_id::text,household_id::text,subject_id::text`, identity.ID, identity.TenantID, now).
|
||||
Scan(&previousBindingID, &previousHouseholdID, &previousSubjectID)
|
||||
bindingRemoved := err == nil
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Response{}, fmt.Errorf("close device binding for factory reset: %w", err)
|
||||
}
|
||||
if bindingRemoved {
|
||||
if err := notifyPreviousOwner(ctx, tx, identity, previousHouseholdID, previousSubjectID, now); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE device_claim SET revoked_at=$3
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND used_at IS NULL AND revoked_at IS NULL`,
|
||||
identity.ID, identity.TenantID, now); err != nil {
|
||||
return Response{}, fmt.Errorf("revoke old device claims: %w", err)
|
||||
}
|
||||
|
||||
providerStatus := "NOT_PROVISIONED"
|
||||
if tag, err := tx.Exec(ctx, `
|
||||
UPDATE hardware_agent_provider_credential
|
||||
SET status='SUSPENDED',updated_at=$3
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status='ACTIVE'`, identity.ID, identity.TenantID, now); err != nil {
|
||||
return Response{}, fmt.Errorf("suspend provider credential: %w", err)
|
||||
} else if tag.RowsAffected() > 0 {
|
||||
providerStatus = "SUSPENDED"
|
||||
} else if err := tx.QueryRow(ctx, `SELECT status FROM hardware_agent_provider_credential WHERE device_id=$1::uuid`, identity.ID).Scan(&providerStatus); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Response{}, fmt.Errorf("read provider credential status: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=$2 WHERE device_id=$1::uuid AND status='ACTIVE'`, identity.ID, now); err != nil {
|
||||
return Response{}, fmt.Errorf("disable hardware agent binding: %w", err)
|
||||
}
|
||||
|
||||
var cancelledCommands int64
|
||||
if tag, err := tx.Exec(ctx, `
|
||||
UPDATE device_command
|
||||
SET status='CANCELLED',result_summary='{"error_code":"DEVICE_FACTORY_RESET"}'::jsonb,
|
||||
completed_at=$3,version=version+1,updated_at=$3
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED')`,
|
||||
identity.ID, identity.TenantID, now); err != nil {
|
||||
return Response{}, fmt.Errorf("cancel device commands: %w", err)
|
||||
} else {
|
||||
cancelledCommands = tag.RowsAffected()
|
||||
}
|
||||
|
||||
if err := transitionToClaimable(ctx, tx, identity, requestID); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
var configRevision int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE device_twin
|
||||
SET desired_revision=desired_revision+1,reported_revision=NULL,reported_config='{}'::jsonb,
|
||||
capabilities='{}'::jsonb,updated_at=$3
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid
|
||||
RETURNING desired_revision`, identity.ID, identity.TenantID, now).Scan(&configRevision); err != nil {
|
||||
return Response{}, fmt.Errorf("reset device twin: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE device SET factory_reset_counter=$3,version=version+1,updated_at=$4
|
||||
WHERE id=$1::uuid AND tenant_id=$2::uuid`, identity.ID, identity.TenantID, input.ResetCounter, now); err != nil {
|
||||
return Response{}, fmt.Errorf("record factory reset counter: %w", err)
|
||||
}
|
||||
|
||||
claim, err := issueClaim(ctx, tx, identity, now)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
response := Response{
|
||||
ResetID: input.ResetID, DeviceID: identity.PublicID, ActivationState: string(deviceactivation.Claimable),
|
||||
ResetCounter: input.ResetCounter, BindingRemoved: bindingRemoved, PreviousBindingID: previousBindingID,
|
||||
ProviderCredentialStatus: providerStatus, LicenseAction: "RETAINED",
|
||||
EndedConversationCount: endedConversations, CancelledCommandCount: cancelledCommands,
|
||||
ConfigRevision: configRevision, Claim: claim, ResetAt: now,
|
||||
LocalActions: []string{"clear_volc_credential", "clear_user_context", "clear_session_state", "clear_network_credentials", "reboot_to_claimable"},
|
||||
}
|
||||
actorID := identity.PublicID
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{
|
||||
TenantID: &identity.TenantID, ActorType: "device", ActorPublicID: &actorID,
|
||||
Action: "device.factory_reset", ResourceType: "device", ResourceID: &actorID,
|
||||
RequestID: requestID, Result: "success", Detail: map[string]any{
|
||||
"reset_id": input.ResetID, "reset_counter": input.ResetCounter, "trigger": input.Trigger,
|
||||
"firmware_version": input.FirmwareVersion, "previous_binding_id": previousBindingID,
|
||||
"device_reported_conversation_id": input.ActiveConversationID,
|
||||
"ended_conversation_count": endedConversations, "cancelled_command_count": cancelledCommands,
|
||||
"provider_credential_status": providerStatus, "license_action": "RETAINED",
|
||||
},
|
||||
}); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func notifyPreviousOwner(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, householdID, subjectID string, now time.Time) error {
|
||||
var wasOnline bool
|
||||
err := tx.QueryRow(ctx, `
|
||||
UPDATE device_presence_state SET online=false,changed_at=$4
|
||||
WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND household_id=$3::uuid AND online
|
||||
RETURNING true`, identity.TenantID, identity.ID, householdID, now).Scan(&wasOnline)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("mark factory-reset device offline: %w", err)
|
||||
}
|
||||
if wasOnline {
|
||||
if _, err := tx.Exec(ctx, `SELECT append_web_event($1::uuid,$2::uuid,$3::uuid,'device.presence.changed','device',$4::uuid,'offline')`,
|
||||
identity.TenantID, householdID, subjectID, identity.PublicID); err != nil {
|
||||
return fmt.Errorf("publish factory-reset presence event: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `SELECT append_web_event($1::uuid,$2::uuid,$3::uuid,'device.binding.reset','device',$4::uuid,'CLAIMABLE')`,
|
||||
identity.TenantID, householdID, subjectID, identity.PublicID); err != nil {
|
||||
return fmt.Errorf("publish factory-reset binding event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func endActiveConversations(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string, now time.Time) (int, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id::text,status FROM conversation
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')
|
||||
FOR UPDATE`, identity.ID, identity.TenantID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("lock active conversations for factory reset: %w", err)
|
||||
}
|
||||
type activeConversation struct{ id, status string }
|
||||
items := make([]activeConversation, 0)
|
||||
for rows.Next() {
|
||||
var item activeConversation
|
||||
if err := rows.Scan(&item.id, &item.status); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE conversation SET status='FAILED',ended_at=$2,stop_reason='device_factory_reset',
|
||||
failure_code='DEVICE_FACTORY_RESET',version=version+1,updated_at=$2
|
||||
WHERE id=$1::uuid`, item.id, now); err != nil {
|
||||
return 0, fmt.Errorf("fail conversation during factory reset: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,request_id,occurred_at)
|
||||
VALUES($1::uuid,$2,'FAILED','device factory reset','device',NULLIF($3,''),$4)`, item.id, item.status, requestID, now); err != nil {
|
||||
return 0, fmt.Errorf("record factory reset conversation transition: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE hardware_agent_session SET status='FAILED',provider_ended_at=COALESCE(provider_ended_at,$2),updated_at=$2
|
||||
WHERE conversation_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')`, item.id, now); err != nil {
|
||||
return 0, fmt.Errorf("fail hardware agent session during factory reset: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE rtc_task SET status='FAILED',updated_at=$2
|
||||
WHERE conversation_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED')`, item.id, now); err != nil {
|
||||
return 0, fmt.Errorf("fail rtc task during factory reset: %w", err)
|
||||
}
|
||||
}
|
||||
return len(items), nil
|
||||
}
|
||||
|
||||
func transitionToClaimable(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string) error {
|
||||
var stateText string
|
||||
if err := tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&stateText); err != nil {
|
||||
return fmt.Errorf("read device activation state after reset cleanup: %w", err)
|
||||
}
|
||||
state, err := deviceactivation.Parse(stateText)
|
||||
if err != nil {
|
||||
return ErrStateConflict
|
||||
}
|
||||
actorID := identity.PublicID
|
||||
apply := func(target deviceactivation.State, reason string) error {
|
||||
_, applyErr := deviceactivation.Apply(ctx, tx, deviceactivation.Input{
|
||||
DeviceID: identity.ID, TenantID: identity.TenantID, Target: target,
|
||||
Reason: reason, Source: "device-factory-reset", ActorType: "device",
|
||||
ActorPublicID: &actorID, RequestID: requestID,
|
||||
})
|
||||
return applyErr
|
||||
}
|
||||
if state == deviceactivation.Provisioning {
|
||||
if err := apply(deviceactivation.Bound, "设备恢复出厂,结束预配"); err != nil {
|
||||
return fmt.Errorf("return provisioning device to bound state: %w", err)
|
||||
}
|
||||
state = deviceactivation.Bound
|
||||
}
|
||||
if state != deviceactivation.Claimable {
|
||||
if state != deviceactivation.Bound && state != deviceactivation.Active && state != deviceactivation.Lost {
|
||||
return ErrStateConflict
|
||||
}
|
||||
if err := apply(deviceactivation.Claimable, "设备恢复出厂并解除用户绑定"); err != nil {
|
||||
return fmt.Errorf("make factory-reset device claimable: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func issueClaim(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, issuedAt time.Time) (Claim, error) {
|
||||
randomBytes := make([]byte, 10)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return Claim{}, fmt.Errorf("generate factory reset claim: %w", err)
|
||||
}
|
||||
rawCode := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes)
|
||||
code := strings.Join([]string{rawCode[0:4], rawCode[4:8], rawCode[8:12], rawCode[12:16]}, "-")
|
||||
digest := sha256.Sum256([]byte(code))
|
||||
expiresAt := issuedAt.Add(24 * time.Hour)
|
||||
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)`, identity.TenantID, identity.ID, hex.EncodeToString(digest[:]), expiresAt, issuedAt); err != nil {
|
||||
return Claim{}, fmt.Errorf("insert factory reset claim: %w", err)
|
||||
}
|
||||
return Claim{Code: code, IssuedAt: issuedAt, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package devicereset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestFactoryResetUnbindsAndSuspendsProviderCredential(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
deviceID := uuid.NewString()
|
||||
devicePublicID := uuid.NewString()
|
||||
serial := sha256.Sum256([]byte(devicePublicID))
|
||||
const tenantID = "10000000-0000-0000-0000-000000000001"
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO device(id,public_id,tenant_id,serial_digest,model,status,credential_ref,activation_state,activation_reason)
|
||||
VALUES($1::uuid,$2::uuid,$3::uuid,$4,'factory-reset-test','registered',$5,'ACTIVE','integration-test')`,
|
||||
deviceID, devicePublicID, tenantID, hex.EncodeToString(serial[:]), "test://"+devicePublicID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO device_twin(tenant_id,device_id,desired_revision,desired_config) VALUES($1::uuid,$2::uuid,2,'{"voice_runtime":"volc_hardware_agent"}'::jsonb)`, tenantID, deviceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,reason)
|
||||
SELECT $1::uuid,$2::uuid,h.id,sp.id,'factory-reset-integration'
|
||||
FROM household h JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id
|
||||
WHERE h.public_id='10000000-0000-0000-0000-000000000211'::uuid
|
||||
AND sp.public_id='10000000-0000-0000-0000-000000000411'::uuid`, tenantID, deviceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO device_presence_state(tenant_id,device_id,household_id,subject_id,online,last_seen_at,changed_at)
|
||||
SELECT $1::uuid,$2::uuid,h.id,sp.id,true,now(),now()
|
||||
FROM household h JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id
|
||||
WHERE h.public_id='10000000-0000-0000-0000-000000000211'::uuid
|
||||
AND sp.public_id='10000000-0000-0000-0000-000000000411'::uuid`, tenantID, deviceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO hardware_agent_device_binding(tenant_id,device_id,product_key,provider_device_ref,credential_ref,bot_id,transport,status)
|
||||
VALUES($1::uuid,$2::uuid,'product-test','device-test',$3,'bot-test','low_load','ACTIVE')`, tenantID, deviceID, "volc-device-secret://"+devicePublicID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO hardware_agent_provider_credential(tenant_id,device_id,credential_ref,instance_id,product_key,provider_device_name,hardware_id,encrypted_secret,nonce,status)
|
||||
VALUES($1::uuid,$2::uuid,$3,'instance-test','product-test','device-test','hardware-test',$4,$5,'ACTIVE')`,
|
||||
tenantID, deviceID, "volc-device-secret://"+devicePublicID, []byte("encrypted"), []byte("nonce")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE actor_key=$1`, "device:"+devicePublicID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, devicePublicID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM web_event WHERE resource_public_id=$1::uuid`, devicePublicID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_presence_state WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_claim WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM hardware_agent_provider_credential WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM hardware_agent_device_binding WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_binding WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_activation_transition WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_twin WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device WHERE id=$1::uuid`, deviceID)
|
||||
})
|
||||
|
||||
service, err := NewService(pool, idempotency.NewExecutor(pool), audit.NewRepository())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := Input{
|
||||
ResetID: uuid.NewString(), ResetCounter: 1, OccurredAt: time.Now().UTC(),
|
||||
Trigger: "physical_long_press", FirmwareVersion: "1.1.0", PendingTurnCount: 0,
|
||||
}
|
||||
raw, _ := json.Marshal(input)
|
||||
identity := deviceauth.Identity{ID: deviceID, PublicID: devicePublicID, TenantID: tenantID, Status: "registered", ActivationState: "ACTIVE"}
|
||||
first, replayed, err := service.Reset(ctx, identity, "factory-reset-integration-key", "factory-reset-integration-request", raw, input)
|
||||
if err != nil || replayed {
|
||||
t.Fatalf("first reset response=%+v replayed=%v err=%v", first, replayed, err)
|
||||
}
|
||||
second, replayed, err := service.Reset(ctx, identity, "factory-reset-integration-key", "factory-reset-integration-request-replay", raw, input)
|
||||
if err != nil || !replayed || second.Claim.Code != first.Claim.Code {
|
||||
t.Fatalf("replay response=%+v replayed=%v err=%v", second, replayed, err)
|
||||
}
|
||||
var state, providerStatus, bindingStatus string
|
||||
var resetCounter int64
|
||||
var activeBindings, bindingResetEvents int
|
||||
var online bool
|
||||
if err := pool.QueryRow(ctx, `SELECT activation_state,factory_reset_counter FROM device WHERE id=$1::uuid`, deviceID).Scan(&state, &resetCounter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL`, deviceID).Scan(&activeBindings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM hardware_agent_provider_credential WHERE device_id=$1::uuid`, deviceID).Scan(&providerStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM hardware_agent_device_binding WHERE device_id=$1::uuid`, deviceID).Scan(&bindingStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT online FROM device_presence_state WHERE device_id=$1::uuid`, deviceID).Scan(&online); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM web_event WHERE resource_public_id=$1::uuid AND event_type='device.binding.reset'`, devicePublicID).Scan(&bindingResetEvents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != "CLAIMABLE" || resetCounter != 1 || activeBindings != 0 || providerStatus != "SUSPENDED" || bindingStatus != "DISABLED" ||
|
||||
online || bindingResetEvents != 1 || !first.BindingRemoved || first.LicenseAction != "RETAINED" || first.Claim.Code == "" {
|
||||
t.Fatalf("reset state=%s counter=%d bindings=%d provider=%s binding=%s online=%v reset_events=%d response=%+v", state, resetCounter, activeBindings, providerStatus, bindingStatus, online, bindingResetEvents, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryResetRejectsPendingTranscriptsBeforeDatabaseWork(t *testing.T) {
|
||||
service := &Service{}
|
||||
input := Input{ResetID: uuid.NewString(), ResetCounter: 1, OccurredAt: time.Now().UTC(), Trigger: "device_menu", FirmwareVersion: "1.0.0", PendingTurnCount: 1}
|
||||
_, _, err := service.Reset(context.Background(), deviceauth.Identity{}, "factory-reset-pending-turns", "request", []byte(`{}`), input)
|
||||
if !errors.Is(err, ErrPendingTranscripts) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -214,13 +214,16 @@ func buildPilotDailySnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, ten
|
||||
rows, err = tx.Query(ctx, `WITH providers AS (
|
||||
SELECT provider FROM provider_dependency_state WHERE tenant_id=$1::uuid
|
||||
UNION SELECT pe.provider FROM provider_event pe WHERE pe.tenant_id=$1::uuid AND pe.received_at >= $2 AND pe.received_at < $3
|
||||
UNION SELECT rt.provider FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id WHERE c.tenant_id=$1::uuid AND c.created_at >= $2 AND c.created_at < $3)
|
||||
UNION SELECT rt.provider FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id WHERE c.tenant_id=$1::uuid AND c.created_at >= $2 AND c.created_at < $3
|
||||
UNION SELECT hs.provider FROM conversation c JOIN hardware_agent_session hs ON hs.conversation_id=c.id WHERE c.tenant_id=$1::uuid AND c.created_at >= $2 AND c.created_at < $3)
|
||||
SELECT p.provider,COALESCE(pds.status,'UNKNOWN'),COALESCE(pds.consecutive_failures,0),
|
||||
(SELECT count(*) FROM provider_event pe WHERE pe.tenant_id=$1::uuid AND pe.provider=p.provider AND pe.processing_status='failed' AND pe.received_at >= $2 AND pe.received_at < $3),
|
||||
(SELECT count(*) FROM provider_event pe WHERE pe.tenant_id=$1::uuid AND pe.provider=p.provider AND pe.processing_status='failed' AND pe.received_at >= $2 AND pe.received_at < $3)+
|
||||
(SELECT count(*) FROM hardware_agent_event he JOIN hardware_agent_session hs ON hs.conversation_id=he.conversation_id WHERE he.tenant_id=$1::uuid AND hs.provider=p.provider AND he.event_type='session_failed' AND he.occurred_at >= $2 AND he.occurred_at < $3),
|
||||
(SELECT count(DISTINCT c.id) FROM conversation c
|
||||
JOIN rtc_task rt ON rt.conversation_id=c.id
|
||||
LEFT JOIN rtc_task rt ON rt.conversation_id=c.id
|
||||
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id
|
||||
JOIN conversation_state_history csh ON csh.conversation_id=c.id
|
||||
WHERE c.tenant_id=$1::uuid AND rt.provider=p.provider AND csh.to_status='FAILED' AND csh.occurred_at >= $2 AND csh.occurred_at < $3),
|
||||
WHERE c.tenant_id=$1::uuid AND COALESCE(rt.provider,hs.provider)=p.provider AND csh.to_status='FAILED' AND csh.occurred_at >= $2 AND csh.occurred_at < $3),
|
||||
(SELECT count(*) FROM provider_event pe WHERE pe.tenant_id=$1::uuid AND pe.provider=p.provider AND pe.processing_status IN ('pending','failed'))
|
||||
FROM providers p LEFT JOIN provider_dependency_state pds ON pds.tenant_id=$1::uuid AND pds.provider=p.provider ORDER BY p.provider`, tenantInternalID, windowStart, windowEnd)
|
||||
if err != nil {
|
||||
|
||||
@@ -191,7 +191,7 @@ func (s *Service) Search(ctx context.Context, principal identity.Principal, tena
|
||||
"device_id": "d.public_id=$2::uuid",
|
||||
"conversation_id": "c.public_id=$2::uuid",
|
||||
"room_id": "rt.room_id=$2",
|
||||
"provider_task_ref": "rt.provider_task_ref=$2",
|
||||
"provider_task_ref": "COALESCE(rt.provider_task_ref,hs.provider_session_ref)=$2",
|
||||
"request_id": "EXISTS(SELECT 1 FROM audit_event ae WHERE ae.tenant_id=c.tenant_id AND ae.request_id=$2 AND ae.resource_id IN(c.id,c.public_id,d.id,d.public_id))",
|
||||
}[field]
|
||||
query := diagnosticSelect + " WHERE d.tenant_id=$1::uuid AND " + condition + " ORDER BY c.created_at DESC NULLS LAST LIMIT 50"
|
||||
@@ -242,8 +242,8 @@ func (s *Service) StopConversation(ctx context.Context, principal identity.Princ
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"conversation_id": conversationPublicID, "reason": reason})
|
||||
result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/diagnostics/conversations/stop", Key: key, RequestHash: idempotency.Hash(body), ExpiresAfter: 24 * time.Hour}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
var internalID, deviceID, status, rtcStatus string
|
||||
err := tx.QueryRow(operationContext, `SELECT c.id::text,d.public_id::text,c.status,rt.status FROM conversation c JOIN device d ON d.id=c.device_id JOIN rtc_task rt ON rt.conversation_id=c.id WHERE c.tenant_id=$1::uuid AND c.public_id=$2::uuid FOR UPDATE OF c,rt`, a.tenantID, conversationPublicID).Scan(&internalID, &deviceID, &status, &rtcStatus)
|
||||
var internalID, deviceID, status, runtimeStatus, runtimeType string
|
||||
err := tx.QueryRow(operationContext, `SELECT c.id::text,d.public_id::text,c.status,c.runtime_type,COALESCE(rt.status,hs.status,'') FROM conversation c JOIN device d ON d.id=c.device_id LEFT JOIN rtc_task rt ON rt.conversation_id=c.id LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id WHERE c.tenant_id=$1::uuid AND c.public_id=$2::uuid FOR UPDATE OF c`, a.tenantID, conversationPublicID).Scan(&internalID, &deviceID, &status, &runtimeType, &runtimeStatus)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return idempotency.Result{}, ErrNotFound
|
||||
}
|
||||
@@ -256,15 +256,19 @@ func (s *Service) StopConversation(ctx context.Context, principal identity.Princ
|
||||
case "COMPLETED", "FAILED", "EXPIRED", "STOPPING":
|
||||
case "PREPARING", "ROOM_ISSUED":
|
||||
status = "EXPIRED"
|
||||
if _, err = tx.Exec(operationContext, `UPDATE conversation SET status='EXPIRED',stop_reason='ops_stop_before_start',ended_at=now(),lease_expires_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID); err == nil {
|
||||
if _, err = tx.Exec(operationContext, `UPDATE conversation SET status='EXPIRED',stop_reason='ops_stop_before_start',ended_at=now(),lease_expires_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID); err == nil && runtimeType == "volc_hardware_agent" {
|
||||
_, err = tx.Exec(operationContext, `UPDATE hardware_agent_session SET status='EXPIRED',updated_at=now() WHERE conversation_id=$1::uuid`, internalID)
|
||||
} else if err == nil {
|
||||
_, err = tx.Exec(operationContext, `UPDATE rtc_task SET status='COMPLETED',lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, internalID)
|
||||
}
|
||||
case "STARTING_AGENT", "ACTIVE":
|
||||
status, queued = "STOPPING", true
|
||||
if _, err = tx.Exec(operationContext, `UPDATE conversation SET status='STOPPING',stop_reason='ops_stop',lease_expires_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID); err == nil {
|
||||
status, queued = "STOPPING", runtimeType != "volc_hardware_agent"
|
||||
if _, err = tx.Exec(operationContext, `UPDATE conversation SET status='STOPPING',stop_reason='ops_stop',lease_expires_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID); err == nil && runtimeType == "volc_hardware_agent" {
|
||||
_, err = tx.Exec(operationContext, `UPDATE hardware_agent_session SET status='STOPPING',updated_at=now() WHERE conversation_id=$1::uuid`, internalID)
|
||||
} else if err == nil {
|
||||
_, err = tx.Exec(operationContext, `UPDATE rtc_task SET status=CASE WHEN status='ACTIVE' THEN 'STOPPING' ELSE 'UNKNOWN' END,lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, internalID)
|
||||
}
|
||||
if err == nil {
|
||||
if err == nil && runtimeType != "volc_hardware_agent" {
|
||||
dedupe := "ops-stop:" + internalID
|
||||
payload, _ := json.Marshal(map[string]string{"conversation_id": conversationPublicID, "reason": "ops_stop"})
|
||||
_, err = s.outbox.Enqueue(operationContext, tx, outbox.NewEvent{TenantID: &a.tenantID, AggregateType: "conversation", AggregateID: internalID, EventType: "conversation.agent_stop_requested", DeduplicationKey: &dedupe, Payload: payload})
|
||||
@@ -280,7 +284,7 @@ func (s *Service) StopConversation(ctx context.Context, principal identity.Princ
|
||||
return idempotency.Result{}, err
|
||||
}
|
||||
}
|
||||
if err = s.audit.Record(operationContext, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "ops.diagnostics.conversation.stop", ResourceType: "conversation", ResourceID: &conversationPublicID, RequestID: requestID, Purpose: reason, Result: "success", Detail: map[string]any{"from_rtc_status": rtcStatus, "to_status": status}}); err != nil {
|
||||
if err = s.audit.Record(operationContext, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "ops.diagnostics.conversation.stop", ResourceType: "conversation", ResourceID: &conversationPublicID, RequestID: requestID, Purpose: reason, Result: "success", Detail: map[string]any{"runtime_type": runtimeType, "from_runtime_status": runtimeStatus, "to_status": status}}); err != nil {
|
||||
return idempotency.Result{}, err
|
||||
}
|
||||
output := ActionResult{Action: "stop_conversation", DeviceID: deviceID, ConversationID: conversationPublicID, ConversationStatus: status, Queued: queued}
|
||||
@@ -386,9 +390,9 @@ func (s *Service) RevokeDevice(ctx context.Context, principal identity.Principal
|
||||
}
|
||||
}
|
||||
|
||||
var conversationInternalID, conversationPublicID, conversationStatus, rtcStatus string
|
||||
var conversationInternalID, conversationPublicID, conversationStatus, runtimeStatus, runtimeType string
|
||||
queued := false
|
||||
conversationQueryErr := tx.QueryRow(op, `SELECT c.id::text,c.public_id::text,c.status,rt.status FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id WHERE c.tenant_id=$1::uuid AND c.device_id=$2::uuid AND c.status NOT IN('COMPLETED','FAILED','EXPIRED') ORDER BY c.created_at DESC LIMIT 1 FOR UPDATE OF c,rt`, a.tenantID, deviceID).Scan(&conversationInternalID, &conversationPublicID, &conversationStatus, &rtcStatus)
|
||||
conversationQueryErr := tx.QueryRow(op, `SELECT c.id::text,c.public_id::text,c.status,c.runtime_type,COALESCE(rt.status,hs.status,'') FROM conversation c LEFT JOIN rtc_task rt ON rt.conversation_id=c.id LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id WHERE c.tenant_id=$1::uuid AND c.device_id=$2::uuid AND c.status NOT IN('COMPLETED','FAILED','EXPIRED') ORDER BY c.created_at DESC LIMIT 1 FOR UPDATE OF c`, a.tenantID, deviceID).Scan(&conversationInternalID, &conversationPublicID, &conversationStatus, &runtimeType, &runtimeStatus)
|
||||
if conversationQueryErr != nil && !errors.Is(conversationQueryErr, pgx.ErrNoRows) {
|
||||
return idempotency.Result{}, conversationQueryErr
|
||||
}
|
||||
@@ -400,21 +404,31 @@ func (s *Service) RevokeDevice(ctx context.Context, principal identity.Principal
|
||||
if _, updateErr := tx.Exec(op, `UPDATE conversation SET status='EXPIRED',stop_reason='device_revoked_before_start',ended_at=now(),lease_expires_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, conversationInternalID); updateErr != nil {
|
||||
return idempotency.Result{}, updateErr
|
||||
}
|
||||
if _, updateErr := tx.Exec(op, `UPDATE rtc_task SET status='COMPLETED',lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, conversationInternalID); updateErr != nil {
|
||||
query := `UPDATE rtc_task SET status='COMPLETED',lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`
|
||||
if runtimeType == "volc_hardware_agent" {
|
||||
query = `UPDATE hardware_agent_session SET status='EXPIRED',updated_at=now() WHERE conversation_id=$1::uuid`
|
||||
}
|
||||
if _, updateErr := tx.Exec(op, query, conversationInternalID); updateErr != nil {
|
||||
return idempotency.Result{}, updateErr
|
||||
}
|
||||
case "STARTING_AGENT", "ACTIVE":
|
||||
conversationStatus, queued = "STOPPING", true
|
||||
conversationStatus, queued = "STOPPING", runtimeType != "volc_hardware_agent"
|
||||
if _, updateErr := tx.Exec(op, `UPDATE conversation SET status='STOPPING',stop_reason='device_revoked',lease_expires_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, conversationInternalID); updateErr != nil {
|
||||
return idempotency.Result{}, updateErr
|
||||
}
|
||||
if _, updateErr := tx.Exec(op, `UPDATE rtc_task SET status=CASE WHEN status='ACTIVE' THEN 'STOPPING' ELSE 'UNKNOWN' END,lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, conversationInternalID); updateErr != nil {
|
||||
query := `UPDATE rtc_task SET status=CASE WHEN status='ACTIVE' THEN 'STOPPING' ELSE 'UNKNOWN' END,lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`
|
||||
if runtimeType == "volc_hardware_agent" {
|
||||
query = `UPDATE hardware_agent_session SET status='STOPPING',updated_at=now() WHERE conversation_id=$1::uuid`
|
||||
}
|
||||
if _, updateErr := tx.Exec(op, query, conversationInternalID); updateErr != nil {
|
||||
return idempotency.Result{}, updateErr
|
||||
}
|
||||
dedupe := "ops-stop:" + conversationInternalID
|
||||
payload, _ := json.Marshal(map[string]string{"conversation_id": conversationPublicID, "reason": "device_revoked"})
|
||||
if _, enqueueErr := s.outbox.Enqueue(op, tx, outbox.NewEvent{TenantID: &a.tenantID, AggregateType: "conversation", AggregateID: conversationInternalID, EventType: "conversation.agent_stop_requested", DeduplicationKey: &dedupe, Payload: payload}); enqueueErr != nil {
|
||||
return idempotency.Result{}, enqueueErr
|
||||
if runtimeType != "volc_hardware_agent" {
|
||||
dedupe := "ops-stop:" + conversationInternalID
|
||||
payload, _ := json.Marshal(map[string]string{"conversation_id": conversationPublicID, "reason": "device_revoked"})
|
||||
if _, enqueueErr := s.outbox.Enqueue(op, tx, outbox.NewEvent{TenantID: &a.tenantID, AggregateType: "conversation", AggregateID: conversationInternalID, EventType: "conversation.agent_stop_requested", DeduplicationKey: &dedupe, Payload: payload}); enqueueErr != nil {
|
||||
return idempotency.Result{}, enqueueErr
|
||||
}
|
||||
}
|
||||
case "STOPPING":
|
||||
default:
|
||||
@@ -431,7 +445,8 @@ func (s *Service) RevokeDevice(ctx context.Context, principal identity.Principal
|
||||
if conversationPublicID != "" {
|
||||
detail["conversation_id"] = conversationPublicID
|
||||
detail["conversation_status"] = conversationStatus
|
||||
detail["from_rtc_status"] = rtcStatus
|
||||
detail["runtime_type"] = runtimeType
|
||||
detail["from_runtime_status"] = runtimeStatus
|
||||
}
|
||||
if auditErr := s.audit.Record(op, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "ops.diagnostics.device.revoke", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Purpose: reason, Result: "success", Detail: detail}); auditErr != nil {
|
||||
return idempotency.Result{}, auditErr
|
||||
@@ -493,8 +508,8 @@ func (s *Service) ReconcileConversation(ctx context.Context, principal identity.
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"conversation_id": conversationPublicID, "reason": reason})
|
||||
result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/diagnostics/conversations/reconcile", Key: key, RequestHash: idempotency.Hash(body), ExpiresAfter: 24 * time.Hour}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
var internalID, deviceID, status, taskRef string
|
||||
if queryErr := tx.QueryRow(operationContext, `SELECT c.id::text,d.public_id::text,c.status,rt.provider_task_ref FROM conversation c JOIN device d ON d.id=c.device_id JOIN rtc_task rt ON rt.conversation_id=c.id WHERE c.tenant_id=$1::uuid AND c.public_id=$2::uuid FOR UPDATE OF c`, a.tenantID, conversationPublicID).Scan(&internalID, &deviceID, &status, &taskRef); errors.Is(queryErr, pgx.ErrNoRows) {
|
||||
var internalID, deviceID, status, taskRef, runtimeType string
|
||||
if queryErr := tx.QueryRow(operationContext, `SELECT c.id::text,d.public_id::text,c.status,COALESCE(rt.provider_task_ref,hs.provider_session_ref),c.runtime_type FROM conversation c JOIN device d ON d.id=c.device_id LEFT JOIN rtc_task rt ON rt.conversation_id=c.id LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id WHERE c.tenant_id=$1::uuid AND c.public_id=$2::uuid FOR UPDATE OF c`, a.tenantID, conversationPublicID).Scan(&internalID, &deviceID, &status, &taskRef, &runtimeType); errors.Is(queryErr, pgx.ErrNoRows) {
|
||||
return idempotency.Result{}, ErrNotFound
|
||||
} else if queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
@@ -505,7 +520,7 @@ func (s *Service) ReconcileConversation(ctx context.Context, principal identity.
|
||||
} else if status == "STOPPING" {
|
||||
eventType = "conversation.agent_stop_reconcile_requested"
|
||||
}
|
||||
queued := eventType != ""
|
||||
queued := eventType != "" && runtimeType != "volc_hardware_agent"
|
||||
if queued {
|
||||
dedupe := "ops-reconcile:" + taskRef + ":" + key
|
||||
payload, _ := json.Marshal(map[string]string{"conversation_id": conversationPublicID, "provider_task_ref": taskRef, "reason": "ops_reconcile"})
|
||||
@@ -513,7 +528,7 @@ func (s *Service) ReconcileConversation(ctx context.Context, principal identity.
|
||||
return idempotency.Result{}, enqueueErr
|
||||
}
|
||||
}
|
||||
if auditErr := s.audit.Record(operationContext, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "ops.diagnostics.conversation.reconcile", ResourceType: "conversation", ResourceID: &conversationPublicID, RequestID: requestID, Purpose: reason, Result: "success", Detail: map[string]any{"status": status, "queued": queued}}); auditErr != nil {
|
||||
if auditErr := s.audit.Record(operationContext, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "ops.diagnostics.conversation.reconcile", ResourceType: "conversation", ResourceID: &conversationPublicID, RequestID: requestID, Purpose: reason, Result: "success", Detail: map[string]any{"status": status, "runtime_type": runtimeType, "queued": queued}}); auditErr != nil {
|
||||
return idempotency.Result{}, auditErr
|
||||
}
|
||||
output := ActionResult{Action: "reconcile_conversation", DeviceID: deviceID, ConversationID: conversationPublicID, ConversationStatus: status, Queued: queued}
|
||||
@@ -578,7 +593,7 @@ const diagnosticSelect = `SELECT
|
||||
COALESCE(dt.desired_revision,1),dt.reported_revision,COALESCE(dt.last_error_code,''),dt.last_error_at,
|
||||
COALESCE(c.public_id::text,''),COALESCE(c.status,''),COALESCE(c.failure_code,''),c.allocation_expires_at,
|
||||
rt.credential_issued_at,rt.credential_expires_at,
|
||||
COALESCE(rt.room_id,''),COALESCE(rt.provider,''),COALESCE(rt.provider_task_ref,''),COALESCE(rt.status,''),
|
||||
COALESCE(rt.room_id,''),COALESCE(rt.provider,hs.provider,''),COALESCE(rt.provider_task_ref,hs.provider_session_ref,''),COALESCE(rt.status,hs.status,''),
|
||||
COALESCE(rt.start_attempts,0),COALESCE(rt.stop_attempts,0),COALESCE(pe.event_count,0),
|
||||
COALESCE(pe.event_type,''),COALESCE(pe.event_status,''),pe.received_at,COALESCE(ae.request_id,''),
|
||||
c.created_at,ch.room_issued_at,ch.device_activated_at,ch.agent_active_at,ch.stop_requested_at,c.ended_at
|
||||
@@ -587,9 +602,14 @@ LEFT JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
|
||||
LEFT JOIN device_presence_state dp ON dp.device_id=d.id AND dp.tenant_id=d.tenant_id
|
||||
LEFT JOIN conversation c ON c.device_id=d.id AND c.tenant_id=d.tenant_id
|
||||
LEFT JOIN rtc_task rt ON rt.conversation_id=c.id
|
||||
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) OVER() event_count,event_type,COALESCE(sanitized_payload->>'status','') event_status,received_at
|
||||
FROM provider_event WHERE conversation_id=c.id ORDER BY received_at DESC LIMIT 1
|
||||
SELECT count(*) OVER() event_count,event_type,event_status,received_at FROM (
|
||||
SELECT event_type,COALESCE(sanitized_payload->>'status','') event_status,received_at
|
||||
FROM provider_event WHERE conversation_id=c.id
|
||||
UNION ALL
|
||||
SELECT event_type,event_type,occurred_at FROM hardware_agent_event WHERE conversation_id=c.id
|
||||
) runtime_event ORDER BY received_at DESC LIMIT 1
|
||||
) pe ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package hardwarelicense
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("invalid hardware license input")
|
||||
ErrAccessDenied = errors.New("hardware license access denied")
|
||||
ErrNotFound = errors.New("hardware license not found")
|
||||
ErrUnavailable = errors.New("no hardware license is available")
|
||||
ErrAssignment = errors.New("hardware license assignment mismatch")
|
||||
ErrExpired = errors.New("hardware license expired")
|
||||
ErrAcknowledgement = errors.New("hardware license acknowledgement invalid")
|
||||
ErrVersionConflict = errors.New("hardware license version conflict")
|
||||
)
|
||||
|
||||
var referencePattern = regexp.MustCompile(`^[a-zA-Z0-9_\-.:@/]{1,200}$`)
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
audit *audit.Repository
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
type ImportInput struct {
|
||||
LicenseRef string
|
||||
Payload string
|
||||
InstanceID string
|
||||
ProductKey string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
LicenseRef string `json:"license_ref"`
|
||||
Provider string `json:"provider"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
ProductKey string `json:"product_key"`
|
||||
Status string `json:"status"`
|
||||
AllocatedDeviceID *string `json:"allocated_device_id,omitempty"`
|
||||
AllocatedAt *time.Time `json:"allocated_at,omitempty"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
LastErrorCode *string `json:"last_error_code,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
type Delivery struct {
|
||||
LicenseRef string `json:"license_ref"`
|
||||
License string `json:"license"`
|
||||
Digest string `json:"digest"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type AcknowledgeInput struct {
|
||||
LicenseRef string
|
||||
Digest string
|
||||
Result string
|
||||
ErrorCode string
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, auditRepository *audit.Repository, masterKey string) (*Service, error) {
|
||||
if pool == nil || auditRepository == nil {
|
||||
return nil, errors.New("hardware license service dependencies are required")
|
||||
}
|
||||
key, err := hex.DecodeString(strings.TrimSpace(masterKey))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, errors.New("hardware license master key must be 32 bytes encoded as 64 hexadecimal characters")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create hardware license cipher: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create hardware license AEAD: %w", err)
|
||||
}
|
||||
return &Service{pool: pool, audit: auditRepository, aead: aead}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Import(ctx context.Context, principal identity.Principal, tenantPublicID, requestID string, input ImportInput) (Item, error) {
|
||||
input.LicenseRef = strings.TrimSpace(input.LicenseRef)
|
||||
input.Payload = strings.TrimSpace(input.Payload)
|
||||
input.InstanceID = strings.TrimSpace(input.InstanceID)
|
||||
input.ProductKey = strings.TrimSpace(input.ProductKey)
|
||||
if !referencePattern.MatchString(input.LicenseRef) || input.InstanceID == "" || len(input.InstanceID) > 200 || input.ProductKey == "" || len(input.ProductKey) > 200 ||
|
||||
len(input.Payload) < 8 || len(input.Payload) > 65536 || !input.ExpiresAt.After(time.Now().UTC()) {
|
||||
return Item{}, ErrInvalid
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
digestBytes := sha256.Sum256([]byte(input.Payload))
|
||||
digest := hex.EncodeToString(digestBytes[:])
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
encrypted := s.aead.Seal(nil, nonce, []byte(input.Payload), licenseAAD(input.LicenseRef, input.InstanceID, input.ProductKey, input.ExpiresAt))
|
||||
var item Item
|
||||
err = tx.QueryRow(ctx, `INSERT INTO hardware_agent_license(
|
||||
tenant_id,provider,instance_id,product_key,license_ref,payload_digest,encrypted_payload,nonce,status,expires_at
|
||||
) VALUES($1::uuid,'volcengine',$2,$3,$4,$5,$6,$7,'AVAILABLE',$8)
|
||||
RETURNING public_id::text,license_ref,provider,instance_id,product_key,status,NULL::text,allocated_at,activated_at,expires_at,last_error_code,created_at,updated_at,version`,
|
||||
access.tenantID, input.InstanceID, input.ProductKey, input.LicenseRef, digest, encrypted, nonce, input.ExpiresAt).
|
||||
Scan(&item.ID, &item.LicenseRef, &item.Provider, &item.InstanceID, &item.ProductKey, &item.Status, &item.AllocatedDeviceID, &item.AllocatedAt, &item.ActivatedAt, &item.ExpiresAt, &item.LastErrorCode, &item.CreatedAt, &item.UpdatedAt, &item.Version)
|
||||
if err != nil {
|
||||
return Item{}, fmt.Errorf("import hardware license: %w", err)
|
||||
}
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.hardware_license.import", ResourceType: "hardware_agent_license", ResourceID: &item.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"license_ref": item.LicenseRef, "product_key": item.ProductKey, "expires_at": item.ExpiresAt, "payload_digest": digest}}); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Item, error) {
|
||||
access, err := requireAccess(ctx, s.pool, principal, tenantPublicID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = s.pool.Exec(ctx, `UPDATE hardware_agent_license SET status='EXPIRED',updated_at=now(),version=version+1 WHERE tenant_id=$1::uuid AND expires_at<=now() AND status IN ('AVAILABLE','ALLOCATED','ACTIVE','SUSPENDED')`, access.tenantID)
|
||||
rows, err := s.pool.Query(ctx, `SELECT l.public_id::text,l.license_ref,l.provider,l.instance_id,l.product_key,l.status,d.public_id::text,l.allocated_at,l.activated_at,l.expires_at,l.last_error_code,l.created_at,l.updated_at,l.version FROM hardware_agent_license l LEFT JOIN device d ON d.id=l.allocated_device_id WHERE l.tenant_id=$1::uuid ORDER BY l.created_at DESC`, access.tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]Item, 0)
|
||||
for rows.Next() {
|
||||
var item Item
|
||||
if err := rows.Scan(&item.ID, &item.LicenseRef, &item.Provider, &item.InstanceID, &item.ProductKey, &item.Status, &item.AllocatedDeviceID, &item.AllocatedAt, &item.ActivatedAt, &item.ExpiresAt, &item.LastErrorCode, &item.CreatedAt, &item.UpdatedAt, &item.Version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Revoke(ctx context.Context, principal identity.Principal, tenantPublicID, licensePublicID, requestID string, expectedVersion int64) (Item, error) {
|
||||
return s.transitionInventory(ctx, principal, tenantPublicID, licensePublicID, requestID, expectedVersion, "REVOKED")
|
||||
}
|
||||
|
||||
func (s *Service) Release(ctx context.Context, principal identity.Principal, tenantPublicID, licensePublicID, requestID string, expectedVersion int64) (Item, error) {
|
||||
return s.transitionInventory(ctx, principal, tenantPublicID, licensePublicID, requestID, expectedVersion, "AVAILABLE")
|
||||
}
|
||||
|
||||
func (s *Service) transitionInventory(ctx context.Context, principal identity.Principal, tenantPublicID, licensePublicID, requestID string, expectedVersion int64, target string) (Item, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
var internalID, status string
|
||||
var version int64
|
||||
var deviceID *string
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text,status,version,allocated_device_id::text FROM hardware_agent_license WHERE public_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, licensePublicID, access.tenantID).Scan(&internalID, &status, &version, &deviceID); errors.Is(err, pgx.ErrNoRows) {
|
||||
return Item{}, ErrNotFound
|
||||
} else if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
if version != expectedVersion {
|
||||
return Item{}, ErrVersionConflict
|
||||
}
|
||||
if target == "AVAILABLE" && (status != "SUSPENDED" || deviceID == nil) {
|
||||
return Item{}, ErrAssignment
|
||||
}
|
||||
if target == "AVAILABLE" && deviceID != nil {
|
||||
var bound bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, *deviceID).Scan(&bound); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
if bound {
|
||||
return Item{}, ErrAssignment
|
||||
}
|
||||
}
|
||||
if target == "REVOKED" && status == "REVOKED" {
|
||||
return Item{}, ErrAssignment
|
||||
}
|
||||
if target == "REVOKED" && deviceID != nil {
|
||||
var activationState string
|
||||
if err := tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, *deviceID, access.tenantID).Scan(&activationState); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
if activationState == string(deviceactivation.Active) {
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: *deviceID, TenantID: access.tenantID,
|
||||
Target: deviceactivation.Provisioning, Reason: "硬件智能体License已吊销", Source: "license-revoke", ActorType: "user",
|
||||
ActorPublicID: &access.userPublicID, RequestID: requestID}); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
var item Item
|
||||
query := `UPDATE hardware_agent_license SET status=$3,
|
||||
allocated_device_id=CASE WHEN $3='AVAILABLE' THEN NULL ELSE allocated_device_id END,
|
||||
allocated_at=CASE WHEN $3='AVAILABLE' THEN NULL ELSE allocated_at END,
|
||||
activated_at=CASE WHEN $3='AVAILABLE' THEN NULL ELSE activated_at END,
|
||||
last_error_code=CASE WHEN $3='AVAILABLE' THEN NULL ELSE last_error_code END,
|
||||
released_at=CASE WHEN $3='AVAILABLE' THEN now() ELSE released_at END,
|
||||
revoked_at=CASE WHEN $3='REVOKED' THEN now() ELSE revoked_at END,
|
||||
updated_at=now(),version=version+1
|
||||
WHERE id=$1::uuid AND tenant_id=$2::uuid
|
||||
RETURNING public_id::text,license_ref,provider,instance_id,product_key,status,NULL::text,allocated_at,activated_at,expires_at,last_error_code,created_at,updated_at,version`
|
||||
if err := tx.QueryRow(ctx, query, internalID, access.tenantID, target).Scan(&item.ID, &item.LicenseRef, &item.Provider, &item.InstanceID, &item.ProductKey, &item.Status, &item.AllocatedDeviceID, &item.AllocatedAt, &item.ActivatedAt, &item.ExpiresAt, &item.LastErrorCode, &item.CreatedAt, &item.UpdatedAt, &item.Version); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.hardware_license." + strings.ToLower(target), ResourceType: "hardware_agent_license", ResourceID: &item.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"license_ref": item.LicenseRef, "previous_status": status}}); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) AcquireForDevice(ctx context.Context, tx pgx.Tx, tenantID, deviceID, instanceID, productKey string) (Delivery, error) {
|
||||
_, _ = tx.Exec(ctx, `UPDATE hardware_agent_license SET status='EXPIRED',updated_at=now(),version=version+1 WHERE tenant_id=$1::uuid AND expires_at<=now() AND status IN ('AVAILABLE','ALLOCATED','ACTIVE','SUSPENDED')`, tenantID)
|
||||
var internalID, ref, digest, status string
|
||||
var encrypted, nonce []byte
|
||||
var expires time.Time
|
||||
err := tx.QueryRow(ctx, `SELECT id::text,license_ref,payload_digest,status,encrypted_payload,nonce,expires_at FROM hardware_agent_license WHERE tenant_id=$1::uuid AND allocated_device_id=$2::uuid AND instance_id=$3 AND product_key=$4 AND status IN ('ALLOCATED','ACTIVE','SUSPENDED') FOR UPDATE`, tenantID, deviceID, instanceID, productKey).Scan(&internalID, &ref, &digest, &status, &encrypted, &nonce, &expires)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,license_ref,payload_digest,status,encrypted_payload,nonce,expires_at FROM hardware_agent_license WHERE tenant_id=$1::uuid AND allocated_device_id IS NULL AND instance_id=$2 AND product_key=$3 AND status='AVAILABLE' AND expires_at>now() ORDER BY expires_at,id FOR UPDATE SKIP LOCKED LIMIT 1`, tenantID, instanceID, productKey).Scan(&internalID, &ref, &digest, &status, &encrypted, &nonce, &expires)
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Delivery{}, ErrUnavailable
|
||||
}
|
||||
if err != nil {
|
||||
return Delivery{}, err
|
||||
}
|
||||
payload, err := s.aead.Open(nil, nonce, encrypted, licenseAAD(ref, instanceID, productKey, expires))
|
||||
if err != nil {
|
||||
return Delivery{}, errors.New("decrypt hardware license")
|
||||
}
|
||||
if status != "ACTIVE" {
|
||||
status = "ALLOCATED"
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_license SET allocated_device_id=$2::uuid,status=$3,
|
||||
allocated_at=COALESCE(allocated_at,now()),released_at=NULL,last_error_code=NULL,updated_at=now(),version=version+1
|
||||
WHERE id=$1::uuid`, internalID, deviceID, status); err != nil {
|
||||
return Delivery{}, err
|
||||
}
|
||||
return Delivery{LicenseRef: ref, License: string(payload), Digest: digest, ExpiresAt: expires, Status: status}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Acknowledge(ctx context.Context, identity deviceauth.Identity, requestID string, input AcknowledgeInput) error {
|
||||
input.LicenseRef = strings.TrimSpace(input.LicenseRef)
|
||||
input.Digest = strings.ToLower(strings.TrimSpace(input.Digest))
|
||||
input.Result = strings.ToUpper(strings.TrimSpace(input.Result))
|
||||
input.ErrorCode = strings.TrimSpace(input.ErrorCode)
|
||||
if !referencePattern.MatchString(input.LicenseRef) || !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(input.Digest) || (input.Result != "INSTALLED" && input.Result != "FAILED") || len(input.ErrorCode) > 100 {
|
||||
return ErrAcknowledgement
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var id, publicID, digest, status string
|
||||
var expires time.Time
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text,public_id::text,payload_digest,status,expires_at FROM hardware_agent_license WHERE tenant_id=$1::uuid AND allocated_device_id=$2::uuid AND license_ref=$3 FOR UPDATE`, identity.TenantID, identity.ID, input.LicenseRef).Scan(&id, &publicID, &digest, &status, &expires); errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrAssignment
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if !expires.After(time.Now().UTC()) {
|
||||
_, _ = tx.Exec(ctx, `UPDATE hardware_agent_license SET status='EXPIRED',updated_at=now(),version=version+1 WHERE id=$1::uuid`, id)
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrExpired
|
||||
}
|
||||
if digest != input.Digest {
|
||||
return ErrAcknowledgement
|
||||
}
|
||||
if input.Result == "FAILED" {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_license SET status='SUSPENDED',last_error_code=NULLIF($2,''),updated_at=now(),version=version+1 WHERE id=$1::uuid`, id, input.ErrorCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_license SET status='ACTIVE',activated_at=COALESCE(activated_at,now()),last_error_code=NULL,updated_at=now(),version=version+1 WHERE id=$1::uuid`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var providerReady, bindingReady bool
|
||||
var activationState string
|
||||
if err := tx.QueryRow(ctx, `SELECT d.activation_state,EXISTS(SELECT 1 FROM hardware_agent_provider_credential pc WHERE pc.device_id=d.id AND pc.status='ACTIVE'),EXISTS(SELECT 1 FROM device_binding b WHERE b.device_id=d.id AND b.unbound_at IS NULL) FROM device d WHERE d.id=$1::uuid AND d.tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&activationState, &providerReady, &bindingReady); err != nil {
|
||||
return err
|
||||
}
|
||||
if !providerReady || !bindingReady {
|
||||
return ErrAssignment
|
||||
}
|
||||
if activationState == string(deviceactivation.Provisioning) {
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: identity.ID, TenantID: identity.TenantID, Target: deviceactivation.Active, Reason: "设备已安全保存并确认有效License", Source: "license-ack", ActorType: "device", ActorPublicID: &identity.PublicID, RequestID: requestID}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &identity.TenantID, ActorType: "device", ActorPublicID: &identity.PublicID, Action: "hardware_agent.license.ack", ResourceType: "hardware_agent_license", ResourceID: &publicID, RequestID: requestID, Result: "success", Detail: map[string]any{"license_ref": input.LicenseRef, "result": input.Result, "error_code": input.ErrorCode}}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
type access struct{ tenantID, userPublicID, role string }
|
||||
type queryRow interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
func requireAccess(ctx context.Context, q queryRow, principal identity.Principal, tenantPublicID string, mutate bool) (access, error) {
|
||||
var a access
|
||||
err := q.QueryRow(ctx, `SELECT t.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(&a.tenantID, &a.userPublicID, &a.role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return access{}, ErrAccessDenied
|
||||
}
|
||||
if err != nil {
|
||||
return access{}, err
|
||||
}
|
||||
if mutate && a.role != "ops_operator" && a.role != "ops_admin" {
|
||||
return access{}, ErrAccessDenied
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
func licenseAAD(ref, instanceID, productKey string, expires time.Time) []byte {
|
||||
return []byte(fmt.Sprintf("hardware-license-v1\n%s\n%s\n%s\n%d", ref, instanceID, productKey, expires.UTC().Unix()))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package hardwarelicense
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestNewServiceRequiresDomainKey(t *testing.T) {
|
||||
for _, key := range []string{"", "not-hex", "00"} {
|
||||
if _, err := NewService(&pgxpool.Pool{}, audit.NewRepository(), key); err == nil {
|
||||
t.Fatalf("key %q should be rejected", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseCipherUsesBoundAAD(t *testing.T) {
|
||||
service, err := NewService(&pgxpool.Pool{}, audit.NewRepository(), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expires := time.Date(2027, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
nonce := make([]byte, service.aead.NonceSize())
|
||||
aad := licenseAAD("license-1", "instance-1", "product-1", expires)
|
||||
ciphertext := service.aead.Seal(nil, nonce, []byte("secret-license-value"), aad)
|
||||
plaintext, err := service.aead.Open(nil, nonce, ciphertext, aad)
|
||||
if err != nil || string(plaintext) != "secret-license-value" {
|
||||
t.Fatalf("round trip = %q, %v", plaintext, err)
|
||||
}
|
||||
if _, err := service.aead.Open(nil, nonce, ciphertext, licenseAAD("license-1", "instance-1", "product-2", expires)); err == nil {
|
||||
t.Fatal("ciphertext must not decrypt for another product")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package hardwareprovision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultDynamicRegisterURL = "https://iot-cn-shanghai.iot.volces.com/2021-12-14/DynamicRegister?Action=DynamicRegister&Version=2021-12-14"
|
||||
|
||||
type RegisterInput struct {
|
||||
InstanceID string
|
||||
ProductKey string
|
||||
DeviceName string
|
||||
}
|
||||
|
||||
type RegisterResult struct {
|
||||
DeviceSecret string
|
||||
RTCAppID string
|
||||
}
|
||||
|
||||
type Registrar interface {
|
||||
Register(context.Context, RegisterInput) (RegisterResult, error)
|
||||
}
|
||||
|
||||
type HTTPRegistrar struct {
|
||||
endpoint string
|
||||
productSecret string
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewHTTPRegistrar(endpoint, productSecret string, timeout time.Duration) (*HTTPRegistrar, error) {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if endpoint == "" {
|
||||
endpoint = defaultDynamicRegisterURL
|
||||
}
|
||||
if !strings.HasPrefix(endpoint, "https://") && !strings.HasPrefix(endpoint, "http://127.0.0.1:") && !strings.HasPrefix(endpoint, "http://localhost:") {
|
||||
return nil, errors.New("Volcengine dynamic registration URL must use HTTPS")
|
||||
}
|
||||
if len([]byte(strings.TrimSpace(productSecret))) < 16 {
|
||||
return nil, errors.New("Volcengine hardware product secret must contain at least 16 bytes")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return nil, errors.New("Volcengine hardware registration timeout must be positive")
|
||||
}
|
||||
return &HTTPRegistrar{
|
||||
endpoint: endpoint, productSecret: strings.TrimSpace(productSecret),
|
||||
client: &http.Client{Timeout: timeout}, now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *HTTPRegistrar) Register(ctx context.Context, input RegisterInput) (RegisterResult, error) {
|
||||
randomNumber, err := rand.Int(rand.Reader, big.NewInt(2_147_483_646))
|
||||
if err != nil {
|
||||
return RegisterResult{}, fmt.Errorf("generate dynamic registration nonce: %w", err)
|
||||
}
|
||||
randomValue := randomNumber.Int64() + 1
|
||||
timestamp := r.now().UTC().UnixMilli()
|
||||
content := fmt.Sprintf("auth_type=1&device_name=%s&random_num=%d&product_key=%s×tamp=%d", input.DeviceName, randomValue, input.ProductKey, timestamp)
|
||||
mac := hmac.New(sha256.New, []byte(r.productSecret))
|
||||
_, _ = mac.Write([]byte(content))
|
||||
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"InstanceID": input.InstanceID, "product_key": input.ProductKey, "device_name": input.DeviceName,
|
||||
"random_num": randomValue, "timestamp": timestamp, "auth_type": 1, "signature": signature,
|
||||
})
|
||||
if err != nil {
|
||||
return RegisterResult{}, fmt.Errorf("encode dynamic registration request: %w", err)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, r.endpoint, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return RegisterResult{}, fmt.Errorf("create dynamic registration request: %w", err)
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := r.client.Do(request)
|
||||
if err != nil {
|
||||
return RegisterResult{}, fmt.Errorf("call Volcengine dynamic registration: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, 64<<10))
|
||||
if err != nil {
|
||||
return RegisterResult{}, fmt.Errorf("read Volcengine dynamic registration response: %w", err)
|
||||
}
|
||||
var payload struct {
|
||||
ResponseMetadata struct {
|
||||
Error struct {
|
||||
Code string `json:"Code"`
|
||||
CodeN any `json:"CodeN"`
|
||||
Message string `json:"Message"`
|
||||
} `json:"Error"`
|
||||
} `json:"ResponseMetadata"`
|
||||
Result struct {
|
||||
Payload string `json:"payload"`
|
||||
RTCAppID string `json:"RTCAppID"`
|
||||
} `json:"Result"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return RegisterResult{}, fmt.Errorf("decode Volcengine dynamic registration response: HTTP %d", response.StatusCode)
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 || payload.ResponseMetadata.Error.Code != "" || payload.ResponseMetadata.Error.CodeN != nil {
|
||||
code := payload.ResponseMetadata.Error.Code
|
||||
if code == "" {
|
||||
code = fmt.Sprintf("HTTP_%d", response.StatusCode)
|
||||
}
|
||||
message := strings.TrimSpace(payload.ResponseMetadata.Error.Message)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(payload.Message)
|
||||
}
|
||||
if message == "" {
|
||||
message = "Volcengine dynamic registration failed"
|
||||
}
|
||||
return RegisterResult{}, fmt.Errorf("%s: %s", code, message)
|
||||
}
|
||||
secret, err := decryptRegisteredDeviceSecret(payload.Result.Payload, r.productSecret)
|
||||
if err != nil {
|
||||
return RegisterResult{}, err
|
||||
}
|
||||
return RegisterResult{DeviceSecret: secret, RTCAppID: strings.TrimSpace(payload.Result.RTCAppID)}, nil
|
||||
}
|
||||
|
||||
func decryptRegisteredDeviceSecret(encoded, productSecret string) (string, error) {
|
||||
keyMaterial := []byte(productSecret)
|
||||
if len(keyMaterial) < aes.BlockSize {
|
||||
return "", errors.New("product secret is too short to decrypt device credential")
|
||||
}
|
||||
key := keyMaterial[:aes.BlockSize]
|
||||
encrypted, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
|
||||
if err != nil || len(encrypted) == 0 || len(encrypted)%aes.BlockSize != 0 {
|
||||
return "", errors.New("Volcengine dynamic registration returned an invalid device credential")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create device credential decryptor: %w", err)
|
||||
}
|
||||
plain := make([]byte, len(encrypted))
|
||||
cipher.NewCBCDecrypter(block, key).CryptBlocks(plain, encrypted)
|
||||
padding := int(plain[len(plain)-1])
|
||||
if padding < 1 || padding > aes.BlockSize || padding > len(plain) {
|
||||
return "", errors.New("Volcengine device credential padding is invalid")
|
||||
}
|
||||
for _, value := range plain[len(plain)-padding:] {
|
||||
if int(value) != padding {
|
||||
return "", errors.New("Volcengine device credential padding is invalid")
|
||||
}
|
||||
}
|
||||
secret := strings.TrimSpace(string(plain[:len(plain)-padding]))
|
||||
if len(secret) < 16 || len(secret) > 4096 {
|
||||
return "", errors.New("Volcengine device credential length is invalid")
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package hardwareprovision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
)
|
||||
|
||||
func TestHTTPRegistrarSignsAndDecryptsDynamicRegistration(t *testing.T) {
|
||||
const productSecret = "1234567890abcdef-product-secret"
|
||||
const deviceSecret = "per-device-secret-with-at-least-32-characters"
|
||||
var received map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := json.NewDecoder(request.Body).Decode(&received); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
content := "auth_type=1&device_name=" + received["device_name"].(string) +
|
||||
"&random_num=" + strconv.FormatInt(int64(received["random_num"].(float64)), 10) +
|
||||
"&product_key=" + received["product_key"].(string) +
|
||||
"×tamp=" + strconv.FormatInt(int64(received["timestamp"].(float64)), 10)
|
||||
mac := hmac.New(sha256.New, []byte(productSecret))
|
||||
_, _ = mac.Write([]byte(content))
|
||||
if received["signature"] != base64.StdEncoding.EncodeToString(mac.Sum(nil)) {
|
||||
t.Fatal("dynamic registration signature did not match canonical fields")
|
||||
}
|
||||
if _, leaked := received["product_secret"]; leaked {
|
||||
t.Fatal("product secret must not be serialized into the provider request")
|
||||
}
|
||||
_ = json.NewEncoder(writer).Encode(map[string]any{"Result": map[string]any{
|
||||
"payload": encryptDeviceSecretForTest(t, deviceSecret, productSecret), "RTCAppID": "rtc-app",
|
||||
}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
registrar, err := NewHTTPRegistrar(server.URL, productSecret, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHTTPRegistrar() error = %v", err)
|
||||
}
|
||||
registrar.now = func() time.Time { return time.UnixMilli(1_700_000_000_000) }
|
||||
result, err := registrar.Register(context.Background(), RegisterInput{InstanceID: "instance-1", ProductKey: "product-1", DeviceName: "device-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
if result.DeviceSecret != deviceSecret || result.RTCAppID != "rtc-app" {
|
||||
t.Fatalf("Register() result = %+v", result)
|
||||
}
|
||||
if received["InstanceID"] != "instance-1" || received["product_key"] != "product-1" || received["device_name"] != "device-1" {
|
||||
t.Fatalf("unexpected registration request: %+v", received)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledProvisioningDoesNotRequireProductSecret(t *testing.T) {
|
||||
service, err := NewService(nil, nil, Options{Enabled: false})
|
||||
if err != nil {
|
||||
t.Fatalf("NewService() error = %v", err)
|
||||
}
|
||||
_, err = service.Provision(context.Background(), deviceauth.Identity{}, "request", ProvisionInput{HardwareID: "device"})
|
||||
if err != ErrUnavailable {
|
||||
t.Fatalf("Provision() error = %v, want ErrUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func encryptDeviceSecretForTest(t *testing.T, secret, productSecret string) string {
|
||||
t.Helper()
|
||||
key := []byte(productSecret)[:aes.BlockSize]
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain := []byte(secret)
|
||||
padding := aes.BlockSize - len(plain)%aes.BlockSize
|
||||
for index := 0; index < padding; index++ {
|
||||
plain = append(plain, byte(padding))
|
||||
}
|
||||
encrypted := make([]byte, len(plain))
|
||||
cipher.NewCBCEncrypter(block, key).CryptBlocks(encrypted, plain)
|
||||
return base64.StdEncoding.EncodeToString(encrypted)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package hardwareprovision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnavailable = errors.New("hardware provider provisioning is unavailable")
|
||||
ErrInvalid = errors.New("invalid hardware provider provisioning input")
|
||||
ErrBindingMissing = errors.New("hardware agent binding is missing")
|
||||
ErrBindingMismatch = errors.New("hardware agent product binding does not match platform provisioning configuration")
|
||||
ErrHardwareMismatch = errors.New("hardware identity does not match the provisioned credential")
|
||||
ErrActivationState = errors.New("device activation state does not allow provider provisioning")
|
||||
)
|
||||
|
||||
var hardwareIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_\-.:@]{1,200}$`)
|
||||
|
||||
type Options struct {
|
||||
Enabled bool
|
||||
InstanceID string
|
||||
ProductKey string
|
||||
BotID string
|
||||
Transport string
|
||||
MinimumSDKVersion string
|
||||
ProductSecret string
|
||||
CredentialMasterKey string
|
||||
RegisterURL string
|
||||
RegisterTimeout time.Duration
|
||||
DeviceNamePrefix string
|
||||
Registrar Registrar
|
||||
}
|
||||
|
||||
type ProvisionInput struct {
|
||||
HardwareID string `json:"hardware_id"`
|
||||
}
|
||||
|
||||
type ProvisionResponse struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
ProductKey string `json:"product_key"`
|
||||
DeviceName string `json:"device_name"`
|
||||
HardwareID string `json:"hardware_id"`
|
||||
DeviceSecret string `json:"device_secret"`
|
||||
CredentialRef string `json:"credential_ref"`
|
||||
CredentialVersion int64 `json:"credential_version"`
|
||||
CredentialSource string `json:"credential_source"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
enabled bool
|
||||
pool *pgxpool.Pool
|
||||
audit *audit.Repository
|
||||
registrar Registrar
|
||||
aead cipher.AEAD
|
||||
instanceID string
|
||||
productKey string
|
||||
botID string
|
||||
transport string
|
||||
minimumSDK string
|
||||
deviceNamePrefix string
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, auditRepository *audit.Repository, options Options) (*Service, error) {
|
||||
service := &Service{enabled: options.Enabled}
|
||||
if !options.Enabled {
|
||||
return service, nil
|
||||
}
|
||||
if pool == nil || auditRepository == nil {
|
||||
return nil, errors.New("hardware provider provisioning requires database and audit repository")
|
||||
}
|
||||
service.instanceID = strings.TrimSpace(options.InstanceID)
|
||||
service.productKey = strings.TrimSpace(options.ProductKey)
|
||||
service.botID = strings.TrimSpace(options.BotID)
|
||||
service.transport = strings.TrimSpace(options.Transport)
|
||||
service.minimumSDK = strings.TrimSpace(options.MinimumSDKVersion)
|
||||
service.deviceNamePrefix = strings.TrimSpace(options.DeviceNamePrefix)
|
||||
if service.deviceNamePrefix == "" {
|
||||
service.deviceNamePrefix = "dt-"
|
||||
}
|
||||
if service.instanceID == "" || len(service.instanceID) > 200 || service.productKey == "" || len(service.productKey) > 200 ||
|
||||
service.botID == "" || len(service.botID) > 200 || (service.transport != "high_quality" && service.transport != "low_load") ||
|
||||
service.minimumSDK == "" || len(service.minimumSDK) > 80 ||
|
||||
!regexp.MustCompile(`^[a-zA-Z0-9_\-.:@]{1,32}$`).MatchString(service.deviceNamePrefix) {
|
||||
return nil, errors.New("hardware provider instance, product defaults and device-name prefix are invalid")
|
||||
}
|
||||
key, err := hex.DecodeString(strings.TrimSpace(options.CredentialMasterKey))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, errors.New("VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY must be 32 bytes encoded as 64 hexadecimal characters")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create hardware provider credential cipher: %w", err)
|
||||
}
|
||||
service.aead, err = cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create hardware provider credential AEAD: %w", err)
|
||||
}
|
||||
service.registrar = options.Registrar
|
||||
if service.registrar == nil {
|
||||
service.registrar, err = NewHTTPRegistrar(options.RegisterURL, options.ProductSecret, options.RegisterTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
service.pool = pool
|
||||
service.audit = auditRepository
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *Service) Provision(ctx context.Context, identity deviceauth.Identity, requestID string, input ProvisionInput) (ProvisionResponse, error) {
|
||||
if !s.enabled {
|
||||
return ProvisionResponse{}, ErrUnavailable
|
||||
}
|
||||
hardwareID := strings.TrimSpace(input.HardwareID)
|
||||
if !hardwareIDPattern.MatchString(hardwareID) {
|
||||
return ProvisionResponse{}, ErrInvalid
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("begin hardware provider provisioning: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, identity.ID); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("lock hardware provider provisioning: %w", err)
|
||||
}
|
||||
activationState, err := s.prepareActivation(ctx, tx, identity, requestID)
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, err
|
||||
}
|
||||
if err := s.ensureProductBinding(ctx, tx, identity); err != nil {
|
||||
return ProvisionResponse{}, err
|
||||
}
|
||||
existing, found, err := s.readExisting(ctx, tx, identity.ID)
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, err
|
||||
}
|
||||
if found {
|
||||
if existing.HardwareID != hardwareID {
|
||||
return ProvisionResponse{}, ErrHardwareMismatch
|
||||
}
|
||||
if existing.InstanceID != s.instanceID || existing.ProductKey != s.productKey {
|
||||
return ProvisionResponse{}, ErrBindingMismatch
|
||||
}
|
||||
secret, err := s.aead.Open(nil, existing.Nonce, existing.EncryptedSecret, credentialAAD(existing.CredentialRef, existing.KeyVersion, existing.InstanceID, existing.ProductKey, existing.DeviceName))
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, errors.New("decrypt hardware provider device credential")
|
||||
}
|
||||
response := ProvisionResponse{InstanceID: existing.InstanceID, ProductKey: existing.ProductKey, DeviceName: existing.DeviceName,
|
||||
HardwareID: existing.HardwareID, DeviceSecret: string(secret), CredentialRef: existing.CredentialRef,
|
||||
CredentialVersion: existing.KeyVersion, CredentialSource: "platform_stored", IssuedAt: existing.IssuedAt}
|
||||
if existing.Status == "SUSPENDED" {
|
||||
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'`, identity.ID); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("reactivate hardware provider credential: %w", err)
|
||||
}
|
||||
}
|
||||
if activationState == deviceactivation.Provisioning {
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: identity.ID, TenantID: identity.TenantID,
|
||||
Target: deviceactivation.Active, Reason: "火山运行凭据已就绪", Source: "provider-provisioning", ActorType: "device",
|
||||
ActorPublicID: &identity.PublicID, RequestID: requestID}); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("complete provider activation: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.recordAudit(ctx, tx, identity, requestID, "hardware_agent.provider_credential.resolve", response, true); err != nil {
|
||||
return ProvisionResponse{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("commit hardware provider credential resolution: %w", err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
var configuredProduct, configuredDeviceName string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(product_key,''),COALESCE(provider_device_ref,'')
|
||||
FROM hardware_agent_device_binding
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND provider='volcengine' AND status='ACTIVE'
|
||||
FOR UPDATE`, identity.ID, identity.TenantID).Scan(&configuredProduct, &configuredDeviceName)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ProvisionResponse{}, ErrBindingMissing
|
||||
}
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("read hardware agent provider binding: %w", err)
|
||||
}
|
||||
if configuredProduct != "" && configuredProduct != s.productKey {
|
||||
return ProvisionResponse{}, ErrBindingMismatch
|
||||
}
|
||||
deviceName := strings.TrimSpace(configuredDeviceName)
|
||||
if deviceName == "" {
|
||||
deviceName = s.deviceNamePrefix + strings.ReplaceAll(identity.PublicID, "-", "")
|
||||
}
|
||||
if len(deviceName) > 128 || !regexp.MustCompile(`^[a-zA-Z0-9_\-.:@]{1,128}$`).MatchString(deviceName) {
|
||||
return ProvisionResponse{}, ErrInvalid
|
||||
}
|
||||
registered, err := s.registrar.Register(ctx, RegisterInput{InstanceID: s.instanceID, ProductKey: s.productKey, DeviceName: deviceName})
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("register hardware provider device: %w", err)
|
||||
}
|
||||
secret := []byte(strings.TrimSpace(registered.DeviceSecret))
|
||||
if len(secret) < 16 || len(secret) > 4096 {
|
||||
return ProvisionResponse{}, errors.New("hardware provider returned an invalid device credential")
|
||||
}
|
||||
credentialRef := "volc-device-secret://" + identity.PublicID
|
||||
const keyVersion int64 = 1
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("generate hardware provider credential nonce: %w", err)
|
||||
}
|
||||
encrypted := s.aead.Seal(nil, nonce, secret, credentialAAD(credentialRef, keyVersion, s.instanceID, s.productKey, deviceName))
|
||||
issuedAt := time.Now().UTC()
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO hardware_agent_provider_credential(
|
||||
tenant_id,device_id,credential_ref,instance_id,product_key,provider_device_name,hardware_id,
|
||||
key_version,encrypted_secret,nonce,status,issued_at
|
||||
) VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9,$10,'ACTIVE',$11)`,
|
||||
identity.TenantID, identity.ID, credentialRef, s.instanceID, s.productKey, deviceName, hardwareID,
|
||||
keyVersion, encrypted, nonce, issuedAt)
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("store hardware provider device credential: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE hardware_agent_device_binding
|
||||
SET product_key=$2,provider_device_ref=$3,credential_ref=$4,updated_at=now()
|
||||
WHERE device_id=$1::uuid`, identity.ID, s.productKey, deviceName, credentialRef)
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("update hardware agent provider binding: %w", err)
|
||||
}
|
||||
response := ProvisionResponse{InstanceID: s.instanceID, ProductKey: s.productKey, DeviceName: deviceName, HardwareID: hardwareID,
|
||||
DeviceSecret: string(secret), CredentialRef: credentialRef, CredentialVersion: keyVersion,
|
||||
CredentialSource: "platform_dynamic_registration", IssuedAt: issuedAt}
|
||||
if activationState == deviceactivation.Provisioning {
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: identity.ID, TenantID: identity.TenantID,
|
||||
Target: deviceactivation.Active, Reason: "火山动态注册与运行凭据已就绪", Source: "provider-provisioning", ActorType: "device",
|
||||
ActorPublicID: &identity.PublicID, RequestID: requestID}); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("complete provider activation: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.recordAudit(ctx, tx, identity, requestID, "hardware_agent.provider_credential.provision", response, false); err != nil {
|
||||
return ProvisionResponse{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("commit hardware provider provisioning: %w", err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ensureProductBinding resolves the device runtime from the platform-owned
|
||||
// product defaults. DynamicRegister only creates the provider device identity;
|
||||
// it does not return a Bot ID, so devices must never supply or persist that
|
||||
// routing choice themselves.
|
||||
func (s *Service) ensureProductBinding(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity) error {
|
||||
var runtime string
|
||||
if err := tx.QueryRow(ctx, `SELECT COALESCE(desired_config->>'voice_runtime','legacy_voicechat') FROM device_twin WHERE device_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&runtime); errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrBindingMissing
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("read hardware agent runtime: %w", err)
|
||||
}
|
||||
if runtime != "volc_hardware_agent" {
|
||||
return ErrBindingMissing
|
||||
}
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO hardware_agent_device_binding(
|
||||
tenant_id,device_id,provider,product_key,bot_id,transport,minimum_sdk_version,status
|
||||
) VALUES($1::uuid,$2::uuid,'volcengine',$3,$4,$5,$6,'ACTIVE')
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
product_key=EXCLUDED.product_key,bot_id=EXCLUDED.bot_id,transport=EXCLUDED.transport,
|
||||
minimum_sdk_version=EXCLUDED.minimum_sdk_version,status='ACTIVE',updated_at=now()`,
|
||||
identity.TenantID, identity.ID, s.productKey, s.botID, s.transport, s.minimumSDK)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind hardware agent product defaults: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE device_twin SET desired_revision=desired_revision+1,
|
||||
desired_config=desired_config||jsonb_build_object(
|
||||
'voice_runtime','volc_hardware_agent','hardware_agent_transport',$2::text,'minimum_sdk_version',$3::text
|
||||
),updated_at=now()
|
||||
WHERE device_id=$1::uuid AND (
|
||||
COALESCE(desired_config->>'hardware_agent_transport','')<>$2 OR
|
||||
COALESCE(desired_config->>'minimum_sdk_version','')<>$3
|
||||
)`, identity.ID, s.transport, s.minimumSDK)
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish hardware agent product defaults: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type storedCredential struct {
|
||||
Status string
|
||||
CredentialRef string
|
||||
InstanceID string
|
||||
ProductKey string
|
||||
DeviceName string
|
||||
HardwareID string
|
||||
KeyVersion int64
|
||||
EncryptedSecret []byte
|
||||
Nonce []byte
|
||||
IssuedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Service) readExisting(ctx context.Context, tx pgx.Tx, deviceID string) (storedCredential, bool, error) {
|
||||
var item storedCredential
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT status,credential_ref,instance_id,product_key,provider_device_name,hardware_id,key_version,encrypted_secret,nonce,issued_at
|
||||
FROM hardware_agent_provider_credential
|
||||
WHERE device_id=$1::uuid AND provider='volcengine' AND status IN ('ACTIVE','SUSPENDED')
|
||||
FOR UPDATE`, deviceID).Scan(&item.Status, &item.CredentialRef, &item.InstanceID, &item.ProductKey, &item.DeviceName, &item.HardwareID,
|
||||
&item.KeyVersion, &item.EncryptedSecret, &item.Nonce, &item.IssuedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return storedCredential{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return storedCredential{}, false, fmt.Errorf("read hardware provider device credential: %w", err)
|
||||
}
|
||||
return item, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) prepareActivation(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string) (deviceactivation.State, error) {
|
||||
var stateText string
|
||||
if err := tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&stateText); err != nil {
|
||||
return "", fmt.Errorf("read device activation for provisioning: %w", err)
|
||||
}
|
||||
state, err := deviceactivation.Parse(stateText)
|
||||
if err != nil {
|
||||
return "", ErrActivationState
|
||||
}
|
||||
if state == deviceactivation.Bound {
|
||||
result, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: identity.ID, TenantID: identity.TenantID,
|
||||
Target: deviceactivation.Provisioning, Reason: "设备请求火山运行凭据", Source: "provider-provisioning", ActorType: "device",
|
||||
ActorPublicID: &identity.PublicID, RequestID: requestID})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("start provider activation: %w", err)
|
||||
}
|
||||
return result.To, nil
|
||||
}
|
||||
if state != deviceactivation.Provisioning && state != deviceactivation.Active {
|
||||
return "", ErrActivationState
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Service) recordAudit(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID, action string, response ProvisionResponse, reused bool) error {
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &identity.TenantID, ActorType: "device", ActorPublicID: &identity.PublicID,
|
||||
Action: action, ResourceType: "device", ResourceID: &identity.PublicID, RequestID: requestID, Result: "success",
|
||||
Detail: map[string]any{"provider": "volcengine", "product_key": response.ProductKey, "provider_device_name": response.DeviceName,
|
||||
"credential_version": response.CredentialVersion, "reused": reused}})
|
||||
}
|
||||
|
||||
func credentialAAD(reference string, version int64, instanceID, productKey, deviceName string) []byte {
|
||||
return []byte(fmt.Sprintf("hardware-provider-credential-v1\n%s\n%d\n%s\n%s\n%s", reference, version, instanceID, productKey, deviceName))
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package hardwareprovision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type countingRegistrar struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *countingRegistrar) Register(_ context.Context, _ RegisterInput) (RegisterResult, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls++
|
||||
return RegisterResult{DeviceSecret: "provider-device-secret-with-at-least-32-characters"}, nil
|
||||
}
|
||||
|
||||
func TestProvisionPersistsEncryptedCredentialAndReusesRegistration(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
deviceID := uuid.NewString()
|
||||
devicePublicID := uuid.NewString()
|
||||
const tenantID = "10000000-0000-0000-0000-000000000001"
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO device(id,public_id,tenant_id,serial_digest,model,status,credential_ref,activation_state,activation_reason)
|
||||
VALUES($1::uuid,$2::uuid,$3::uuid,$4,'hardware-provision-test','registered',$5,'BOUND','integration-test')`,
|
||||
deviceID, devicePublicID, tenantID, strings.Repeat("9", 64), "test://"+devicePublicID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO device_twin(tenant_id,device_id,desired_revision,desired_config)
|
||||
VALUES($1::uuid,$2::uuid,1,'{"voice_runtime":"volc_hardware_agent"}'::jsonb)`, tenantID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, devicePublicID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM hardware_agent_provider_credential WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM hardware_agent_device_binding WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_twin WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_activation_transition WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device WHERE id=$1::uuid`, deviceID)
|
||||
})
|
||||
|
||||
registrar := &countingRegistrar{}
|
||||
service, err := NewService(pool, audit.NewRepository(), Options{
|
||||
Enabled: true, InstanceID: "instance-1", ProductKey: "product-1", BotID: "bot-test", Transport: "low_load", MinimumSDKVersion: "1.1.0", ProductSecret: "unused-with-mock-registrar",
|
||||
CredentialMasterKey: strings.Repeat("ab", 32), RegisterTimeout: time.Second, DeviceNamePrefix: "dt-", Registrar: registrar,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity := deviceauth.Identity{ID: deviceID, PublicID: devicePublicID, TenantID: tenantID, Status: "registered"}
|
||||
first, err := service.Provision(ctx, identity, "request-1", ProvisionInput{HardwareID: "hardware-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.Provision(ctx, identity, "request-2", ProvisionInput{HardwareID: "hardware-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.DeviceSecret != second.DeviceSecret || first.CredentialSource != "platform_dynamic_registration" || second.CredentialSource != "platform_stored" {
|
||||
t.Fatalf("unexpected provisioning responses: first=%+v second=%+v", first, second)
|
||||
}
|
||||
registrar.mu.Lock()
|
||||
calls := registrar.calls
|
||||
registrar.mu.Unlock()
|
||||
if calls != 1 {
|
||||
t.Fatalf("registrar calls = %d, want 1", calls)
|
||||
}
|
||||
var activationState string
|
||||
if err := pool.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid`, deviceID).Scan(&activationState); err != nil || activationState != "ACTIVE" {
|
||||
t.Fatalf("activation state=%s err=%v, want ACTIVE", activationState, err)
|
||||
}
|
||||
var encrypted []byte
|
||||
var productKey, deviceName, credentialRef, botID, transport, minimumSDK string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT pc.encrypted_secret,hb.product_key,hb.provider_device_ref,hb.credential_ref,hb.bot_id,hb.transport,hb.minimum_sdk_version
|
||||
FROM hardware_agent_provider_credential pc JOIN hardware_agent_device_binding hb ON hb.device_id=pc.device_id
|
||||
WHERE pc.device_id=$1::uuid`, deviceID).Scan(&encrypted, &productKey, &deviceName, &credentialRef, &botID, &transport, &minimumSDK); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encrypted), first.DeviceSecret) || productKey != "product-1" || deviceName != first.DeviceName || credentialRef != first.CredentialRef || botID != "bot-test" || transport != "low_load" || minimumSDK != "1.1.0" {
|
||||
t.Fatalf("credential was not stored/bound safely")
|
||||
}
|
||||
}
|
||||
@@ -492,6 +492,8 @@ func writeCreateConversationError(writer http.ResponseWriter, request *http.Requ
|
||||
WriteError(writer, request, http.StatusForbidden, "CONSENT_REQUIRED", "缺少本次语音通话所需授权")
|
||||
case errors.Is(err, orchestration.ErrFirmwareUpdateRequired):
|
||||
WriteError(writer, request, http.StatusUpgradeRequired, "FIRMWARE_UPDATE_REQUIRED", "设备固件低于当前通话最低版本,请先完成升级")
|
||||
case errors.Is(err, orchestration.ErrHardwareSDKUpdateRequired):
|
||||
WriteError(writer, request, http.StatusUpgradeRequired, "HARDWARE_AGENT_SDK_UPDATE_REQUIRED", "火山硬件智能体SDK低于当前准入版本,请先完成升级")
|
||||
case errors.Is(err, orchestration.ErrConversationAlreadyActive):
|
||||
WriteError(writer, request, http.StatusConflict, "CONVERSATION_ALREADY_ACTIVE", "设备已有未结束通话")
|
||||
case errors.Is(err, orchestration.ErrUsageQuotaExceeded):
|
||||
|
||||
@@ -111,6 +111,7 @@ func TestConversationErrorWritersExposeStableStateAndRetryContracts(t *testing.T
|
||||
{"create unbound", writeCreateConversationError, orchestration.ErrDeviceNotBound, http.StatusConflict, "DEVICE_NOT_BOUND"},
|
||||
{"create consent", writeCreateConversationError, orchestration.ErrConsentRequired, http.StatusForbidden, "CONSENT_REQUIRED"},
|
||||
{"create firmware", writeCreateConversationError, orchestration.ErrFirmwareUpdateRequired, http.StatusUpgradeRequired, "FIRMWARE_UPDATE_REQUIRED"},
|
||||
{"create hardware SDK", writeCreateConversationError, orchestration.ErrHardwareSDKUpdateRequired, http.StatusUpgradeRequired, "HARDWARE_AGENT_SDK_UPDATE_REQUIRED"},
|
||||
{"create active", writeCreateConversationError, orchestration.ErrConversationAlreadyActive, http.StatusConflict, "CONVERSATION_ALREADY_ACTIVE"},
|
||||
{"create quota", writeCreateConversationError, orchestration.ErrUsageQuotaExceeded, http.StatusTooManyRequests, "USAGE_QUOTA_EXCEEDED"},
|
||||
{"create runtime config", writeCreateConversationError, orchestration.ErrRuntimeConfigUnavailable, http.StatusServiceUnavailable, "RUNTIME_CONFIG_UNAVAILABLE"},
|
||||
|
||||
@@ -31,3 +31,41 @@ func DeviceAuth(verifier DeviceRequestVerifier, next http.Handler) http.Handler
|
||||
next.ServeHTTP(writer, request.WithContext(deviceauth.WithIdentity(request.Context(), identity)))
|
||||
})
|
||||
}
|
||||
|
||||
func RequireDeviceActivation(allowed []string, next http.Handler) http.Handler {
|
||||
allowedSet := make(map[string]struct{}, len(allowed))
|
||||
for _, state := range allowed {
|
||||
allowedSet[state] = struct{}{}
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
identity, ok := deviceauth.FromContext(r.Context())
|
||||
if !ok {
|
||||
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
state := identity.ActivationState
|
||||
if state == "" {
|
||||
state = "ACTIVE"
|
||||
}
|
||||
if _, ok := allowedSet[state]; !ok {
|
||||
WriteAPIError(w, r, http.StatusConflict, Error{Code: "DEVICE_ACTIVATION_REQUIRED", Message: "当前设备激活状态不能执行该操作", Details: map[string]any{"activation_state": state, "allowed_states": allowed}})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func RequireHardwareLicense(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
identity, ok := deviceauth.FromContext(r.Context())
|
||||
if !ok {
|
||||
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
if identity.VoiceRuntime == "volc_hardware_agent" && !identity.HardwareLicenseReady {
|
||||
WriteError(w, r, http.StatusConflict, "HARDWARE_LICENSE_REQUIRED", "设备没有已激活且未到期的硬件智能体License")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +56,53 @@ func TestDeviceAuthDoesNotExposeCredentialFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireDeviceActivation(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
handler := RequireDeviceActivation([]string{"ACTIVE"}, next)
|
||||
|
||||
activeRequest := httptest.NewRequest(http.MethodPost, "/device/v3/agent-sessions", nil)
|
||||
activeRequest = activeRequest.WithContext(deviceauth.WithIdentity(activeRequest.Context(), deviceauth.Identity{ActivationState: "ACTIVE"}))
|
||||
activeRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(activeRecorder, activeRequest)
|
||||
if activeRecorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("active status = %d", activeRecorder.Code)
|
||||
}
|
||||
|
||||
boundRequest := httptest.NewRequest(http.MethodPost, "/device/v3/agent-sessions", nil)
|
||||
boundRequest = boundRequest.WithContext(deviceauth.WithIdentity(boundRequest.Context(), deviceauth.Identity{ActivationState: "BOUND"}))
|
||||
boundRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(boundRecorder, boundRequest)
|
||||
if boundRecorder.Code != http.StatusConflict || !containsText(boundRecorder.Body.String(), "DEVICE_ACTIVATION_REQUIRED") {
|
||||
t.Fatalf("bound response = %d %s", boundRecorder.Code, boundRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireHardwareLicense(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
handler := RequireHardwareLicense(next)
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
identity deviceauth.Identity
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{name: "hardware license active", identity: deviceauth.Identity{VoiceRuntime: "volc_hardware_agent", HardwareLicenseReady: true}, status: http.StatusNoContent},
|
||||
{name: "hardware license missing", identity: deviceauth.Identity{VoiceRuntime: "volc_hardware_agent"}, status: http.StatusConflict, code: "HARDWARE_LICENSE_REQUIRED"},
|
||||
{name: "legacy runtime unaffected", identity: deviceauth.Identity{VoiceRuntime: "legacy_voicechat"}, status: http.StatusNoContent},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/agent-sessions", nil)
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), testCase.identity))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != testCase.status || (testCase.code != "" && !containsText(recorder.Body.String(), testCase.code)) {
|
||||
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsText(value, part string) bool {
|
||||
for index := 0; index+len(part) <= len(value); index++ {
|
||||
if value[index:index+len(part)] == part {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicereset"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
)
|
||||
|
||||
type DeviceFactoryResetService interface {
|
||||
Reset(context.Context, deviceauth.Identity, string, string, []byte, devicereset.Input) (devicereset.Response, bool, error)
|
||||
}
|
||||
|
||||
func DeviceFactoryResetHandler(service DeviceFactoryResetService) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.Header().Set("Pragma", "no-cache")
|
||||
identity, ok := deviceauth.FromContext(request.Context())
|
||||
if !ok {
|
||||
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
||||
if len(key) < 16 || len(key) > 128 {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "缺少有效的重复请求识别码")
|
||||
return
|
||||
}
|
||||
rawBody, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "INVALID_REQUEST_BODY", "请求正文无法读取")
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
||||
decoder.DisallowUnknownFields()
|
||||
var input devicereset.Input
|
||||
if err := decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil || devicereset.Validate(input) != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "设备恢复出厂参数不正确")
|
||||
return
|
||||
}
|
||||
response, replayed, err := service.Reset(request.Context(), identity, key, RequestID(request.Context()), rawBody, input)
|
||||
if replayed {
|
||||
writer.Header().Set("Idempotency-Replayed", "true")
|
||||
}
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, devicereset.ErrPendingTranscripts):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_PENDING_TRANSCRIPTS", "设备仍有未上传的聊天记录")
|
||||
case errors.Is(err, devicereset.ErrFirmwareInProgress):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_FIRMWARE_IN_PROGRESS", "设备正在升级固件,暂时不能恢复出厂")
|
||||
case errors.Is(err, devicereset.ErrResetCounterConflict):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_COUNTER_CONFLICT", "设备重置计数器不是递增值")
|
||||
case errors.Is(err, devicereset.ErrStateConflict):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_STATE_CONFLICT", "当前设备状态不允许恢复出厂")
|
||||
case errors.Is(err, idempotency.ErrKeyConflict):
|
||||
WriteError(writer, request, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT", "重复请求识别码已用于不同的请求")
|
||||
case errors.Is(err, idempotency.ErrInProgress):
|
||||
WriteAPIError(writer, request, http.StatusConflict, Error{Code: "IDEMPOTENCY_IN_PROGRESS", Message: "相同恢复出厂请求仍在处理中", Retryable: true})
|
||||
case errors.Is(err, devicereset.ErrInvalid):
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "设备恢复出厂参数不正确")
|
||||
default:
|
||||
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "DEVICE_RESET_FAILED", Message: "设备恢复出厂暂时失败", Retryable: true})
|
||||
}
|
||||
return
|
||||
}
|
||||
WriteJSON(writer, request, http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicereset"
|
||||
)
|
||||
|
||||
type fakeDeviceFactoryResetService struct {
|
||||
response devicereset.Response
|
||||
replayed bool
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func validDeviceFactoryResetBody() string {
|
||||
return fmt.Sprintf(`{"reset_id":"10000000-0000-0000-0000-000000000901","reset_counter":2,"occurred_at":%q,"trigger":"physical_long_press","firmware_version":"1.1.0","pending_turn_count":0}`,
|
||||
time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func (f *fakeDeviceFactoryResetService) Reset(_ context.Context, _ deviceauth.Identity, _ string, _ string, _ []byte, _ devicereset.Input) (devicereset.Response, bool, error) {
|
||||
f.calls++
|
||||
return f.response, f.replayed, f.err
|
||||
}
|
||||
|
||||
func TestDeviceFactoryResetHandlerReturnsClaimableState(t *testing.T) {
|
||||
service := &fakeDeviceFactoryResetService{replayed: true, response: devicereset.Response{
|
||||
ResetID: "10000000-0000-0000-0000-000000000901", DeviceID: "10000000-0000-0000-0000-000000000512",
|
||||
ActivationState: "CLAIMABLE", ResetCounter: 2, BindingRemoved: true,
|
||||
ProviderCredentialStatus: "SUSPENDED", LicenseAction: "RETAINED",
|
||||
Claim: devicereset.Claim{Code: "ABCD-EFGH-IJKL-MNOP", IssuedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(24 * time.Hour)},
|
||||
}}
|
||||
body := validDeviceFactoryResetBody()
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/factory-reset", strings.NewReader(body))
|
||||
request.Header.Set("Idempotency-Key", "factory-reset-key-0001")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "10000000-0000-0000-0000-000000000512"}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
DeviceFactoryResetHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK || service.calls != 1 || recorder.Header().Get("Idempotency-Replayed") != "true" ||
|
||||
!strings.Contains(recorder.Body.String(), `"activation_state":"CLAIMABLE"`) || !strings.Contains(recorder.Body.String(), `"code":"ABCD-EFGH-IJKL-MNOP"`) {
|
||||
t.Fatalf("status=%d calls=%d headers=%v body=%s", recorder.Code, service.calls, recorder.Header(), recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceFactoryResetHandlerRejectsInvalidInput(t *testing.T) {
|
||||
service := &fakeDeviceFactoryResetService{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/factory-reset", strings.NewReader(`{"reset_id":"bad"}`))
|
||||
request.Header.Set("Idempotency-Key", "factory-reset-key-0001")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "10000000-0000-0000-0000-000000000512"}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
DeviceFactoryResetHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest || service.calls != 0 || !strings.Contains(recorder.Body.String(), "VALIDATION_FAILED") {
|
||||
t.Fatalf("status=%d calls=%d body=%s", recorder.Code, service.calls, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceFactoryResetHandlerMapsPendingTranscripts(t *testing.T) {
|
||||
service := &fakeDeviceFactoryResetService{err: devicereset.ErrPendingTranscripts}
|
||||
body := validDeviceFactoryResetBody()
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/factory-reset", strings.NewReader(body))
|
||||
request.Header.Set("Idempotency-Key", "factory-reset-key-0001")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "10000000-0000-0000-0000-000000000512"}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
DeviceFactoryResetHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "DEVICE_RESET_PENDING_TRANSCRIPTS") {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceFactoryResetHandlerMapsIdempotencyConflict(t *testing.T) {
|
||||
service := &fakeDeviceFactoryResetService{err: errors.New("unexpected")}
|
||||
body := validDeviceFactoryResetBody()
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/factory-reset", strings.NewReader(body))
|
||||
request.Header.Set("Idempotency-Key", "factory-reset-key-0001")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "10000000-0000-0000-0000-000000000512"}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
DeviceFactoryResetHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), "DEVICE_RESET_FAILED") {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/hardwarelicense"
|
||||
"data-terminal-cloud/internal/hardwareprovision"
|
||||
"data-terminal-cloud/internal/orchestration"
|
||||
)
|
||||
|
||||
type HardwareAgentSessionService interface {
|
||||
CreateHardwareAgent(context.Context, deviceauth.Identity, string, string, []byte, orchestration.HardwareAgentCreateInput) (orchestration.HardwareAgentCreateResponse, bool, error)
|
||||
ProcessHardwareAgentEvent(context.Context, deviceauth.Identity, string, []byte, orchestration.HardwareAgentEventInput) (orchestration.HardwareAgentEventResponse, error)
|
||||
}
|
||||
|
||||
type HardwareAgentProvisioningService interface {
|
||||
Provision(context.Context, deviceauth.Identity, string, hardwareprovision.ProvisionInput) (hardwareprovision.ProvisionResponse, error)
|
||||
}
|
||||
|
||||
type HardwareAgentLicenseService interface {
|
||||
Acknowledge(context.Context, deviceauth.Identity, string, hardwarelicense.AcknowledgeInput) error
|
||||
}
|
||||
|
||||
func AcknowledgeHardwareAgentLicenseHandler(service HardwareAgentLicenseService) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
identity, ok := deviceauth.FromContext(r.Context())
|
||||
if !ok {
|
||||
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
LicenseRef string `json:"license_ref"`
|
||||
Digest string `json:"digest"`
|
||||
Result string `json:"result"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &body, "License确认参数不正确") {
|
||||
return
|
||||
}
|
||||
err := service.Acknowledge(r.Context(), identity, RequestID(r.Context()), hardwarelicense.AcknowledgeInput{LicenseRef: body.LicenseRef, Digest: body.Digest, Result: body.Result, ErrorCode: body.ErrorCode})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, hardwarelicense.ErrAssignment), errors.Is(err, hardwarelicense.ErrAcknowledgement):
|
||||
WriteError(w, r, http.StatusConflict, "HARDWARE_LICENSE_ACK_CONFLICT", "License不属于当前设备或摘要不一致")
|
||||
case errors.Is(err, hardwarelicense.ErrExpired):
|
||||
WriteError(w, r, http.StatusConflict, "HARDWARE_LICENSE_EXPIRED", "License已经到期")
|
||||
default:
|
||||
WriteAPIError(w, r, http.StatusServiceUnavailable, Error{Code: "HARDWARE_LICENSE_ACK_FAILED", Message: "License确认暂时失败", Retryable: true})
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"license_ref": body.LicenseRef, "status": strings.ToUpper(strings.TrimSpace(body.Result))})
|
||||
})
|
||||
}
|
||||
|
||||
func ProvisionHardwareAgentCredentialHandler(service HardwareAgentProvisioningService) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.Header().Set("Pragma", "no-cache")
|
||||
identity, ok := deviceauth.FromContext(request.Context())
|
||||
if !ok {
|
||||
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
||||
if len(key) < 16 || len(key) > 128 {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "缺少有效的重复请求识别码")
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
var input hardwareprovision.ProvisionInput
|
||||
if err := decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "火山设备注册参数不正确")
|
||||
return
|
||||
}
|
||||
response, err := service.Provision(request.Context(), identity, RequestID(request.Context()), input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, hardwareprovision.ErrUnavailable):
|
||||
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "HARDWARE_AGENT_PROVISIONING_UNAVAILABLE", Message: "平台尚未配置火山设备注册服务", Retryable: false})
|
||||
case errors.Is(err, hardwareprovision.ErrInvalid):
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "火山硬件标识不正确")
|
||||
case errors.Is(err, hardwareprovision.ErrBindingMissing):
|
||||
WriteError(writer, request, http.StatusConflict, "HARDWARE_AGENT_BINDING_REQUIRED", "设备尚未绑定火山硬件智能体")
|
||||
case errors.Is(err, hardwareprovision.ErrBindingMismatch):
|
||||
WriteError(writer, request, http.StatusConflict, "HARDWARE_AGENT_PRODUCT_MISMATCH", "设备绑定产品与平台注册产品不一致")
|
||||
case errors.Is(err, hardwareprovision.ErrHardwareMismatch):
|
||||
WriteError(writer, request, http.StatusConflict, "HARDWARE_AGENT_HARDWARE_MISMATCH", "硬件标识与已注册凭据不一致")
|
||||
case errors.Is(err, hardwareprovision.ErrActivationState):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_ACTIVATION_REQUIRED", "设备必须先完成用户绑定,才能申请火山运行凭据")
|
||||
default:
|
||||
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "HARDWARE_AGENT_PROVISIONING_FAILED", Message: "火山设备注册暂时失败", Retryable: true})
|
||||
}
|
||||
return
|
||||
}
|
||||
WriteJSON(writer, request, http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func CreateHardwareAgentSessionHandler(service HardwareAgentSessionService) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
identity, ok := deviceauth.FromContext(request.Context())
|
||||
if !ok {
|
||||
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
||||
if len(key) < 16 || len(key) > 128 {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "缺少有效的重复请求识别码")
|
||||
return
|
||||
}
|
||||
rawBody, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "INVALID_REQUEST_BODY", "请求正文无法读取")
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
||||
decoder.DisallowUnknownFields()
|
||||
var input orchestration.HardwareAgentCreateInput
|
||||
if err = decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "硬件智能体会话参数不正确")
|
||||
return
|
||||
}
|
||||
if input.DeviceTime.IsZero() || input.ConfigRevision < 1 || !input.Capabilities.HardwareAgent ||
|
||||
!input.Capabilities.TranscriptUpload || !input.Capabilities.DynamicSessionConfig ||
|
||||
!input.Capabilities.CredentialRotation || strings.TrimSpace(input.Capabilities.SDKVersion) == "" ||
|
||||
len(input.Capabilities.SDKVersion) > 80 || strings.TrimSpace(input.Capabilities.InputAudioFormat) == "" ||
|
||||
len(input.Capabilities.InputAudioFormat) > 80 || strings.TrimSpace(input.Capabilities.OutputAudioFormat) == "" ||
|
||||
len(input.Capabilities.OutputAudioFormat) > 80 || input.RequestedGoal != "open_conversation" {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "设备时间、配置版本或硬件智能体能力不正确")
|
||||
return
|
||||
}
|
||||
validTransport := false
|
||||
for _, transport := range input.Capabilities.Transports {
|
||||
if transport != "high_quality" && transport != "low_load" {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "硬件智能体传输模式不受支持")
|
||||
return
|
||||
}
|
||||
validTransport = true
|
||||
}
|
||||
if !validTransport {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "设备必须声明至少一种支持的硬件智能体传输模式")
|
||||
return
|
||||
}
|
||||
response, replayed, err := service.CreateHardwareAgent(request.Context(), identity, key, RequestID(request.Context()), rawBody, input)
|
||||
if replayed {
|
||||
writer.Header().Set("Idempotency-Replayed", "true")
|
||||
}
|
||||
if err != nil {
|
||||
writeCreateConversationError(writer, request, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(writer, request, http.StatusCreated, response)
|
||||
})
|
||||
}
|
||||
|
||||
func HardwareAgentEventHandler(service HardwareAgentSessionService) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
identity, ok := deviceauth.FromContext(request.Context())
|
||||
if !ok {
|
||||
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
rawBody, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "INVALID_REQUEST_BODY", "请求正文无法读取")
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
||||
decoder.DisallowUnknownFields()
|
||||
var input orchestration.HardwareAgentEventInput
|
||||
if err = decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil ||
|
||||
strings.TrimSpace(input.EventID) == "" || input.SequenceNo < 1 || input.OccurredAt.IsZero() ||
|
||||
input.OccurredAt.After(time.Now().UTC().Add(5*time.Minute)) || strings.TrimSpace(input.ProviderSessionRef) == "" ||
|
||||
strings.TrimSpace(input.SessionNonce) == "" {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "硬件智能体事件参数不正确")
|
||||
return
|
||||
}
|
||||
switch input.EventType {
|
||||
case "session_started", "session_ended", "session_failed", "session_updated":
|
||||
default:
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "不支持的硬件智能体事件类型")
|
||||
return
|
||||
}
|
||||
if input.EventType == "session_started" && (!input.DynamicConfigApplied || strings.TrimSpace(input.ProviderConnectionRef) == "" ||
|
||||
strings.TrimSpace(input.BotID) == "" || strings.TrimSpace(input.Transport) == "" || strings.TrimSpace(input.ConfigDigest) == "") {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "会话启动前必须证明动态人设配置已生效")
|
||||
return
|
||||
}
|
||||
response, err := service.ProcessHardwareAgentEvent(request.Context(), identity, request.PathValue("id"), rawBody, input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, orchestration.ErrConversationNotFound):
|
||||
WriteError(writer, request, http.StatusNotFound, "CONVERSATION_NOT_FOUND", "通话不存在或不属于当前设备")
|
||||
case errors.Is(err, orchestration.ErrHardwareSessionProof):
|
||||
WriteError(writer, request, http.StatusUnauthorized, "HARDWARE_SESSION_PROOF_INVALID", "硬件智能体会话凭证无效")
|
||||
case errors.Is(err, orchestration.ErrHardwareEventConflict), errors.Is(err, orchestration.ErrConversationStateConflict):
|
||||
WriteError(writer, request, http.StatusConflict, "HARDWARE_AGENT_EVENT_CONFLICT", "硬件智能体事件与当前会话状态冲突")
|
||||
default:
|
||||
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "HARDWARE_AGENT_EVENT_UNAVAILABLE", Message: "暂时无法处理硬件智能体事件", Retryable: true})
|
||||
}
|
||||
return
|
||||
}
|
||||
WriteJSON(writer, request, http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/hardwareprovision"
|
||||
)
|
||||
|
||||
type fakeHardwareProvisioner struct {
|
||||
response hardwareprovision.ProvisionResponse
|
||||
err error
|
||||
input hardwareprovision.ProvisionInput
|
||||
}
|
||||
|
||||
func (f *fakeHardwareProvisioner) Provision(_ context.Context, _ deviceauth.Identity, _ string, input hardwareprovision.ProvisionInput) (hardwareprovision.ProvisionResponse, error) {
|
||||
f.input = input
|
||||
return f.response, f.err
|
||||
}
|
||||
|
||||
func TestProvisionHardwareAgentCredentialHandlerReturnsNoStoreCredential(t *testing.T) {
|
||||
service := &fakeHardwareProvisioner{response: hardwareprovision.ProvisionResponse{
|
||||
InstanceID: "instance", ProductKey: "product", DeviceName: "device", HardwareID: "hardware",
|
||||
DeviceSecret: "device-secret", CredentialRef: "credential-ref", CredentialVersion: 1,
|
||||
CredentialSource: "platform_dynamic_registration", IssuedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/hardware-agent-credentials:provision", strings.NewReader(`{"hardware_id":"hardware"}`))
|
||||
request.Header.Set("Idempotency-Key", "provision-request-123456")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{ID: "internal", PublicID: "public", TenantID: "tenant", Status: "registered"}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
ProvisionHardwareAgentCredentialHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("Pragma") != "no-cache" {
|
||||
t.Fatalf("credential response was cacheable: headers=%v", recorder.Header())
|
||||
}
|
||||
var response struct {
|
||||
Data hardwareprovision.ProvisionResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Data.DeviceSecret != "device-secret" || service.input.HardwareID != "hardware" {
|
||||
t.Fatalf("unexpected response/input: response=%+v input=%+v", response.Data, service.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionHardwareAgentCredentialHandlerMapsUnavailable(t *testing.T) {
|
||||
service := &fakeHardwareProvisioner{err: hardwareprovision.ErrUnavailable}
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/hardware-agent-credentials:provision", strings.NewReader(`{"hardware_id":"hardware"}`))
|
||||
request.Header.Set("Idempotency-Key", "provision-request-123456")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
ProvisionHardwareAgentCredentialHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), "HARDWARE_AGENT_PROVISIONING_UNAVAILABLE") {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionHardwareAgentCredentialHandlerRejectsHardwareMismatch(t *testing.T) {
|
||||
service := &fakeHardwareProvisioner{err: hardwareprovision.ErrHardwareMismatch}
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/hardware-agent-credentials:provision", strings.NewReader(`{"hardware_id":"hardware"}`))
|
||||
request.Header.Set("Idempotency-Key", "provision-request-123456")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
ProvisionHardwareAgentCredentialHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "HARDWARE_AGENT_HARDWARE_MISMATCH") {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -49,12 +49,17 @@ func CreateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
HardwareRevision string `json:"hardware_revision"`
|
||||
MinimumSecurityVersion string `json:"minimum_security_version"`
|
||||
MinimumFirmwareVersion string `json:"minimum_firmware_version"`
|
||||
HouseholdID string `json:"household_id"`
|
||||
SubjectID string `json:"subject_id"`
|
||||
Model string `json:"model"`
|
||||
HardwareRevision string `json:"hardware_revision"`
|
||||
MinimumSecurityVersion string `json:"minimum_security_version"`
|
||||
MinimumFirmwareVersion string `json:"minimum_firmware_version"`
|
||||
HouseholdID string `json:"household_id"`
|
||||
SubjectID string `json:"subject_id"`
|
||||
VoiceRuntime string `json:"voice_runtime"`
|
||||
HardwareAgentBotID string `json:"hardware_agent_bot_id"`
|
||||
HardwareAgentTransport string `json:"hardware_agent_transport"`
|
||||
HardwareAgentMinimumSDKVersion string `json:"hardware_agent_minimum_sdk_version"`
|
||||
InitialActivationState string `json:"activation_state"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &body, "设备登记参数不正确") {
|
||||
return
|
||||
@@ -66,6 +71,9 @@ func CreateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
result, err := service.Create(r.Context(), principal, tenantID, RequestID(r.Context()), opsdevice.CreateInput{
|
||||
Model: body.Model, HardwareRevision: body.HardwareRevision, MinimumSecurityVersion: body.MinimumSecurityVersion,
|
||||
MinimumFirmwareVersion: body.MinimumFirmwareVersion, HouseholdID: body.HouseholdID, SubjectID: body.SubjectID,
|
||||
VoiceRuntime: body.VoiceRuntime, HardwareAgentBotID: body.HardwareAgentBotID, HardwareAgentTransport: body.HardwareAgentTransport,
|
||||
HardwareAgentMinimumSDKVersion: body.HardwareAgentMinimumSDKVersion,
|
||||
InitialActivationState: body.InitialActivationState,
|
||||
})
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
@@ -77,6 +85,57 @@ func CreateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func TransitionOpsDeviceActivationHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "设备编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
TargetState string `json:"target_state"`
|
||||
Reason string `json:"reason"`
|
||||
ExpectedRevision int64 `json:"expected_revision"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &body, "激活状态迁移参数不正确") {
|
||||
return
|
||||
}
|
||||
item, err := service.TransitionActivation(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), opsdevice.ActivationTransitionInput{
|
||||
TargetState: body.TargetState, Reason: body.Reason, ExpectedRevision: body.ExpectedRevision,
|
||||
})
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func ListOpsDeviceActivationTransitionsHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "设备编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
items, err := service.ListActivationTransitions(r.Context(), principal, tenantID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
@@ -93,13 +152,17 @@ func UpdateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Model *string `json:"model"`
|
||||
HardwareRevision *string `json:"hardware_revision"`
|
||||
MinimumSecurityVersion *string `json:"minimum_security_version"`
|
||||
MinimumFirmwareVersion *string `json:"minimum_firmware_version"`
|
||||
Status *string `json:"status"`
|
||||
HouseholdID *string `json:"household_id"`
|
||||
SubjectID *string `json:"subject_id"`
|
||||
Model *string `json:"model"`
|
||||
HardwareRevision *string `json:"hardware_revision"`
|
||||
MinimumSecurityVersion *string `json:"minimum_security_version"`
|
||||
MinimumFirmwareVersion *string `json:"minimum_firmware_version"`
|
||||
Status *string `json:"status"`
|
||||
HouseholdID *string `json:"household_id"`
|
||||
SubjectID *string `json:"subject_id"`
|
||||
VoiceRuntime *string `json:"voice_runtime"`
|
||||
HardwareAgentBotID *string `json:"hardware_agent_bot_id"`
|
||||
HardwareAgentTransport *string `json:"hardware_agent_transport"`
|
||||
HardwareAgentMinimumSDKVersion *string `json:"hardware_agent_minimum_sdk_version"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &body, "设备修改参数不正确") {
|
||||
return
|
||||
@@ -111,6 +174,8 @@ func UpdateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
item, err := service.Update(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), expected, opsdevice.UpdateInput{
|
||||
Model: body.Model, HardwareRevision: body.HardwareRevision, MinimumSecurityVersion: body.MinimumSecurityVersion,
|
||||
MinimumFirmwareVersion: body.MinimumFirmwareVersion, Status: body.Status, HouseholdID: body.HouseholdID, SubjectID: body.SubjectID,
|
||||
VoiceRuntime: body.VoiceRuntime, HardwareAgentBotID: body.HardwareAgentBotID, HardwareAgentTransport: body.HardwareAgentTransport,
|
||||
HardwareAgentMinimumSDKVersion: body.HardwareAgentMinimumSDKVersion,
|
||||
})
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
@@ -213,6 +278,10 @@ func writeOpsDeviceError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
WriteError(w, r, http.StatusPreconditionFailed, "VERSION_CONFLICT", "设备信息已变化,请刷新后重试")
|
||||
case errors.Is(err, opsdevice.ErrClaimUnavailable):
|
||||
WriteError(w, r, http.StatusConflict, "DEVICE_CLAIM_UNAVAILABLE", "只有已登记且尚未绑定的设备可以生成绑定码")
|
||||
case errors.Is(err, opsdevice.ErrActivationConflict):
|
||||
WriteError(w, r, http.StatusConflict, "DEVICE_ACTIVATION_CONFLICT", "设备激活状态已变化或不允许执行该迁移")
|
||||
case errors.Is(err, opsdevice.ErrActivationNotReady):
|
||||
WriteError(w, r, http.StatusConflict, "DEVICE_ACTIVATION_NOT_READY", "设备绑定或运行凭据尚未就绪,不能进入 ACTIVE")
|
||||
default:
|
||||
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "设备管理操作失败")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/saas"
|
||||
)
|
||||
|
||||
func ListOpsFirmwareSigningKeysHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := service.ListOpsFirmwareSigningKeys(r.Context(), principal, tenantID)
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func RegisterOpsFirmwareSigningKeyHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
KeyID string `json:"key_id"`
|
||||
PublicKey string `json:"public_key_base64"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &input, "固件签名密钥参数不正确") {
|
||||
return
|
||||
}
|
||||
input.KeyID, input.PublicKey = strings.TrimSpace(input.KeyID), strings.TrimSpace(input.PublicKey)
|
||||
decoded, decodeErr := base64.StdEncoding.DecodeString(input.PublicKey)
|
||||
if input.KeyID == "" || len([]rune(input.KeyID)) > 120 || decodeErr != nil || len(decoded) != 32 || base64.StdEncoding.EncodeToString(decoded) != input.PublicKey {
|
||||
writeValidation(w, r, "固件签名密钥参数不正确", FieldError{Field: "key", Message: "密钥编号和标准Base64编码的32字节Ed25519公钥均为必填"})
|
||||
return
|
||||
}
|
||||
if err := service.RegisterOpsFirmwareSigningKey(r.Context(), principal, tenantID, RequestID(r.Context()), input.KeyID, input.PublicKey); err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusCreated, map[string]any{"key_id": input.KeyID, "status": "ACTIVE"})
|
||||
})
|
||||
}
|
||||
|
||||
func RevokeOpsFirmwareSigningKeyHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
KeyID string `json:"key_id"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &input, "固件签名密钥吊销参数不正确") {
|
||||
return
|
||||
}
|
||||
input.KeyID = strings.TrimSpace(input.KeyID)
|
||||
if input.KeyID == "" || len([]rune(input.KeyID)) > 120 {
|
||||
writeValidation(w, r, "固件签名密钥吊销参数不正确", FieldError{Field: "key_id", Message: "必须为1至120个字符"})
|
||||
return
|
||||
}
|
||||
item, err := service.RevokeOpsFirmwareSigningKey(r.Context(), principal, tenantID, RequestID(r.Context()), input.KeyID)
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func ListOpsFirmwareReleasesHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
status := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("status")))
|
||||
if status != "" && status != "UPLOADING" && status != "READY" && status != "PAUSED" && status != "RETIRED" {
|
||||
writeValidation(w, r, "固件查询参数不正确", FieldError{Field: "status", Message: "必须为UPLOADING、READY、PAUSED或RETIRED"})
|
||||
return
|
||||
}
|
||||
items, err := service.ListOpsFirmwareReleases(r.Context(), principal, tenantID, strings.TrimSpace(r.URL.Query().Get("model")), status)
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func PrepareOpsFirmwareReleaseHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Model string `json:"model"`
|
||||
HardwareRevision string `json:"hardware_revision"`
|
||||
Version string `json:"version"`
|
||||
ExpectedSize int64 `json:"expected_size"`
|
||||
ExpectedDigest string `json:"expected_digest"`
|
||||
Signature string `json:"signature"`
|
||||
SigningKeyID string `json:"signing_key_id"`
|
||||
MinimumSecurityVersion string `json:"minimum_security_version"`
|
||||
ReleaseNotes string `json:"release_notes"`
|
||||
ReleaseChannel string `json:"release_channel"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &input, "固件版本参数不正确") {
|
||||
return
|
||||
}
|
||||
input.Model, input.HardwareRevision, input.Version = strings.TrimSpace(input.Model), strings.TrimSpace(input.HardwareRevision), strings.TrimSpace(input.Version)
|
||||
input.ExpectedDigest, input.Signature, input.SigningKeyID = strings.ToLower(strings.TrimSpace(input.ExpectedDigest)), strings.TrimSpace(input.Signature), strings.TrimSpace(input.SigningKeyID)
|
||||
input.ReleaseChannel = strings.ToUpper(strings.TrimSpace(input.ReleaseChannel))
|
||||
if input.ReleaseChannel == "" {
|
||||
input.ReleaseChannel = "STABLE"
|
||||
}
|
||||
if input.Model == "" || len([]rune(input.Model)) > 120 || len([]rune(input.HardwareRevision)) > 80 || input.Version == "" || len([]rune(input.Version)) > 80 || input.ExpectedSize < 1 || input.ExpectedSize > 268435456 || !sha256Pattern.MatchString(input.ExpectedDigest) || input.Signature == "" || input.SigningKeyID == "" || (input.ReleaseChannel != "STABLE" && input.ReleaseChannel != "BETA" && input.ReleaseChannel != "INTERNAL") || len([]rune(input.ReleaseNotes)) > 2000 {
|
||||
writeValidation(w, r, "固件版本参数不正确", FieldError{Field: "firmware", Message: "型号、版本、1至256MB大小、SHA-256、签名、密钥编号和合法发布通道均为必填"})
|
||||
return
|
||||
}
|
||||
item, err := service.PrepareOpsFirmwareRelease(r.Context(), principal, tenantID, RequestID(r.Context()), saas.PrepareFirmwareInput{Model: input.Model, HardwareRevision: input.HardwareRevision, Version: input.Version, ExpectedSize: input.ExpectedSize, ExpectedDigest: input.ExpectedDigest, Signature: input.Signature, SigningKeyID: input.SigningKeyID, MinimumSecurityVersion: strings.TrimSpace(input.MinimumSecurityVersion), ReleaseNotes: strings.TrimSpace(input.ReleaseNotes), ReleaseChannel: input.ReleaseChannel, Mandatory: input.Mandatory})
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusCreated, item)
|
||||
})
|
||||
}
|
||||
|
||||
func OpsFirmwareReleaseActionHandler(service *saas.Service, action string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "固件版本编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
var item saas.FirmwareRelease
|
||||
var err error
|
||||
switch action {
|
||||
case "finalize":
|
||||
item, err = service.FinalizeOpsFirmwareRelease(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
case "upload-session":
|
||||
item, err = service.RenewOpsFirmwareUpload(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
case "pause":
|
||||
item, err = service.PauseOpsFirmwareRelease(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
case "resume":
|
||||
item, err = service.ResumeOpsFirmwareRelease(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
case "retire":
|
||||
item, err = service.RetireOpsFirmwareRelease(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
default:
|
||||
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "请求的操作不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func DownloadOpsFirmwareReleaseHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "固件版本编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
item, err := service.OpsFirmwareDownload(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func ListOpsFirmwareRolloutsHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := service.ListOpsFirmwareRollouts(r.Context(), principal, tenantID)
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func StartOpsFirmwareRolloutHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
ReleaseID string `json:"release_id"`
|
||||
Name string `json:"name"`
|
||||
TargetPercentage int `json:"target_percentage"`
|
||||
DeviceIDs []string `json:"device_ids"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &input, "OTA升级批次参数不正确") {
|
||||
return
|
||||
}
|
||||
input.ReleaseID, input.Name = strings.TrimSpace(input.ReleaseID), strings.TrimSpace(input.Name)
|
||||
validDevices := len(input.DeviceIDs) > 0 && len(input.DeviceIDs) <= 10000
|
||||
for _, deviceID := range input.DeviceIDs {
|
||||
validDevices = validDevices && validUUID(deviceID)
|
||||
}
|
||||
if !validUUID(input.ReleaseID) || input.Name == "" || len([]rune(input.Name)) > 160 || input.TargetPercentage < 1 || input.TargetPercentage > 100 || !validDevices {
|
||||
writeValidation(w, r, "OTA升级批次参数不正确", FieldError{Field: "rollout", Message: "版本、1至160字符批次名称、1至100灰度比例和有效目标设备均为必填"})
|
||||
return
|
||||
}
|
||||
item, err := service.StartOpsFirmwareRollout(r.Context(), principal, tenantID, input.ReleaseID, RequestID(r.Context()), input.Name, input.TargetPercentage, input.DeviceIDs)
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusAccepted, item)
|
||||
})
|
||||
}
|
||||
|
||||
func GetOpsFirmwareRolloutHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "OTA升级批次编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
item, err := service.GetOpsFirmwareRollout(r.Context(), principal, tenantID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func PauseOpsFirmwareRolloutHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "OTA升级批次编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
item, err := service.PauseOpsFirmwareRollout(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func opsFirmwarePrincipal(w http.ResponseWriter, r *http.Request) (identity.Principal, string, bool) {
|
||||
principal, ok := webPrincipal(w, r)
|
||||
if !ok {
|
||||
return identity.Principal{}, "", false
|
||||
}
|
||||
tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
|
||||
if !validUUID(tenantID) {
|
||||
writeValidation(w, r, "运维固件请求参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须提供有效的客户组织UUID"})
|
||||
return identity.Principal{}, "", false
|
||||
}
|
||||
return principal, tenantID, true
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"data-terminal-cloud/internal/identity"
|
||||
)
|
||||
|
||||
func TestOpsFirmwareHandlersRejectInvalidBoundariesBeforeService(t *testing.T) {
|
||||
principal := identity.Principal{Issuer: "https://issuer.example", Subject: "ops-user"}
|
||||
tenantID := "10000000-0000-0000-0000-000000000010"
|
||||
tests := []struct {
|
||||
name string
|
||||
handler http.Handler
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
tenant string
|
||||
}{
|
||||
{name: "tenant required", handler: ListOpsFirmwareReleasesHandler(nil), method: http.MethodGet, path: "/api/v1/ops/firmware-releases"},
|
||||
{name: "status allowlist", handler: ListOpsFirmwareReleasesHandler(nil), method: http.MethodGet, path: "/api/v1/ops/firmware-releases?status=deleted", tenant: tenantID},
|
||||
{name: "public key exact length", handler: RegisterOpsFirmwareSigningKeyHandler(nil), method: http.MethodPost, path: "/api/v1/ops/firmware-signing-keys", body: `{"key_id":"ops-key","public_key_base64":"YQ=="}`, tenant: tenantID},
|
||||
{name: "release id uuid", handler: OpsFirmwareReleaseActionHandler(nil, "pause"), method: http.MethodPost, path: "/api/v1/ops/firmware-releases/not-a-uuid/pause", tenant: tenantID},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
|
||||
request = request.WithContext(identity.WithPrincipal(request.Context(), principal))
|
||||
if test.tenant != "" {
|
||||
request.Header.Set("X-Tenant-Id", test.tenant)
|
||||
}
|
||||
if strings.Contains(test.path, "not-a-uuid") {
|
||||
request.SetPathValue("id", "not-a-uuid")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
test.handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/concurrency"
|
||||
"data-terminal-cloud/internal/hardwarelicense"
|
||||
)
|
||||
|
||||
func ListOpsHardwareLicensesHandler(service *hardwarelicense.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := service.List(r.Context(), principal, tenantID)
|
||||
if err != nil {
|
||||
writeHardwareLicenseError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func ImportOpsHardwareLicenseHandler(service *hardwarelicense.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
LicenseRef string `json:"license_ref"`
|
||||
License string `json:"license"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
ProductKey string `json:"product_key"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &body, "License导入参数不正确") {
|
||||
return
|
||||
}
|
||||
item, err := service.Import(r.Context(), principal, tenantID, RequestID(r.Context()), hardwarelicense.ImportInput{LicenseRef: body.LicenseRef, Payload: body.License, InstanceID: body.InstanceID, ProductKey: body.ProductKey, ExpiresAt: body.ExpiresAt})
|
||||
if err != nil {
|
||||
writeHardwareLicenseError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
|
||||
WriteJSON(w, r, http.StatusCreated, item)
|
||||
})
|
||||
}
|
||||
|
||||
func TransitionOpsHardwareLicenseHandler(service *hardwarelicense.Service, action string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "License编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
expected, err := concurrency.ParseIfMatch(r)
|
||||
if err != nil {
|
||||
writeETagError(w, r, err)
|
||||
return
|
||||
}
|
||||
var item hardwarelicense.Item
|
||||
if action == "release" {
|
||||
item, err = service.Release(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), expected)
|
||||
} else {
|
||||
item, err = service.Revoke(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), expected)
|
||||
}
|
||||
if err != nil {
|
||||
writeHardwareLicenseError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func writeHardwareLicenseError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, hardwarelicense.ErrInvalid):
|
||||
writeValidation(w, r, "License参数不正确", FieldError{Field: "license", Message: "引用、密文、产品或到期时间不正确"})
|
||||
case errors.Is(err, hardwarelicense.ErrAccessDenied):
|
||||
WriteError(w, r, http.StatusForbidden, "OPS_ACCESS_DENIED", "仅独立运维账号可以管理License")
|
||||
case errors.Is(err, hardwarelicense.ErrNotFound):
|
||||
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "License不存在")
|
||||
case errors.Is(err, hardwarelicense.ErrVersionConflict):
|
||||
WriteError(w, r, http.StatusPreconditionFailed, "VERSION_CONFLICT", "License信息已变化,请刷新后重试")
|
||||
case errors.Is(err, hardwarelicense.ErrAssignment):
|
||||
WriteError(w, r, http.StatusConflict, "HARDWARE_LICENSE_STATE_CONFLICT", "License当前状态不允许执行该操作")
|
||||
default:
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
WriteError(w, r, http.StatusConflict, "HARDWARE_LICENSE_ALREADY_EXISTS", "License引用已经存在")
|
||||
return
|
||||
}
|
||||
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "License管理操作失败")
|
||||
}
|
||||
}
|
||||
+86
-44
@@ -24,40 +24,45 @@ import (
|
||||
)
|
||||
|
||||
type RouterOptions struct {
|
||||
ServiceName string
|
||||
Logger *slog.Logger
|
||||
RateLimiter RateLimiter
|
||||
Readiness *health.Checker
|
||||
TokenVerifier *identity.OIDCVerifier
|
||||
IdentityRepository *identity.Repository
|
||||
SaaSService *saas.Service
|
||||
DeviceVerifier DeviceRequestVerifier
|
||||
DeviceService *device.Repository
|
||||
DeviceTelemetrySubmitter DeviceTelemetrySubmitter
|
||||
MediaService *media.Service
|
||||
WebEventService *webevents.Service
|
||||
SharingService *sharing.Service
|
||||
OpsConfigService *opsconfig.Service
|
||||
BillingService *billing.Service
|
||||
AlertService *alerts.Service
|
||||
DiagnosticService *diagnostics.Service
|
||||
OpsAccessService *opsaccess.Service
|
||||
OpsDeviceService *opsdevice.Service
|
||||
ContentModelService *contentmodel.Service
|
||||
ConversationCreator ConversationCreator
|
||||
ConversationActivator ConversationActivator
|
||||
DeviceConversationStateReader DeviceConversationStateReader
|
||||
ConversationHeartbeater ConversationHeartbeater
|
||||
ConversationStopper ConversationStopper
|
||||
ConversationTranscriptSubmitter ConversationTranscriptSubmitter
|
||||
ProviderCallbackSecret []byte
|
||||
ProviderCallbackSkew time.Duration
|
||||
ProviderCallbackProcessor ProviderCallbackProcessor
|
||||
PlatformHealthCallbackSecret []byte
|
||||
PlatformHealthCallbackSkew time.Duration
|
||||
PlatformHealthProcessor PlatformHealthProcessor
|
||||
MemoryToolService *memorytool.Service
|
||||
MemoryToolSigner *memorytool.TokenSigner
|
||||
ServiceName string
|
||||
Logger *slog.Logger
|
||||
RateLimiter RateLimiter
|
||||
Readiness *health.Checker
|
||||
TokenVerifier *identity.OIDCVerifier
|
||||
IdentityRepository *identity.Repository
|
||||
SaaSService *saas.Service
|
||||
DeviceVerifier DeviceRequestVerifier
|
||||
DeviceService *device.Repository
|
||||
DeviceTelemetrySubmitter DeviceTelemetrySubmitter
|
||||
MediaService *media.Service
|
||||
WebEventService *webevents.Service
|
||||
SharingService *sharing.Service
|
||||
OpsConfigService *opsconfig.Service
|
||||
BillingService *billing.Service
|
||||
AlertService *alerts.Service
|
||||
DiagnosticService *diagnostics.Service
|
||||
OpsAccessService *opsaccess.Service
|
||||
OpsDeviceService *opsdevice.Service
|
||||
ContentModelService *contentmodel.Service
|
||||
ConversationCreator ConversationCreator
|
||||
ConversationActivator ConversationActivator
|
||||
DeviceConversationStateReader DeviceConversationStateReader
|
||||
ConversationHeartbeater ConversationHeartbeater
|
||||
ConversationStopper ConversationStopper
|
||||
ConversationTranscriptSubmitter ConversationTranscriptSubmitter
|
||||
HardwareAgentSessionService HardwareAgentSessionService
|
||||
HardwareAgentProvisioningService HardwareAgentProvisioningService
|
||||
DeviceFactoryResetService DeviceFactoryResetService
|
||||
ProviderCallbackSecret []byte
|
||||
ProviderCallbackSkew time.Duration
|
||||
ProviderCallbackProcessor ProviderCallbackProcessor
|
||||
NativeSubtitleSecret []byte
|
||||
NativeSubtitleProcessor NativeSubtitleProcessor
|
||||
PlatformHealthCallbackSecret []byte
|
||||
PlatformHealthCallbackSkew time.Duration
|
||||
PlatformHealthProcessor PlatformHealthProcessor
|
||||
MemoryToolService *memorytool.Service
|
||||
MemoryToolSigner *memorytool.TokenSigner
|
||||
}
|
||||
|
||||
func NewRouter(options RouterOptions) http.Handler {
|
||||
@@ -165,6 +170,8 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("DELETE /api/v1/ops/devices/{id}", authenticated(DeleteOpsDeviceHandler(options.OpsDeviceService)))
|
||||
mux.Handle("POST /api/v1/ops/devices/{id}/credentials/rotate", authenticated(RotateOpsDeviceCredentialHandler(options.OpsDeviceService)))
|
||||
mux.Handle("POST /api/v1/ops/devices/{id}/claims", authenticated(IssueOpsDeviceClaimHandler(options.OpsDeviceService)))
|
||||
mux.Handle("POST /api/v1/ops/devices/{id}/activation-transitions", authenticated(TransitionOpsDeviceActivationHandler(options.OpsDeviceService)))
|
||||
mux.Handle("GET /api/v1/ops/devices/{id}/activation-transitions", authenticated(ListOpsDeviceActivationTransitionsHandler(options.OpsDeviceService)))
|
||||
}
|
||||
if options.ContentModelService != nil {
|
||||
mux.Handle("GET /api/v1/content-models", authenticated(ListContentModelsHandler(options.ContentModelService)))
|
||||
@@ -223,6 +230,21 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("GET /api/v1/firmware-rollouts/{id}", authenticated(GetFirmwareRolloutHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/firmware-rollouts/{id}/pause", authenticated(PauseFirmwareRolloutHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/firmware-rollouts/{id}/rollback", authenticated(RollbackFirmwareRolloutHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/ops/firmware-signing-keys", authenticated(ListOpsFirmwareSigningKeysHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/ops/firmware-signing-keys", authenticated(RegisterOpsFirmwareSigningKeyHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/ops/firmware-signing-keys/revoke", authenticated(RevokeOpsFirmwareSigningKeyHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/ops/firmware-releases", authenticated(ListOpsFirmwareReleasesHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/ops/firmware-releases", authenticated(PrepareOpsFirmwareReleaseHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/ops/firmware-releases/{id}/upload-session", authenticated(OpsFirmwareReleaseActionHandler(options.SaaSService, "upload-session")))
|
||||
mux.Handle("POST /api/v1/ops/firmware-releases/{id}/finalize", authenticated(OpsFirmwareReleaseActionHandler(options.SaaSService, "finalize")))
|
||||
mux.Handle("POST /api/v1/ops/firmware-releases/{id}/pause", authenticated(OpsFirmwareReleaseActionHandler(options.SaaSService, "pause")))
|
||||
mux.Handle("POST /api/v1/ops/firmware-releases/{id}/resume", authenticated(OpsFirmwareReleaseActionHandler(options.SaaSService, "resume")))
|
||||
mux.Handle("POST /api/v1/ops/firmware-releases/{id}/retire", authenticated(OpsFirmwareReleaseActionHandler(options.SaaSService, "retire")))
|
||||
mux.Handle("GET /api/v1/ops/firmware-releases/{id}/download", authenticated(DownloadOpsFirmwareReleaseHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/ops/firmware-rollouts", authenticated(ListOpsFirmwareRolloutsHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/ops/firmware-rollouts", authenticated(StartOpsFirmwareRolloutHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/ops/firmware-rollouts/{id}", authenticated(GetOpsFirmwareRolloutHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/ops/firmware-rollouts/{id}/pause", authenticated(PauseOpsFirmwareRolloutHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/subjects/{id}/conversations", authenticated(ListSubjectConversationsHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/conversations/{id}", authenticated(GetConversationHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/conversations/{id}/usage", authenticated(GetConversationUsageHandler(options.SaaSService)))
|
||||
@@ -277,6 +299,12 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
deviceAuthenticated := func(handler http.Handler) http.Handler {
|
||||
return DeviceAuth(options.DeviceVerifier, DeviceRateLimit(logger, options.RateLimiter, handler))
|
||||
}
|
||||
activeDevice := func(handler http.Handler) http.Handler {
|
||||
return deviceAuthenticated(RequireDeviceActivation([]string{"ACTIVE"}, handler))
|
||||
}
|
||||
provisionableDevice := func(handler http.Handler) http.Handler {
|
||||
return deviceAuthenticated(RequireDeviceActivation([]string{"BOUND", "PROVISIONING", "ACTIVE"}, handler))
|
||||
}
|
||||
mux.Handle("GET /device/v2/bootstrap", deviceAuthenticated(DeviceBootstrapHandler(options.DeviceService)))
|
||||
mux.Handle("GET /device/v2/config", deviceAuthenticated(DeviceConfigHandler(options.DeviceService)))
|
||||
mux.Handle("GET /device/v2/commands", deviceAuthenticated(PullDeviceCommandsHandler(options.DeviceService)))
|
||||
@@ -285,32 +313,46 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("POST /device/v2/telemetry:batch", deviceAuthenticated(SubmitDeviceTelemetryHandler(options.DeviceTelemetrySubmitter)))
|
||||
}
|
||||
mux.Handle("GET /device/v2/firmware-releases/{id}/download", deviceAuthenticated(DeviceFirmwareDownloadHandler(options.SaaSService)))
|
||||
if options.DeviceFactoryResetService != nil {
|
||||
mux.Handle("POST /device/v3/factory-reset", deviceAuthenticated(DeviceFactoryResetHandler(options.DeviceFactoryResetService)))
|
||||
}
|
||||
if options.MediaService != nil {
|
||||
mux.Handle("POST /device/v2/conversations/{id}/upload-batches", deviceAuthenticated(PrepareMediaUploadBatchHandler(options.MediaService)))
|
||||
mux.Handle("POST /device/v2/upload-batches/{id}/complete", deviceAuthenticated(CompleteMediaUploadBatchHandler(options.MediaService)))
|
||||
mux.Handle("GET /device/v2/upload-batches/{id}", deviceAuthenticated(GetDeviceMediaUploadBatchHandler(options.MediaService)))
|
||||
mux.Handle("POST /device/v2/media-segments/{id}/local-delete-ack", deviceAuthenticated(AcknowledgeLocalMediaDeleteHandler(options.MediaService)))
|
||||
mux.Handle("POST /device/v2/conversations/{id}/upload-batches", activeDevice(PrepareMediaUploadBatchHandler(options.MediaService)))
|
||||
mux.Handle("POST /device/v2/upload-batches/{id}/complete", activeDevice(CompleteMediaUploadBatchHandler(options.MediaService)))
|
||||
mux.Handle("GET /device/v2/upload-batches/{id}", activeDevice(GetDeviceMediaUploadBatchHandler(options.MediaService)))
|
||||
mux.Handle("POST /device/v2/media-segments/{id}/local-delete-ack", activeDevice(AcknowledgeLocalMediaDeleteHandler(options.MediaService)))
|
||||
}
|
||||
if options.ConversationCreator != nil {
|
||||
mux.Handle("POST /device/v2/conversations", deviceAuthenticated(CreateConversationHandler(options.ConversationCreator)))
|
||||
mux.Handle("POST /device/v2/conversations", activeDevice(CreateConversationHandler(options.ConversationCreator)))
|
||||
}
|
||||
if options.ConversationActivator != nil {
|
||||
mux.Handle("POST /device/v2/conversations/{id}/activate", deviceAuthenticated(ActivateConversationHandler(options.ConversationActivator)))
|
||||
mux.Handle("POST /device/v2/conversations/{id}/activate", activeDevice(ActivateConversationHandler(options.ConversationActivator)))
|
||||
}
|
||||
if options.DeviceConversationStateReader != nil {
|
||||
mux.Handle("GET /device/v2/conversations/{id}", deviceAuthenticated(GetDeviceConversationStateHandler(options.DeviceConversationStateReader)))
|
||||
mux.Handle("GET /device/v2/conversations/{id}", activeDevice(GetDeviceConversationStateHandler(options.DeviceConversationStateReader)))
|
||||
}
|
||||
if options.ConversationHeartbeater != nil {
|
||||
mux.Handle("POST /device/v2/conversations/{id}/heartbeat", deviceAuthenticated(HeartbeatConversationHandler(options.ConversationHeartbeater)))
|
||||
mux.Handle("POST /device/v2/conversations/{id}/heartbeat", activeDevice(HeartbeatConversationHandler(options.ConversationHeartbeater)))
|
||||
}
|
||||
if options.ConversationStopper != nil {
|
||||
mux.Handle("POST /device/v2/conversations/{id}/stop", deviceAuthenticated(StopConversationHandler(options.ConversationStopper)))
|
||||
mux.Handle("POST /device/v2/conversations/{id}/stop", activeDevice(StopConversationHandler(options.ConversationStopper)))
|
||||
}
|
||||
if options.ConversationTranscriptSubmitter != nil {
|
||||
mux.Handle("POST /device/v2/conversations/{id}/turns:batch", deviceAuthenticated(SubmitConversationTurnsHandler(options.ConversationTranscriptSubmitter)))
|
||||
mux.Handle("POST /device/v2/conversations/{id}/turns:batch", activeDevice(SubmitConversationTurnsHandler(options.ConversationTranscriptSubmitter)))
|
||||
}
|
||||
if options.HardwareAgentSessionService != nil {
|
||||
if options.HardwareAgentProvisioningService != nil {
|
||||
mux.Handle("POST /device/v3/hardware-agent-credentials:provision", provisionableDevice(ProvisionHardwareAgentCredentialHandler(options.HardwareAgentProvisioningService)))
|
||||
}
|
||||
mux.Handle("POST /device/v3/agent-sessions", activeDevice(CreateHardwareAgentSessionHandler(options.HardwareAgentSessionService)))
|
||||
mux.Handle("POST /device/v3/agent-sessions/{id}/events", activeDevice(HardwareAgentEventHandler(options.HardwareAgentSessionService)))
|
||||
mux.Handle("POST /device/v3/agent-sessions/{id}/turns:batch", activeDevice(SubmitConversationTurnsHandler(options.ConversationTranscriptSubmitter)))
|
||||
}
|
||||
}
|
||||
if options.ServiceName == "callbacks" && len(options.ProviderCallbackSecret) > 0 && options.ProviderCallbackProcessor != nil {
|
||||
if len(options.NativeSubtitleSecret) >= 32 && options.NativeSubtitleProcessor != nil {
|
||||
mux.Handle("POST /callbacks/v1/volcengine/subtitles", NativeSubtitleCallbackHandler(options.NativeSubtitleSecret, options.NativeSubtitleProcessor))
|
||||
}
|
||||
mux.Handle("POST /callbacks/v1/vertc/voice-chat", VoiceProviderCallbackHandler(
|
||||
options.ProviderCallbackSecret, options.ProviderCallbackSkew, options.ProviderCallbackProcessor,
|
||||
))
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"data-terminal-cloud/internal/orchestration"
|
||||
"data-terminal-cloud/internal/volcsubtitle"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type NativeSubtitleProcessor interface {
|
||||
ProcessNativeSubtitle(context.Context, string, volcsubtitle.Packet) (orchestration.NativeSubtitleResult, error)
|
||||
}
|
||||
|
||||
type NativeSubtitleDeviceProbe interface {
|
||||
ProbeNativeSubtitleDevice(context.Context, volcsubtitle.Packet) error
|
||||
}
|
||||
|
||||
func NativeSubtitleCallbackHandler(secret []byte, processor NativeSubtitleProcessor) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("conversation_id")
|
||||
var envelope volcsubtitle.Envelope
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 384*1024))
|
||||
if err != nil || json.Unmarshal(raw, &envelope) != nil {
|
||||
WriteError(w, r, 400, "INVALID_SUBTITLE", "字幕格式不正确")
|
||||
return
|
||||
}
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
if len(secret) < 32 || subtle.ConstantTimeCompare([]byte(envelope.Signature), secret) != 1 {
|
||||
WriteError(w, r, 401, "SUBTITLE_AUTHENTICATION_FAILED", "Subtitle authentication failed")
|
||||
return
|
||||
}
|
||||
packet, err := volcsubtitle.Decode(envelope.Message)
|
||||
if err != nil || packet.DeviceName == "" || packet.ProductKey == "" {
|
||||
WriteError(w, r, 400, "SUBTITLE_SESSION_REQUIRED", "Subtitle session identity missing")
|
||||
return
|
||||
}
|
||||
probe, ok := processor.(NativeSubtitleDeviceProbe)
|
||||
if !ok {
|
||||
WriteError(w, r, 503, "SUBTITLE_UNAVAILABLE", "Subtitle probe unavailable")
|
||||
return
|
||||
}
|
||||
if err = probe.ProbeNativeSubtitleDevice(r.Context(), packet); err != nil {
|
||||
status := 503
|
||||
if errors.Is(err, orchestration.ErrConversationNotFound) {
|
||||
status = 404
|
||||
}
|
||||
WriteError(w, r, status, "SUBTITLE_DEVICE_UNAVAILABLE", "Subtitle device unavailable")
|
||||
return
|
||||
}
|
||||
// Never associate a callback with the newest device session: delayed
|
||||
// callbacks from before reboot cannot be distinguished in this format.
|
||||
slog.InfoContext(r.Context(), "native hardware subtitle verified", "mode", "device_probe", "device_name", packet.DeviceName, "segments", len(packet.Data), "stored", false)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
expected := volcsubtitle.Signature(secret, id)
|
||||
if len(secret) < 32 || subtle.ConstantTimeCompare([]byte(envelope.Signature), []byte(expected)) != 1 {
|
||||
WriteError(w, r, 401, "SUBTITLE_AUTHENTICATION_FAILED", "字幕回调鉴权失败")
|
||||
return
|
||||
}
|
||||
packet, err := volcsubtitle.Decode(envelope.Message)
|
||||
if err != nil {
|
||||
WriteError(w, r, 400, "INVALID_SUBTITLE", "字幕格式不正确")
|
||||
return
|
||||
}
|
||||
result, err := processor.ProcessNativeSubtitle(r.Context(), id, packet)
|
||||
if err != nil {
|
||||
status, code := 503, "SUBTITLE_UNAVAILABLE"
|
||||
if errors.Is(err, orchestration.ErrConversationNotFound) {
|
||||
status, code = 404, "SUBTITLE_SESSION_NOT_FOUND"
|
||||
}
|
||||
if errors.Is(err, orchestration.ErrTranscriptStorageNotAllowed) {
|
||||
status, code = 403, "TRANSCRIPT_STORAGE_NOT_ALLOWED"
|
||||
}
|
||||
if errors.Is(err, orchestration.ErrNativeSubtitleSpeaker) {
|
||||
status, code = 422, "SUBTITLE_SPEAKER_UNKNOWN"
|
||||
}
|
||||
if errors.Is(err, orchestration.ErrHardwareEventConflict) {
|
||||
status, code = 409, "SUBTITLE_CONFLICT"
|
||||
}
|
||||
if errors.Is(err, orchestration.ErrConversationStateConflict) {
|
||||
status, code = 409, "SUBTITLE_SESSION_STATE"
|
||||
}
|
||||
slog.WarnContext(r.Context(), "native subtitle rejected", "conversation_id", id, "code", code)
|
||||
WriteError(w, r, status, code, "字幕回调暂未接收")
|
||||
return
|
||||
}
|
||||
slog.InfoContext(r.Context(), "native subtitle accepted", "conversation_id", id, "mode", result.Mode, "segments", len(packet.Data), "accepted", result.Accepted, "duplicates", result.Duplicates)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"data-terminal-cloud/internal/orchestration"
|
||||
"data-terminal-cloud/internal/volcsubtitle"
|
||||
)
|
||||
|
||||
type subtitleTestProcessor struct {
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *subtitleTestProcessor) ProbeNativeSubtitleDevice(_ context.Context, _ volcsubtitle.Packet) error {
|
||||
p.calls++
|
||||
return p.err
|
||||
}
|
||||
|
||||
func (p *subtitleTestProcessor) ProcessNativeSubtitle(_ context.Context, _ string, _ volcsubtitle.Packet) (orchestration.NativeSubtitleResult, error) {
|
||||
p.calls++
|
||||
return orchestration.NativeSubtitleResult{Mode: "probe"}, p.err
|
||||
}
|
||||
|
||||
func TestNativeSubtitleHTTPAuthenticationAndFraming(t *testing.T) {
|
||||
secret := []byte("test-native-subtitle-secret-32-bytes")
|
||||
id := "de4e5d8a-708e-4e9b-b565-9265a9b6873a"
|
||||
payload := []byte(`{"type":"subtitle","data":[{"userId":"test-device","roundId":0,"sequence":0,"definite":true,"text":"测试"}]}`)
|
||||
frame := append(make([]byte, 8), payload...)
|
||||
copy(frame, "subv")
|
||||
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
|
||||
for _, tc := range []struct {
|
||||
name, id, sig, message string
|
||||
status int
|
||||
calls int
|
||||
}{
|
||||
{"no Content-Type", id, volcsubtitle.Signature(secret, id), base64.StdEncoding.EncodeToString(frame), 200, 1},
|
||||
{"another conversation", id, volcsubtitle.Signature(secret, "other"), base64.StdEncoding.EncodeToString(frame), 401, 0},
|
||||
{"missing conversation", "", string(secret), base64.StdEncoding.EncodeToString(frame), 400, 0},
|
||||
{"invalid frame", id, volcsubtitle.Signature(secret, id), "bad", 400, 0},
|
||||
{"hardware device probe", "", string(secret), base64.StdEncoding.EncodeToString([]byte(`{"type":"subtitle","productKey":"product","deviceName":"device","data":[{"userId":"input","roundId":1,"sequence":0,"definite":true,"text":"test"}]}`)), 200, 1},
|
||||
{"hardware invalid signature", "", "wrong", base64.StdEncoding.EncodeToString([]byte(`{"type":"subtitle","productKey":"product","deviceName":"device","data":[{"userId":"input","roundId":1,"sequence":0,"definite":true,"text":"test"}]}`)), 401, 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := &subtitleTestProcessor{}
|
||||
body, _ := json.Marshal(volcsubtitle.Envelope{Message: tc.message, Signature: tc.sig})
|
||||
req := httptest.NewRequest("POST", "/callbacks/v1/volcengine/subtitles?conversation_id="+tc.id, strings.NewReader(string(body)))
|
||||
w := httptest.NewRecorder()
|
||||
NativeSubtitleCallbackHandler(secret, p).ServeHTTP(w, req)
|
||||
if w.Code != tc.status || p.calls != tc.calls {
|
||||
t.Fatalf("status=%d calls=%d body=%s", w.Code, p.calls, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/embeddingprovider"
|
||||
"data-terminal-cloud/internal/retrieval"
|
||||
"data-terminal-cloud/internal/vikingmemory"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -39,6 +40,7 @@ type Response struct {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
viking *vikingmemory.Service
|
||||
pool *pgxpool.Pool
|
||||
embedder embeddingprovider.Provider
|
||||
audit *audit.Repository
|
||||
@@ -46,7 +48,7 @@ type Service struct {
|
||||
}
|
||||
|
||||
type conversationScope struct {
|
||||
internalID, publicID, tenantID, subjectID, taskRef, engineMode, status string
|
||||
internalID, publicID, tenantID, subjectID, taskRef, engineMode, runtimeType, status string
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, embedder embeddingprovider.Provider, auditRepository *audit.Repository) (*Service, error) {
|
||||
@@ -56,6 +58,8 @@ func NewService(pool *pgxpool.Pool, embedder embeddingprovider.Provider, auditRe
|
||||
return &Service{pool: pool, embedder: embedder, audit: auditRepository, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfigureVikingMemory(memory *vikingmemory.Service) { s.viking = memory }
|
||||
|
||||
func (s *Service) Search(ctx context.Context, claims Claims, query string, limit int, requestID string) (Response, error) {
|
||||
started := s.now().UTC()
|
||||
query = strings.TrimSpace(query)
|
||||
@@ -68,6 +72,20 @@ func (s *Service) Search(ctx context.Context, claims Claims, query string, limit
|
||||
}
|
||||
digest := sha256.Sum256([]byte(query))
|
||||
queryDigest := hex.EncodeToString(digest[:])
|
||||
if s.viking != nil {
|
||||
searchContext, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
memories, searchErr := s.viking.Search(searchContext, scope.tenantID, scope.subjectID, query, limit)
|
||||
cancel()
|
||||
if searchErr == nil && len(memories) > 0 {
|
||||
items := make([]Result, 0, len(memories))
|
||||
for _, item := range memories {
|
||||
refs, _ := json.Marshal([]map[string]string{{"provider": "viking", "session_id": item.SessionID}})
|
||||
items = append(items, Result{MemoryID: item.ID, Content: item.Content, SourceRefs: refs, Relevance: item.Score})
|
||||
}
|
||||
s.recordCall(ctx, scope, queryDigest, len(items), "SUCCEEDED", "", started)
|
||||
return Response{Items: items}, nil
|
||||
}
|
||||
}
|
||||
embedding, err := s.embedder.Embed(ctx, query)
|
||||
if err != nil {
|
||||
s.recordCall(ctx, scope, queryDigest, 0, "FAILED", "EMBEDDING_UNAVAILABLE", started)
|
||||
@@ -126,16 +144,20 @@ func (s *Service) Search(ctx context.Context, claims Claims, query string, limit
|
||||
|
||||
func (s *Service) resolveScope(ctx context.Context, claims Claims) (conversationScope, error) {
|
||||
var scope conversationScope
|
||||
err := s.pool.QueryRow(ctx, `SELECT c.id::text,c.public_id::text,c.tenant_id::text,c.subject_id::text,rt.provider_task_ref,c.engine_mode,c.status
|
||||
FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id WHERE c.public_id=$1::uuid`, claims.ConversationID).
|
||||
Scan(&scope.internalID, &scope.publicID, &scope.tenantID, &scope.subjectID, &scope.taskRef, &scope.engineMode, &scope.status)
|
||||
err := s.pool.QueryRow(ctx, `SELECT c.id::text,c.public_id::text,c.tenant_id::text,c.subject_id::text,
|
||||
COALESCE(rt.provider_task_ref,hs.provider_session_ref),c.engine_mode,c.runtime_type,c.status
|
||||
FROM conversation c
|
||||
LEFT JOIN rtc_task rt ON rt.conversation_id=c.id
|
||||
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id
|
||||
WHERE c.public_id=$1::uuid`, claims.ConversationID).
|
||||
Scan(&scope.internalID, &scope.publicID, &scope.tenantID, &scope.subjectID, &scope.taskRef, &scope.engineMode, &scope.runtimeType, &scope.status)
|
||||
if errors.Is(err, pgx.ErrNoRows) || scope.taskRef != claims.TaskRef {
|
||||
return conversationScope{}, ErrConversationUnavailable
|
||||
}
|
||||
if err != nil {
|
||||
return conversationScope{}, fmt.Errorf("resolve memory tool conversation: %w", err)
|
||||
}
|
||||
if scope.engineMode != "s2s_hybrid" {
|
||||
if scope.engineMode != "s2s_hybrid" && scope.runtimeType != "volc_hardware_agent" {
|
||||
return conversationScope{}, ErrEngineModeUnsupported
|
||||
}
|
||||
if scope.status != "ACTIVE" && scope.status != "STARTING_AGENT" {
|
||||
|
||||
+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))
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/conversation"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrHardwareSessionProof = errors.New("hardware agent session proof is invalid")
|
||||
ErrHardwareEventConflict = errors.New("hardware agent event conflicts with stored event")
|
||||
)
|
||||
|
||||
func (s *Service) CreateHardwareAgent(ctx context.Context, identity deviceauth.Identity, idempotencyKey, requestID string, rawRequest []byte, input HardwareAgentCreateInput) (HardwareAgentCreateResponse, bool, error) {
|
||||
tenantID := identity.TenantID
|
||||
request := idempotency.Request{TenantID: &tenantID, ActorKey: "device:" + identity.PublicID,
|
||||
Scope: "POST /device/v3/agent-sessions", Key: idempotencyKey,
|
||||
RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 24 * time.Hour}
|
||||
if cached, found, err := s.executor.Lookup(ctx, request); err != nil {
|
||||
return HardwareAgentCreateResponse{}, false, err
|
||||
} else if found {
|
||||
return s.finalizeHardwareCreate(cached)
|
||||
}
|
||||
memoryVector, memoryPolicy, err := s.prepareMemoryQuery(ctx, identity, input.RequestedGoal)
|
||||
if err != nil {
|
||||
return HardwareAgentCreateResponse{}, false, err
|
||||
}
|
||||
var vikingSubject, vikingContext string
|
||||
if s.viking != nil {
|
||||
readContext, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
var readErr error
|
||||
vikingSubject, vikingContext, readErr = s.viking.PrepareForDevice(readContext, identity.TenantID, identity.ID)
|
||||
cancel()
|
||||
if readErr != nil {
|
||||
slog.WarnContext(ctx, "Viking startup memory unavailable", "error", readErr)
|
||||
}
|
||||
}
|
||||
result, err := s.executor.Execute(ctx, request, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
now := s.now().UTC()
|
||||
created, internalID, createErr := s.repository.Create(operationContext, tx, identity, CreateInput{
|
||||
DeviceTime: input.DeviceTime, MonotonicMS: input.MonotonicMS, Network: input.Network,
|
||||
RequestedGoal: input.RequestedGoal, ConfigRevision: input.ConfigRevision,
|
||||
Capabilities: Capabilities{UploadResume: boolPointer(input.Capabilities.TranscriptUpload)},
|
||||
}, AllocationOptions{Now: now, AllocationExpires: now.Add(s.options.AllocationTTL),
|
||||
TokenExpires: now.Add(s.options.TokenTTL), RTCAppID: s.options.RTCAppID,
|
||||
VoiceProvider: s.options.VoiceProvider, EngineMode: s.options.EngineMode,
|
||||
HeartbeatInterval: s.options.HeartbeatInterval, MemoryQueryVector: memoryVector,
|
||||
VikingSubjectID: vikingSubject, VikingContext: vikingContext,
|
||||
MemoryPolicy: memoryPolicy, RequirePublishedRouting: s.options.RequirePublishedRouting,
|
||||
RuntimeType: RuntimeVolcHardwareAgent, HardwareBotID: s.options.HardwareBotID,
|
||||
HardwareTransport: s.options.HardwareTransport, HardwareCapabilities: input.Capabilities,
|
||||
SubtitleCallbackURL: s.options.SubtitleCallbackURL, SubtitleCallbackSecret: s.options.SubtitleCallbackSecret,
|
||||
SubtitlePrimary: s.options.SubtitlePrimary,
|
||||
HardwareMemoryToolURL: s.hardwareMemoryToolURL})
|
||||
if createErr != nil {
|
||||
return idempotency.Result{}, createErr
|
||||
}
|
||||
if created.Hardware == nil {
|
||||
return idempotency.Result{}, errors.New("hardware agent runtime was not allocated")
|
||||
}
|
||||
body, marshalErr := json.Marshal(created.Hardware)
|
||||
if marshalErr != nil {
|
||||
return idempotency.Result{}, marshalErr
|
||||
}
|
||||
actorID := identity.PublicID
|
||||
if auditErr := s.audit.Record(operationContext, tx, audit.Event{TenantID: &tenantID,
|
||||
ActorType: "device", ActorPublicID: &actorID, Action: "hardware_agent.session.create",
|
||||
ResourceType: "conversation", ResourceID: &internalID, RequestID: requestID,
|
||||
Purpose: input.RequestedGoal, Result: "success", Detail: map[string]any{
|
||||
"runtime_type": RuntimeVolcHardwareAgent, "transport": created.Hardware.Runtime.Transport,
|
||||
"prompt_bundle_version": created.Hardware.SessionConfig.PromptBundleVersion,
|
||||
}}); auditErr != nil {
|
||||
return idempotency.Result{}, auditErr
|
||||
}
|
||||
return idempotency.Result{StatusCode: http.StatusCreated, Body: body, ResourceID: &internalID}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return HardwareAgentCreateResponse{}, false, err
|
||||
}
|
||||
return s.finalizeHardwareCreate(result)
|
||||
}
|
||||
|
||||
func (s *Service) finalizeHardwareCreate(result idempotency.Result) (HardwareAgentCreateResponse, bool, error) {
|
||||
response, replayed, err := decodeHardwareCreate(result)
|
||||
if err != nil || s.hardwareMemoryTool == nil || s.hardwareMemoryToolURL == "" {
|
||||
return response, replayed, err
|
||||
}
|
||||
token, err := s.hardwareMemoryTool.Issue(response.ConversationID, response.Runtime.ProviderSessionRef, s.now().UTC().Add(s.hardwareMemoryToolTTL))
|
||||
if err != nil {
|
||||
return HardwareAgentCreateResponse{}, replayed, fmt.Errorf("issue hardware memory tool token: %w", err)
|
||||
}
|
||||
response.SessionConfig.ToolURL = s.hardwareMemoryToolURL
|
||||
response.SessionConfig.ToolScopeToken = token
|
||||
return response, replayed, nil
|
||||
}
|
||||
|
||||
func decodeHardwareCreate(result idempotency.Result) (HardwareAgentCreateResponse, bool, error) {
|
||||
var response HardwareAgentCreateResponse
|
||||
if err := json.Unmarshal(result.Body, &response); err != nil {
|
||||
return HardwareAgentCreateResponse{}, result.Replayed, fmt.Errorf("decode stored hardware agent session: %w", err)
|
||||
}
|
||||
return response, result.Replayed, nil
|
||||
}
|
||||
|
||||
func boolPointer(value bool) *bool { return &value }
|
||||
|
||||
func (s *Service) ProcessHardwareAgentEvent(ctx context.Context, identity deviceauth.Identity, conversationPublicID string, rawBody []byte, input HardwareAgentEventInput) (HardwareAgentEventResponse, error) {
|
||||
if s.pool == nil {
|
||||
return HardwareAgentEventResponse{}, errors.New("hardware agent event database is unavailable")
|
||||
}
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return HardwareAgentEventResponse{}, err
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
now := s.now().UTC()
|
||||
var internalID, tenantID, providerSessionRef, nonceDigest, expectedBotID, expectedTransport, expectedConfigDigest string
|
||||
var lastEventSequence int64
|
||||
var currentStatus conversation.Status
|
||||
var allocationExpires time.Time
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT c.id::text,c.tenant_id::text,c.status,c.allocation_expires_at,
|
||||
hs.provider_session_ref,hs.session_nonce_digest,hs.last_event_sequence,hs.bot_id,hs.transport,hs.config_digest
|
||||
FROM conversation c JOIN hardware_agent_session hs ON hs.conversation_id=c.id AND hs.tenant_id=c.tenant_id
|
||||
WHERE c.public_id=$1::uuid AND c.tenant_id=$2::uuid AND c.device_id=$3::uuid
|
||||
FOR UPDATE OF c,hs`, conversationPublicID, identity.TenantID, identity.ID).Scan(
|
||||
&internalID, &tenantID, ¤tStatus, &allocationExpires, &providerSessionRef, &nonceDigest, &lastEventSequence,
|
||||
&expectedBotID, &expectedTransport, &expectedConfigDigest)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return HardwareAgentEventResponse{}, ErrConversationNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return HardwareAgentEventResponse{}, fmt.Errorf("read hardware agent session: %w", err)
|
||||
}
|
||||
providedDigest := sha256.Sum256([]byte(input.SessionNonce))
|
||||
expectedDigest, decodeErr := hex.DecodeString(nonceDigest)
|
||||
if decodeErr != nil || len(expectedDigest) != len(providedDigest) || subtle.ConstantTimeCompare(expectedDigest, providedDigest[:]) != 1 || input.ProviderSessionRef != providerSessionRef {
|
||||
return HardwareAgentEventResponse{}, ErrHardwareSessionProof
|
||||
}
|
||||
payloadDigest := sha256.Sum256(rawBody)
|
||||
digestText := hex.EncodeToString(payloadDigest[:])
|
||||
var storedDigest string
|
||||
err = tx.QueryRow(ctx, `SELECT payload_digest FROM hardware_agent_event WHERE tenant_id=$1::uuid AND event_id=$2`, tenantID, input.EventID).Scan(&storedDigest)
|
||||
if err == nil {
|
||||
if storedDigest != digestText {
|
||||
return HardwareAgentEventResponse{}, ErrHardwareEventConflict
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return HardwareAgentEventResponse{}, err
|
||||
}
|
||||
return HardwareAgentEventResponse{ConversationID: conversationPublicID, Status: currentStatus, Action: hardwareAgentAction(currentStatus), Duplicate: true, AcceptedAt: now}, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return HardwareAgentEventResponse{}, err
|
||||
}
|
||||
if input.SequenceNo <= lastEventSequence {
|
||||
return HardwareAgentEventResponse{}, ErrHardwareEventConflict
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO hardware_agent_event(
|
||||
tenant_id,conversation_id,event_id,sequence_no,event_type,occurred_at,payload_digest)
|
||||
VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7)`,
|
||||
tenantID, internalID, input.EventID, input.SequenceNo, input.EventType, input.OccurredAt, digestText)
|
||||
if err != nil {
|
||||
return HardwareAgentEventResponse{}, fmt.Errorf("store hardware agent event: %w", err)
|
||||
}
|
||||
if input.EventType == "session_started" {
|
||||
if currentStatus != conversation.StatusRoomIssued || !allocationExpires.After(now) {
|
||||
return HardwareAgentEventResponse{}, ErrConversationStateConflict
|
||||
}
|
||||
if !input.DynamicConfigApplied || input.BotID != expectedBotID || input.Transport != expectedTransport ||
|
||||
input.ConfigDigest != expectedConfigDigest || strings.TrimSpace(input.ProviderConnectionRef) == "" {
|
||||
return HardwareAgentEventResponse{}, ErrHardwareSessionProof
|
||||
}
|
||||
leaseExpires := now.Add(time.Duration(s.options.HeartbeatInterval*5) * time.Second)
|
||||
_, err = tx.Exec(ctx, `UPDATE conversation SET status='ACTIVE',started_at=COALESCE(started_at,$2),lease_expires_at=$3,version=version+1,updated_at=$2 WHERE id=$1::uuid`, internalID, now, leaseExpires)
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='ACTIVE',provider_started_at=COALESCE(provider_started_at,$2),last_event_at=$3,last_event_sequence=$4,updated_at=$3,provider_connection_ref=$5,dynamic_config_applied_at=$2 WHERE conversation_id=$1::uuid`, internalID, input.OccurredAt, now, input.SequenceNo, input.ProviderConnectionRef)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source) VALUES($1::uuid,'ROOM_ISSUED','ACTIVE','device confirmed hardware agent session started','device')`, internalID)
|
||||
}
|
||||
currentStatus = conversation.StatusActive
|
||||
} else if input.EventType == "session_ended" {
|
||||
if currentStatus != conversation.StatusActive && currentStatus != conversation.StatusStopping {
|
||||
return HardwareAgentEventResponse{}, ErrConversationStateConflict
|
||||
}
|
||||
fromStatus := currentStatus
|
||||
_, err = tx.Exec(ctx, `UPDATE conversation SET status='COMPLETED',ended_at=COALESCE(ended_at,$2),lease_expires_at=NULL,stop_reason=COALESCE(stop_reason,'hardware_agent_ended'),version=version+1,updated_at=$2 WHERE id=$1::uuid`, internalID, now)
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='COMPLETED',provider_ended_at=$2,last_event_at=$3,last_event_sequence=$4,updated_at=$3 WHERE conversation_id=$1::uuid`, internalID, input.OccurredAt, now, input.SequenceNo)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source) VALUES($1::uuid,$2,'COMPLETED','device confirmed hardware agent session ended','device')`, internalID, fromStatus)
|
||||
}
|
||||
if err == nil {
|
||||
err = enqueueHardwareContentExtraction(ctx, tx, s.outbox, tenantID, internalID)
|
||||
}
|
||||
currentStatus = conversation.StatusCompleted
|
||||
} else if input.EventType == "session_failed" {
|
||||
if conversation.IsTerminal(currentStatus) {
|
||||
return HardwareAgentEventResponse{}, ErrConversationStateConflict
|
||||
}
|
||||
failureCode := input.ErrorCode
|
||||
if failureCode == "" {
|
||||
failureCode = "hardware_agent_failed"
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE conversation SET status='FAILED',ended_at=$2,lease_expires_at=NULL,failure_code=$3,version=version+1,updated_at=$2 WHERE id=$1::uuid`, internalID, now, failureCode)
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='FAILED',provider_ended_at=$2,last_event_at=$3,last_event_sequence=$4,updated_at=$3 WHERE conversation_id=$1::uuid`, internalID, input.OccurredAt, now, input.SequenceNo)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source) VALUES($1::uuid,$2,'FAILED','device reported hardware agent session failure','device')`, internalID, currentStatus)
|
||||
}
|
||||
if err == nil {
|
||||
err = enqueueHardwareContentExtraction(ctx, tx, s.outbox, tenantID, internalID)
|
||||
}
|
||||
currentStatus = conversation.StatusFailed
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET last_event_at=$2,last_event_sequence=GREATEST(last_event_sequence,$3),updated_at=$2 WHERE conversation_id=$1::uuid`, internalID, now, input.SequenceNo)
|
||||
if err == nil && currentStatus == conversation.StatusActive {
|
||||
leaseExpires := now.Add(time.Duration(s.options.HeartbeatInterval*5) * time.Second)
|
||||
_, err = tx.Exec(ctx, `UPDATE conversation SET lease_expires_at=$2,updated_at=$3,last_heartbeat_seq=GREATEST(last_heartbeat_seq,$4) WHERE id=$1::uuid AND status='ACTIVE'`, internalID, leaseExpires, now, input.SequenceNo)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return HardwareAgentEventResponse{}, fmt.Errorf("apply hardware agent event: %w", err)
|
||||
}
|
||||
actorID := identity.PublicID
|
||||
if err = s.audit.Record(ctx, tx, audit.Event{TenantID: &tenantID, ActorType: "device", ActorPublicID: &actorID,
|
||||
Action: "hardware_agent.session.event", ResourceType: "conversation", ResourceID: &internalID,
|
||||
Result: "success", Detail: map[string]any{"event_id": input.EventID, "event_type": input.EventType,
|
||||
"sequence_no": input.SequenceNo, "status": currentStatus}}); err != nil {
|
||||
return HardwareAgentEventResponse{}, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return HardwareAgentEventResponse{}, err
|
||||
}
|
||||
return HardwareAgentEventResponse{ConversationID: conversationPublicID, Status: currentStatus, Action: hardwareAgentAction(currentStatus), AcceptedAt: now}, nil
|
||||
}
|
||||
|
||||
// Schedule the latest stored turns when a hardware session ends, including
|
||||
// abrupt power loss. An extraction version below max(sequence_no) is ignored
|
||||
// by the processor, so a constant version of 1 loses multi-turn summaries.
|
||||
func enqueueHardwareContentExtraction(ctx context.Context, tx pgx.Tx, store *outbox.Store, tenantID, internalID string) error {
|
||||
var version int64
|
||||
if err := tx.QueryRow(ctx, `SELECT COALESCE(max(sequence_no),0) FROM conversation_turn WHERE conversation_id=$1::uuid AND tenant_id=$2::uuid`, internalID, tenantID).Scan(&version); err != nil {
|
||||
return err
|
||||
}
|
||||
if version == 0 {
|
||||
return nil
|
||||
}
|
||||
dedup := fmt.Sprintf("content-extraction-v1:%s:hardware-terminal:%d", internalID, version)
|
||||
payload, err := json.Marshal(map[string]int64{"generation_version": version})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = store.Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "conversation", AggregateID: internalID,
|
||||
EventType: "conversation.content_extraction_requested", DeduplicationKey: &dedup, Payload: payload})
|
||||
return err
|
||||
}
|
||||
|
||||
func hardwareAgentAction(status conversation.Status) string {
|
||||
if status == conversation.StatusStopping || conversation.IsTerminal(status) {
|
||||
return "stop_session"
|
||||
}
|
||||
return "continue"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"data-terminal-cloud/internal/volcsubtitle"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNativeSubtitleSpeaker = errors.New("native subtitle speaker is not mapped")
|
||||
|
||||
type NativeSubtitleResult struct {
|
||||
Mode string
|
||||
Accepted, Duplicates int
|
||||
}
|
||||
type NativeSubtitleService struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewNativeSubtitleService(pool *pgxpool.Pool) *NativeSubtitleService {
|
||||
return &NativeSubtitleService{pool: pool}
|
||||
}
|
||||
|
||||
// Hardware low_load callbacks currently carry a device identity, but no session
|
||||
// identity. Validate connectivity without guessing a conversation or storing text.
|
||||
func (s *NativeSubtitleService) ProbeNativeSubtitleDevice(ctx context.Context, packet volcsubtitle.Packet) error {
|
||||
var found bool
|
||||
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM hardware_agent_provider_credential WHERE product_key=$1 AND provider_device_name=$2 AND status='ACTIVE')`, packet.ProductKey, packet.DeviceName).Scan(&found)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrConversationNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NativeSubtitleService) ProcessNativeSubtitle(ctx context.Context, publicID string, packet volcsubtitle.Packet) (NativeSubtitleResult, error) {
|
||||
result := NativeSubtitleResult{Mode: "probe"}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
result, err = s.processNativeSubtitleTx(ctx, tx, publicID, packet)
|
||||
if err != nil {
|
||||
return NativeSubtitleResult{}, err
|
||||
}
|
||||
return result, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *NativeSubtitleService) processNativeSubtitleTx(ctx context.Context, tx pgx.Tx, publicID string, packet volcsubtitle.Packet) (NativeSubtitleResult, error) {
|
||||
result := NativeSubtitleResult{Mode: "probe"}
|
||||
var id, tenantID, subjectID, retention, status, deviceUID, agentUID, deviceName, botID string
|
||||
var primary, allowed bool
|
||||
err := tx.QueryRow(ctx, `SELECT c.id::text,c.tenant_id::text,c.subject_id::text,c.retention_mode,c.status,
|
||||
COALESCE(hs.capability_snapshot->>'subtitle_device_user_id',''),COALESCE(hs.capability_snapshot->>'subtitle_agent_user_id',''),
|
||||
COALESCE(hs.capability_snapshot->>'subtitle_device_user_id',''),hs.bot_id,COALESCE((hs.capability_snapshot->>'native_subtitle_primary')::boolean,false),
|
||||
EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=c.tenant_id AND cr.subject_id=c.subject_id AND cr.consent_type='transcript_storage' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))
|
||||
FROM conversation c JOIN hardware_agent_session hs ON hs.conversation_id=c.id AND hs.tenant_id=c.tenant_id
|
||||
WHERE c.public_id=$1::uuid FOR UPDATE OF c`, publicID).Scan(&id, &tenantID, &subjectID, &retention, &status, &deviceUID, &agentUID, &deviceName, &botID, &primary, &allowed)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return result, ErrConversationNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if retention == "none" || !allowed {
|
||||
return result, ErrTranscriptStorageNotAllowed
|
||||
}
|
||||
if status != "ROOM_ISSUED" && status != "ACTIVE" && status != "STOPPING" && status != "COMPLETED" && status != "FAILED" {
|
||||
return result, ErrConversationStateConflict
|
||||
}
|
||||
if !primary {
|
||||
return result, nil
|
||||
}
|
||||
result.Mode = "primary"
|
||||
for _, part := range packet.Data {
|
||||
// Server callbacks contain independent completed clauses; retaining only
|
||||
// paragraph=true would silently drop all preceding clauses of the reply.
|
||||
if !part.Definite || strings.TrimSpace(part.Text) == "" {
|
||||
continue
|
||||
}
|
||||
speaker, err := nativeSubtitleSpeaker(part.UserID, deviceUID, deviceName, agentUID, botID)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
digest := sha256.Sum256([]byte(part.UserID))
|
||||
key := fmt.Sprintf("volc-subv-%s-%d-%d", hex.EncodeToString(digest[:8]), *part.RoundID, *part.Sequence)
|
||||
var stored string
|
||||
err = tx.QueryRow(ctx, `SELECT final_text FROM conversation_turn WHERE conversation_id=$1::uuid AND turn_key=$2`, id, key).Scan(&stored)
|
||||
if err == nil {
|
||||
if stored != part.Text {
|
||||
return result, ErrHardwareEventConflict
|
||||
}
|
||||
result.Duplicates++
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return result, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,interrupted,source_type)
|
||||
SELECT $1::uuid,$2::uuid,$3::uuid,$4,COALESCE(max(sequence_no),0)+1,$5,now(),now(),$6,false,'provider' FROM conversation_turn WHERE conversation_id=$3::uuid`, tenantID, subjectID, id, key, speaker, part.Text)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Accepted++
|
||||
}
|
||||
if result.Accepted > 0 && (status == "COMPLETED" || status == "FAILED") {
|
||||
if err = enqueueHardwareContentExtraction(ctx, tx, outbox.NewStore(s.pool), tenantID, id); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func nativeSubtitleSpeaker(uid, deviceUID, deviceName, agentUID, botID string) (string, error) {
|
||||
subject := uid != "" && (uid == deviceUID || uid == deviceName)
|
||||
agent := uid != "" && (uid == agentUID || uid == botID)
|
||||
if subject == agent {
|
||||
return "", ErrNativeSubtitleSpeaker
|
||||
}
|
||||
if subject {
|
||||
return "subject", nil
|
||||
}
|
||||
return "agent", nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package orchestration
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNativeSubtitleSpeakerRequiresExplicitMatch(t *testing.T) {
|
||||
for _, tc := range []struct{ uid, want string }{{"device", "subject"}, {"bot", "agent"}, {"unknown", ""}, {"", ""}} {
|
||||
got, err := nativeSubtitleSpeaker(tc.uid, "device", "device", "bot", "bot")
|
||||
if got != tc.want || (err != nil) != (tc.want == "") {
|
||||
t.Fatalf("%q => %q %v", tc.uid, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := nativeSubtitleSpeaker("same", "same", "same", "same", "same"); err == nil {
|
||||
t.Fatal("ambiguous speaker accepted")
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type recoveryCandidate struct {
|
||||
ProviderTaskRef string
|
||||
StopReason string
|
||||
Trigger string
|
||||
RuntimeType string
|
||||
}
|
||||
|
||||
func NewRecoveryScanner(pool *pgxpool.Pool, outboxStore *outbox.Store, logger *slog.Logger, options RecoveryOptions) (*RecoveryScanner, error) {
|
||||
@@ -81,8 +82,9 @@ func (s *RecoveryScanner) ScanOnce(ctx context.Context) (int, error) {
|
||||
CASE WHEN c.status='ACTIVE' AND cub.in_progress_action='STOP_IMMEDIATELY'
|
||||
AND c.started_at IS NOT NULL
|
||||
AND c.started_at+make_interval(secs=>cub.max_call_duration_sec)<=$1
|
||||
THEN 'quota_duration_limit' ELSE '' END
|
||||
FROM conversation c JOIN rtc_task r ON r.conversation_id=c.id
|
||||
THEN 'quota_duration_limit' ELSE '' END,c.runtime_type
|
||||
FROM conversation c LEFT JOIN rtc_task r ON r.conversation_id=c.id
|
||||
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id
|
||||
LEFT JOIN conversation_usage_budget cub ON cub.conversation_id=c.id AND cub.tenant_id=c.tenant_id
|
||||
WHERE (c.status='ROOM_ISSUED' AND c.allocation_expires_at <= $1)
|
||||
OR (c.status='ACTIVE' AND c.lease_expires_at IS NOT NULL AND c.lease_expires_at <= $1)
|
||||
@@ -90,7 +92,7 @@ func (s *RecoveryScanner) ScanOnce(ctx context.Context) (int, error) {
|
||||
OR (c.status='STARTING_AGENT' AND c.updated_at <= $2)
|
||||
OR (c.status='STOPPING' AND c.updated_at <= $3)
|
||||
ORDER BY c.updated_at, c.id
|
||||
LIMIT $4 FOR UPDATE OF c, r SKIP LOCKED`,
|
||||
LIMIT $4 FOR UPDATE OF c SKIP LOCKED`,
|
||||
now, now.Add(-s.options.StartingAfter), now.Add(-s.options.StoppingAfter), s.options.BatchSize)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("select conversations for recovery: %w", err)
|
||||
@@ -98,7 +100,7 @@ func (s *RecoveryScanner) ScanOnce(ctx context.Context) (int, error) {
|
||||
candidates := make([]recoveryCandidate, 0, s.options.BatchSize)
|
||||
for rows.Next() {
|
||||
var candidate recoveryCandidate
|
||||
if err := rows.Scan(&candidate.InternalID, &candidate.PublicID, &candidate.TenantID, &candidate.Status, &candidate.ProviderTaskRef, &candidate.StopReason, &candidate.Trigger); err != nil {
|
||||
if err := rows.Scan(&candidate.InternalID, &candidate.PublicID, &candidate.TenantID, &candidate.Status, &candidate.ProviderTaskRef, &candidate.StopReason, &candidate.Trigger, &candidate.RuntimeType); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
@@ -130,9 +132,15 @@ func (s *RecoveryScanner) recoverCandidate(ctx context.Context, tx pgx.Tx, candi
|
||||
WHERE id=$1::uuid AND status='ROOM_ISSUED'`, candidate.InternalID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE rtc_task SET status='COMPLETED', lease_expires_at=NULL,
|
||||
last_checked_at=$2, updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
|
||||
return err
|
||||
if candidate.RuntimeType == RuntimeVolcHardwareAgent {
|
||||
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_session SET status='EXPIRED',updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(ctx, `UPDATE rtc_task SET status='COMPLETED', lease_expires_at=NULL,
|
||||
last_checked_at=$2, updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO conversation_state_history (
|
||||
conversation_id, from_status, to_status, reason, source
|
||||
@@ -153,17 +161,25 @@ func (s *RecoveryScanner) recoverCandidate(ctx context.Context, tx pgx.Tx, candi
|
||||
WHERE id=$1::uuid AND status='ACTIVE'`, candidate.InternalID, now, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE rtc_task SET status='STOPPING', lease_expires_at=NULL,
|
||||
updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
|
||||
return err
|
||||
if candidate.RuntimeType == RuntimeVolcHardwareAgent {
|
||||
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_session SET status='STOPPING',updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(ctx, `UPDATE rtc_task SET status='STOPPING', lease_expires_at=NULL,
|
||||
updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO conversation_state_history (
|
||||
conversation_id, from_status, to_status, reason, source
|
||||
) VALUES ($1::uuid, 'ACTIVE', 'STOPPING', $2, 'worker')`, candidate.InternalID, historyReason); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.enqueueRecovery(ctx, tx, candidate, "conversation.agent_stop_requested", candidate.ProviderTaskRef, reason); err != nil {
|
||||
return err
|
||||
if candidate.RuntimeType != RuntimeVolcHardwareAgent {
|
||||
if err := s.enqueueRecovery(ctx, tx, candidate, "conversation.agent_stop_requested", candidate.ProviderTaskRef, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case conversation.StatusStartingAgent:
|
||||
action = "reconcile_starting_task"
|
||||
@@ -175,6 +191,22 @@ func (s *RecoveryScanner) recoverCandidate(ctx context.Context, tx pgx.Tx, candi
|
||||
return err
|
||||
}
|
||||
case conversation.StatusStopping:
|
||||
if candidate.RuntimeType == RuntimeVolcHardwareAgent {
|
||||
action = "fail_unconfirmed_hardware_stop"
|
||||
if _, err := tx.Exec(ctx, `UPDATE conversation SET status='FAILED',ended_at=$2,failure_code='hardware_agent_stop_timeout',version=version+1,updated_at=$2 WHERE id=$1::uuid AND status='STOPPING'`, candidate.InternalID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_session SET status='FAILED',provider_ended_at=$2,updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source) VALUES($1::uuid,'STOPPING','FAILED','hardware agent stop was not confirmed before deadline','worker')`, candidate.InternalID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := enqueueHardwareContentExtraction(ctx, tx, s.outbox, candidate.TenantID, candidate.InternalID); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
action = "reconcile_stopping_task"
|
||||
deduplicationKey := candidate.ProviderTaskRef + ":" + strconv.FormatInt(recoveryBucket(now, s.options.StoppingAfter), 10)
|
||||
reason := candidate.StopReason
|
||||
|
||||
@@ -2,7 +2,9 @@ package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/firmwareversion"
|
||||
"data-terminal-cloud/internal/retrieval"
|
||||
"data-terminal-cloud/internal/volcsubtitle"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -25,6 +28,7 @@ var (
|
||||
ErrDeviceNotBound = errors.New("device is not bound to an active subject")
|
||||
ErrConsentRequired = errors.New("voice chat consent is required")
|
||||
ErrFirmwareUpdateRequired = errors.New("device firmware does not satisfy the minimum version")
|
||||
ErrHardwareSDKUpdateRequired = errors.New("hardware agent SDK does not satisfy the minimum version")
|
||||
ErrConversationAlreadyActive = errors.New("device already has an unfinished conversation")
|
||||
ErrConversationNotFound = errors.New("conversation was not found for device")
|
||||
ErrConversationStateConflict = errors.New("conversation state does not allow operation")
|
||||
@@ -45,6 +49,8 @@ const (
|
||||
)
|
||||
|
||||
type AllocationOptions struct {
|
||||
VikingSubjectID string
|
||||
VikingContext string
|
||||
Now time.Time
|
||||
AllocationExpires time.Time
|
||||
TokenExpires time.Time
|
||||
@@ -55,6 +61,14 @@ type AllocationOptions struct {
|
||||
MemoryQueryVector string
|
||||
MemoryPolicy string
|
||||
RequirePublishedRouting bool
|
||||
RuntimeType string
|
||||
HardwareBotID string
|
||||
HardwareTransport string
|
||||
HardwareCapabilities HardwareAgentCapabilities
|
||||
SubtitleCallbackURL string
|
||||
SubtitleCallbackSecret []byte
|
||||
SubtitlePrimary bool
|
||||
HardwareMemoryToolURL string
|
||||
}
|
||||
|
||||
type Repository struct{}
|
||||
@@ -71,6 +85,7 @@ type StopResult struct {
|
||||
InternalID string
|
||||
ProviderTaskRef string
|
||||
StopRequested bool
|
||||
RuntimeType string
|
||||
}
|
||||
|
||||
func NewRepository() *Repository { return &Repository{} }
|
||||
@@ -83,14 +98,16 @@ func (r *Repository) Stop(
|
||||
input StopInput,
|
||||
now time.Time,
|
||||
) (StopResult, error) {
|
||||
var internalID, providerTaskRef string
|
||||
var internalID, providerTaskRef, runtimeType string
|
||||
var status conversation.Status
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT c.id::text, c.status, c.provider_task_ref
|
||||
FROM conversation c JOIN rtc_task r ON r.conversation_id=c.id
|
||||
SELECT c.id::text, c.status, c.provider_task_ref, c.runtime_type
|
||||
FROM conversation c
|
||||
LEFT JOIN rtc_task r ON r.conversation_id=c.id
|
||||
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id
|
||||
WHERE c.public_id=$1::uuid AND c.device_id=$2::uuid AND c.tenant_id=$3::uuid
|
||||
FOR UPDATE OF c, r`, conversationPublicID, identity.ID, identity.TenantID,
|
||||
).Scan(&internalID, &status, &providerTaskRef)
|
||||
FOR UPDATE OF c`, conversationPublicID, identity.ID, identity.TenantID,
|
||||
).Scan(&internalID, &status, &providerTaskRef, &runtimeType)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return StopResult{}, ErrConversationNotFound
|
||||
}
|
||||
@@ -100,6 +117,7 @@ func (r *Repository) Stop(
|
||||
result := StopResult{
|
||||
Response: StopResponse{ConversationID: conversationPublicID, Status: status, AcceptedAt: now},
|
||||
InternalID: internalID, ProviderTaskRef: providerTaskRef,
|
||||
RuntimeType: runtimeType,
|
||||
}
|
||||
if conversation.IsTerminal(status) {
|
||||
return result, nil
|
||||
@@ -123,9 +141,12 @@ func (r *Repository) Stop(
|
||||
lease_expires_at=NULL, last_heartbeat_seq=GREATEST(last_heartbeat_seq,$4),
|
||||
version=version+1, updated_at=$3 WHERE id=$1::uuid AND status='ROOM_ISSUED'`,
|
||||
internalID, input.Reason, now, input.LastHeartbeatSequence)
|
||||
if err == nil {
|
||||
if err == nil && runtimeType == RuntimeLegacyVoiceChat {
|
||||
_, err = tx.Exec(ctx, `UPDATE rtc_task SET status='COMPLETED', lease_expires_at=NULL,
|
||||
last_checked_at=$2, updated_at=$2 WHERE conversation_id=$1::uuid`, internalID, now)
|
||||
} else if err == nil {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='EXPIRED',updated_at=$2
|
||||
WHERE conversation_id=$1::uuid`, internalID, now)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `
|
||||
@@ -151,10 +172,13 @@ func (r *Repository) Stop(
|
||||
lease_expires_at=NULL, last_heartbeat_seq=GREATEST(last_heartbeat_seq,$3),
|
||||
version=version+1, updated_at=$4 WHERE id=$1::uuid AND status=$5`,
|
||||
internalID, input.Reason, input.LastHeartbeatSequence, now, status)
|
||||
if err == nil {
|
||||
if err == nil && runtimeType == RuntimeLegacyVoiceChat {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE rtc_task SET status='STOPPING', lease_expires_at=NULL, updated_at=$2
|
||||
WHERE conversation_id=$1::uuid`, internalID, now)
|
||||
} else if err == nil {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='STOPPING',updated_at=$2
|
||||
WHERE conversation_id=$1::uuid`, internalID, now)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `
|
||||
@@ -167,7 +191,7 @@ func (r *Repository) Stop(
|
||||
return StopResult{}, fmt.Errorf("stop active conversation: %w", err)
|
||||
}
|
||||
result.Response.Status = conversation.StatusStopping
|
||||
result.StopRequested = true
|
||||
result.StopRequested = runtimeType == RuntimeLegacyVoiceChat
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -264,13 +288,14 @@ func (r *Repository) Create(
|
||||
if options.VoiceProvider == "" {
|
||||
options.VoiceProvider = "mock"
|
||||
}
|
||||
var householdID, householdPublicID, subjectID, subjectPublicID, subjectName string
|
||||
var householdID, householdPublicID, subjectID, subjectPublicID, subjectName, desiredVoiceRuntime string
|
||||
var profileDataJSON, conversationPreferencesJSON json.RawMessage
|
||||
var minimumFirmwareVersion, currentFirmwareVersion *string
|
||||
var profileVersion int64
|
||||
var profileVersion, desiredRevision int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT b.household_id::text, h.public_id::text, b.subject_id::text,
|
||||
s.public_id::text, s.preferred_name, s.profile_data, s.conversation_preferences, s.profile_version,
|
||||
s.public_id::text, s.preferred_name, s.profile_data, s.conversation_preferences, s.profile_version, dt.desired_revision,
|
||||
COALESCE(dt.desired_config->>'voice_runtime','legacy_voicechat'),
|
||||
d.minimum_firmware_version, dt.firmware_version
|
||||
FROM device d
|
||||
JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
|
||||
@@ -280,7 +305,7 @@ func (r *Repository) Create(
|
||||
WHERE d.id = $1::uuid AND d.tenant_id = $2::uuid AND d.status = 'registered'
|
||||
FOR UPDATE OF d, b, s, dt`, identity.ID, identity.TenantID,
|
||||
).Scan(&householdID, &householdPublicID, &subjectID, &subjectPublicID, &subjectName,
|
||||
&profileDataJSON, &conversationPreferencesJSON, &profileVersion, &minimumFirmwareVersion, ¤tFirmwareVersion)
|
||||
&profileDataJSON, &conversationPreferencesJSON, &profileVersion, &desiredRevision, &desiredVoiceRuntime, &minimumFirmwareVersion, ¤tFirmwareVersion)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return CreateResponse{}, "", ErrDeviceNotBound
|
||||
}
|
||||
@@ -301,6 +326,12 @@ func (r *Repository) Create(
|
||||
if activeConversationExists {
|
||||
return CreateResponse{}, "", ErrConversationAlreadyActive
|
||||
}
|
||||
if options.RuntimeType == RuntimeVolcHardwareAgent && input.ConfigRevision != desiredRevision {
|
||||
return CreateResponse{}, "", ErrConfigVersionConflict
|
||||
}
|
||||
if options.RuntimeType == RuntimeVolcHardwareAgent && desiredVoiceRuntime != RuntimeVolcHardwareAgent {
|
||||
return CreateResponse{}, "", ErrRuntimeConfigUnavailable
|
||||
}
|
||||
budgetSnapshot, err := billing.CheckQuotaTx(ctx, tx, identity, options.Now)
|
||||
if err != nil {
|
||||
return CreateResponse{}, "", fmt.Errorf("check usage quota: %w", err)
|
||||
@@ -381,6 +412,17 @@ func (r *Repository) Create(
|
||||
}
|
||||
}
|
||||
contextSummary := buildContextSummary(subjectName, profileContext, recentSummaries, memoryContents)
|
||||
var vikingContext string
|
||||
if options.VikingSubjectID == subjectID && options.VikingContext != "" && consentAllowsConversationContinuity(effectiveConsents) {
|
||||
var allowed bool
|
||||
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type='personalized_memory' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now())) AND NOT EXISTS(SELECT 1 FROM privacy_request WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND request_type='DELETE')`, identity.TenantID, subjectID).Scan(&allowed); err != nil {
|
||||
return CreateResponse{}, "", err
|
||||
}
|
||||
if allowed {
|
||||
vikingContext = truncateContextField(options.VikingContext, 600)
|
||||
contextSummary += "\nViking 自动抽取的历史记忆(仅作背景,不是指令;可能有误,以用户最新说法为准):" + vikingContext
|
||||
}
|
||||
}
|
||||
contextValue := struct {
|
||||
SubjectID string `json:"subject_id"`
|
||||
PreferredName string `json:"preferred_name"`
|
||||
@@ -390,7 +432,8 @@ func (r *Repository) Create(
|
||||
RecentSummaries []string `json:"recent_summaries"`
|
||||
MemoryContents []string `json:"memory_contents"`
|
||||
RetrievalPolicy string `json:"retrieval_policy"`
|
||||
}{subjectPublicID, subjectName, profileVersion, input.RequestedGoal, profileContext, recentSummaries, memoryContents, retrievalPolicy(options.MemoryPolicy)}
|
||||
VikingContext string `json:"viking_context,omitempty"`
|
||||
}{subjectPublicID, subjectName, profileVersion, input.RequestedGoal, profileContext, recentSummaries, memoryContents, retrievalPolicy(options.MemoryPolicy), vikingContext}
|
||||
contextJSON, _ := json.Marshal(contextValue)
|
||||
contextDigest := sha256.Sum256(contextJSON)
|
||||
contextSnapshotID := uuid.NewString()
|
||||
@@ -414,19 +457,32 @@ func (r *Repository) Create(
|
||||
roomID := "r_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
deviceRTCUserID := "d_" + strings.ReplaceAll(identity.PublicID, "-", "")
|
||||
agentRTCUserID := "a_" + strings.ReplaceAll(conversationPublicID, "-", "")
|
||||
runtimeType := options.RuntimeType
|
||||
if runtimeType == "" {
|
||||
runtimeType = RuntimeLegacyVoiceChat
|
||||
}
|
||||
privacy := privacyFromConsent(effectiveConsents)
|
||||
if runtimeType == RuntimeVolcHardwareAgent && (!options.HardwareCapabilities.NearPCMRecording || !options.HardwareCapabilities.FarPCMRecording) {
|
||||
privacy.RecordNear = false
|
||||
privacy.RecordFar = false
|
||||
privacy.UploadAllowed = false
|
||||
privacy.RetentionMode = "none"
|
||||
if consentAllowsTranscriptRetention(effectiveConsents) {
|
||||
privacy.RetentionMode = "transcript_only"
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO conversation (
|
||||
id, public_id, tenant_id, household_id, subject_id, device_id,
|
||||
consent_snapshot_id, context_snapshot_id, provider_task_ref,
|
||||
engine_mode, retention_mode, rtc_credential_renewal_supported,
|
||||
engine_mode, retention_mode, rtc_credential_renewal_supported, runtime_type,
|
||||
status, allocation_expires_at, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
|
||||
$7::uuid, $8::uuid, $9, $10, $11, COALESCE($12, false), 'PREPARING', $13, $14, $14
|
||||
$7::uuid, $8::uuid, $9, $10, $11, COALESCE($12, false), $13, 'PREPARING', $14, $15, $15
|
||||
)`, conversationID, conversationPublicID, identity.TenantID, householdID, subjectID, identity.ID,
|
||||
consentSnapshotID, contextSnapshotID, providerTaskRef, engineMode,
|
||||
privacy.RetentionMode, input.Capabilities.RTCCredentialRenewal, options.AllocationExpires, options.Now,
|
||||
privacy.RetentionMode, input.Capabilities.RTCCredentialRenewal, runtimeType, options.AllocationExpires, options.Now,
|
||||
)
|
||||
if err != nil {
|
||||
var postgresError *pgconn.PgError
|
||||
@@ -438,6 +494,10 @@ func (r *Repository) Create(
|
||||
if err = billing.FreezeBudgetTx(ctx, tx, conversationID, identity.TenantID, budgetSnapshot); err != nil {
|
||||
return CreateResponse{}, "", fmt.Errorf("freeze conversation usage budget: %w", err)
|
||||
}
|
||||
if runtimeType == RuntimeVolcHardwareAgent {
|
||||
return r.createHardwareAgentRuntime(ctx, tx, identity, conversationID, conversationPublicID,
|
||||
providerTaskRef, promptBundleVersion, engineMode, contextSummary, privacy, input, options)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO conversation_state_history (conversation_id, from_status, to_status, reason, source)
|
||||
VALUES ($1::uuid, NULL, 'PREPARING', 'create request accepted', 'device')`, conversationID)
|
||||
@@ -485,6 +545,142 @@ func (r *Repository) Create(
|
||||
}, conversationID, nil
|
||||
}
|
||||
|
||||
func (r *Repository) createHardwareAgentRuntime(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
identity deviceauth.Identity,
|
||||
conversationID, conversationPublicID, providerSessionRef, promptBundleVersion, engineMode, contextSummary string,
|
||||
privacy Privacy,
|
||||
input CreateInput,
|
||||
options AllocationOptions,
|
||||
) (CreateResponse, string, error) {
|
||||
botID := strings.TrimSpace(options.HardwareBotID)
|
||||
transport := strings.TrimSpace(options.HardwareTransport)
|
||||
var boundBotID, boundTransport string
|
||||
var minimumSDKVersion *string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT bot_id,transport,minimum_sdk_version FROM hardware_agent_device_binding
|
||||
WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND status='ACTIVE'`, identity.TenantID, identity.ID,
|
||||
).Scan(&boundBotID, &boundTransport, &minimumSDKVersion)
|
||||
if err == nil {
|
||||
botID, transport = boundBotID, boundTransport
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return CreateResponse{}, "", fmt.Errorf("read hardware agent binding: %w", err)
|
||||
} else if options.RequirePublishedRouting {
|
||||
// Production sessions must resolve an explicit device binding. The
|
||||
// process-wide fallback exists only for local development and smoke tests.
|
||||
return CreateResponse{}, "", ErrRuntimeConfigUnavailable
|
||||
}
|
||||
if botID == "" {
|
||||
return CreateResponse{}, "", ErrRuntimeConfigUnavailable
|
||||
}
|
||||
if minimumSDKVersion == nil || strings.TrimSpace(*minimumSDKVersion) == "" {
|
||||
if options.RequirePublishedRouting {
|
||||
return CreateResponse{}, "", ErrRuntimeConfigUnavailable
|
||||
}
|
||||
} else if !firmwareversion.AtLeast(options.HardwareCapabilities.SDKVersion, *minimumSDKVersion) {
|
||||
return CreateResponse{}, "", ErrHardwareSDKUpdateRequired
|
||||
}
|
||||
if !options.HardwareCapabilities.DynamicSessionConfig || !supportsHardwareTransport(options.HardwareCapabilities.Transports, transport) {
|
||||
return CreateResponse{}, "", ErrRuntimeConfigUnavailable
|
||||
}
|
||||
if transport == "high_quality" && !options.HardwareCapabilities.ServerEventCallback {
|
||||
return CreateResponse{}, "", ErrRuntimeConfigUnavailable
|
||||
}
|
||||
if transport != "high_quality" && transport != "low_load" {
|
||||
transport = "high_quality"
|
||||
}
|
||||
var systemPrompt, personaTone string
|
||||
if strings.HasPrefix(promptBundleVersion, "cfg:") {
|
||||
bundleID := strings.TrimPrefix(promptBundleVersion, "cfg:")
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(persona.payload->>'system_prompt',''),COALESCE(persona.payload->>'tone','')
|
||||
FROM runtime_config_version bundle
|
||||
JOIN runtime_config_version persona ON persona.tenant_id=bundle.tenant_id
|
||||
AND persona.config_kind='PERSONA' AND persona.public_id::text=bundle.payload->>'persona_version_id'
|
||||
WHERE bundle.tenant_id=$1::uuid AND bundle.public_id=$2::uuid AND bundle.config_kind='AGENT_BUNDLE'`,
|
||||
identity.TenantID, bundleID).Scan(&systemPrompt, &personaTone)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return CreateResponse{}, "", fmt.Errorf("read hardware agent persona: %w", err)
|
||||
}
|
||||
}
|
||||
nonceBytes := make([]byte, 32)
|
||||
if _, err = rand.Read(nonceBytes); err != nil {
|
||||
return CreateResponse{}, "", fmt.Errorf("generate hardware session nonce: %w", err)
|
||||
}
|
||||
sessionNonce := base64.RawURLEncoding.EncodeToString(nonceBytes)
|
||||
nonceDigest := sha256.Sum256([]byte(sessionNonce))
|
||||
configValue := map[string]string{
|
||||
"bot_id": botID, "transport": transport, "system_prompt": systemPrompt,
|
||||
"persona_tone": personaTone, "authorized_context": contextSummary,
|
||||
"prompt_bundle_version": promptBundleVersion,
|
||||
"tool_url": options.HardwareMemoryToolURL,
|
||||
}
|
||||
configJSON, _ := json.Marshal(configValue)
|
||||
callbackURL, callbackSignature, subtitleSource := "", "", "device"
|
||||
if options.HardwareCapabilities.NativeSubtitleCallback && options.SubtitleCallbackURL != "" && len(options.SubtitleCallbackSecret) >= 32 {
|
||||
callbackURL = options.SubtitleCallbackURL + "?conversation_id=" + conversationPublicID
|
||||
callbackSignature = volcsubtitle.Signature(options.SubtitleCallbackSecret, conversationPublicID)
|
||||
configValue["subtitle_callback_url"] = callbackURL
|
||||
if options.SubtitlePrimary {
|
||||
subtitleSource = "provider"
|
||||
}
|
||||
configValue["subtitle_source"] = subtitleSource
|
||||
configJSON, _ = json.Marshal(configValue)
|
||||
}
|
||||
configDigest := sha256.Sum256(configJSON)
|
||||
capabilitySnapshot, _ := json.Marshal(options.HardwareCapabilities)
|
||||
var snapshot map[string]any
|
||||
_ = json.Unmarshal(capabilitySnapshot, &snapshot)
|
||||
snapshot["native_subtitle_primary"] = subtitleSource == "provider"
|
||||
var callbackDeviceName string
|
||||
if callbackURL != "" {
|
||||
if err = tx.QueryRow(ctx, `SELECT provider_device_name FROM hardware_agent_provider_credential WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status='ACTIVE'`, identity.ID, identity.TenantID).Scan(&callbackDeviceName); err != nil {
|
||||
return CreateResponse{}, "", err
|
||||
}
|
||||
}
|
||||
snapshot["subtitle_device_user_id"] = callbackDeviceName
|
||||
snapshot["subtitle_agent_user_id"] = botID
|
||||
capabilitySnapshot, _ = json.Marshal(snapshot)
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO hardware_agent_session(
|
||||
tenant_id,conversation_id,provider,provider_session_ref,bot_id,transport,
|
||||
session_nonce_digest,config_digest,status,wait_for_session_update,sdk_version,capability_snapshot
|
||||
) VALUES($1::uuid,$2::uuid,'volcengine',$3,$4,$5,$6,$7,'READY',true,$8,$9::jsonb)`,
|
||||
identity.TenantID, conversationID, providerSessionRef, botID, transport,
|
||||
hex.EncodeToString(nonceDigest[:]), hex.EncodeToString(configDigest[:]), options.HardwareCapabilities.SDKVersion, capabilitySnapshot)
|
||||
if err != nil {
|
||||
return CreateResponse{}, "", fmt.Errorf("create hardware agent session: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE conversation SET status='ROOM_ISSUED',version=version+1,updated_at=$2
|
||||
WHERE id=$1::uuid AND status='PREPARING'`, conversationID, options.Now)
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source)
|
||||
VALUES
|
||||
($1::uuid,NULL,'PREPARING','create request accepted','device'),
|
||||
($1::uuid,'PREPARING','ROOM_ISSUED','hardware agent session configuration frozen','platform')`, conversationID)
|
||||
}
|
||||
if err != nil {
|
||||
return CreateResponse{}, "", fmt.Errorf("issue hardware agent session: %w", err)
|
||||
}
|
||||
hardware := HardwareAgentCreateResponse{
|
||||
ConversationID: conversationPublicID, Status: "SESSION_READY", RuntimeType: RuntimeVolcHardwareAgent,
|
||||
Runtime: HardwareAgentRuntime{Provider: "volcengine", BotID: botID, Transport: transport,
|
||||
ProviderSessionRef: providerSessionRef, SessionNonce: sessionNonce,
|
||||
ConfigDigest: hex.EncodeToString(configDigest[:]), WaitForSessionUpdate: true},
|
||||
SessionConfig: HardwareAgentSessionConfig{DialogID: conversationPublicID, SystemPrompt: systemPrompt,
|
||||
PersonaTone: personaTone, AuthorizedContext: contextSummary, PromptBundleVersion: promptBundleVersion,
|
||||
ToolURL: options.HardwareMemoryToolURL, SubtitleCallbackURL: callbackURL,
|
||||
SubtitleCallbackSignature: callbackSignature, SubtitleSource: subtitleSource},
|
||||
|
||||
Privacy: privacy,
|
||||
Session: Session{EngineMode: engineMode, HeartbeatIntervalSecond: options.HeartbeatInterval,
|
||||
AllocationExpiresAt: options.AllocationExpires},
|
||||
}
|
||||
return CreateResponse{ConversationID: conversationPublicID, Status: conversation.StatusRoomIssued,
|
||||
Privacy: privacy, Session: hardware.Session, Hardware: &hardware}, conversationID, nil
|
||||
}
|
||||
|
||||
func selectRuntimeConfigurationTx(ctx context.Context, tx pgx.Tx, tenantID, subjectID, deviceID, devicePublicID string) (string, string, error) {
|
||||
// Rollouts already bind an immutable configuration version and its business
|
||||
// type. ConfigKey is an operator-defined version family, not a runtime
|
||||
@@ -797,9 +993,10 @@ func selectRecentConversationSummaries(ctx context.Context, tx pgx.Tx, tenantID,
|
||||
FROM conversation_summary summary
|
||||
JOIN conversation ON conversation.id=summary.conversation_id
|
||||
AND conversation.tenant_id=summary.tenant_id
|
||||
AND conversation.subject_id=summary.subject_id
|
||||
WHERE summary.tenant_id=$1::uuid AND summary.subject_id=$2::uuid
|
||||
AND summary.summary_type='conversation_summary' AND summary.status='READY'
|
||||
AND conversation.status='COMPLETED'
|
||||
AND conversation.status IN ('COMPLETED','FAILED')
|
||||
ORDER BY summary.conversation_id, summary.generation_version DESC, summary.created_at DESC
|
||||
)
|
||||
SELECT content FROM latest_summary
|
||||
@@ -877,3 +1074,18 @@ func privacyFromConsent(consents json.RawMessage) Privacy {
|
||||
}
|
||||
return privacy
|
||||
}
|
||||
|
||||
func consentAllowsTranscriptRetention(consents json.RawMessage) bool {
|
||||
values := map[string]bool{}
|
||||
_ = json.Unmarshal(consents, &values)
|
||||
return values["transcript_storage"] || values["transcript"]
|
||||
}
|
||||
|
||||
func supportsHardwareTransport(transports []string, expected string) bool {
|
||||
for _, transport := range transports {
|
||||
if transport == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ func TestRepositoryCreatesOnlyRoomIssuedStateBeforeActivation(t *testing.T) {
|
||||
TenantID: "10000000-0000-0000-0000-000000000001", Status: "registered",
|
||||
}
|
||||
resume := true
|
||||
if _, err := tx.Exec(ctx, `UPDATE consent_snapshot SET effective_consents='{"voice_chat":true,"transcript_storage":true}'::jsonb
|
||||
WHERE tenant_id=$1::uuid AND subject_id=(SELECT subject_id FROM device_binding WHERE device_id=$2::uuid AND unbound_at IS NULL LIMIT 1)`, identity.TenantID, identity.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device SET minimum_firmware_version='9.0.0' WHERE id=$1::uuid`, identity.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -85,16 +89,20 @@ func TestRepositoryCreatesOnlyRoomIssuedStateBeforeActivation(t *testing.T) {
|
||||
if !credentialIssuedAt.Equal(now) || !credentialExpiresAt.Equal(now.Add(5*time.Minute)) {
|
||||
t.Fatalf("credential metadata issued=%s expires=%s", credentialIssuedAt, credentialExpiresAt)
|
||||
}
|
||||
var desiredRevision int64
|
||||
if err := tx.QueryRow(ctx, `SELECT desired_revision FROM device_twin WHERE device_id=$1::uuid`, identity.ID).Scan(&desiredRevision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activation, err := NewRepository().Activate(ctx, tx, identity, response.ConversationID, "request-activate", ActivateInput{
|
||||
JoinedAt: now.Add(time.Second), RoomID: response.RTC.RoomID, RTCUserID: response.RTC.UserID,
|
||||
RTCJoinResult: "success", ConfigRevision: 1,
|
||||
RTCJoinResult: "success", ConfigRevision: desiredRevision,
|
||||
}, now.Add(2*time.Second))
|
||||
if err != nil || !activation.StartRequested || activation.Response.Status != "STARTING_AGENT" {
|
||||
t.Fatalf("activation=%#v err=%v", activation, err)
|
||||
}
|
||||
duplicate, err := NewRepository().Activate(ctx, tx, identity, response.ConversationID, "request-activate-duplicate", ActivateInput{
|
||||
JoinedAt: now.Add(time.Second), RoomID: response.RTC.RoomID, RTCUserID: response.RTC.UserID,
|
||||
RTCJoinResult: "success", ConfigRevision: 1,
|
||||
RTCJoinResult: "success", ConfigRevision: desiredRevision,
|
||||
}, now.Add(3*time.Second))
|
||||
if err != nil || duplicate.StartRequested || duplicate.Response.Status != "STARTING_AGENT" {
|
||||
t.Fatalf("duplicate activation=%#v err=%v", duplicate, err)
|
||||
@@ -118,7 +126,7 @@ func TestRepositoryCreatesOnlyRoomIssuedStateBeforeActivation(t *testing.T) {
|
||||
heartbeatInput := HeartbeatInput{
|
||||
SequenceNo: 2, DeviceTime: now.Add(4 * time.Second), MonotonicMS: &monotonicMS,
|
||||
ConversationStatus: "ACTIVE", RTC: RTCHeartbeat{Joined: true, Uplink: true, Downlink: true},
|
||||
Network: Network{Type: "wifi"}, FirmwareVersion: "1.0.0", ReportedConfigRevision: 1,
|
||||
Network: Network{Type: "wifi"}, FirmwareVersion: "1.0.0", ReportedConfigRevision: desiredRevision,
|
||||
RecordingQueue: RecordingQueue{PendingSegments: 2}, CommandResults: []json.RawMessage{},
|
||||
}
|
||||
heartbeat, err := NewRepository().Heartbeat(ctx, tx, identity, response.ConversationID, heartbeatInput, now.Add(4*time.Second), time.Minute, 5*time.Minute, time.Minute)
|
||||
@@ -132,7 +140,7 @@ func TestRepositoryCreatesOnlyRoomIssuedStateBeforeActivation(t *testing.T) {
|
||||
t.Fatalf("stale heartbeat=%#v err=%v", stale, err)
|
||||
}
|
||||
heartbeatInput.SequenceNo = 3
|
||||
heartbeatInput.ReportedConfigRevision = 2
|
||||
heartbeatInput.ReportedConfigRevision = desiredRevision + 1
|
||||
if _, err := NewRepository().Heartbeat(ctx, tx, identity, response.ConversationID, heartbeatInput, now.Add(31*time.Second), time.Minute, 5*time.Minute, time.Minute); !errors.Is(err, ErrConfigVersionConflict) {
|
||||
t.Fatalf("future configuration revision error=%v", err)
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ import (
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/embeddingprovider"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
"data-terminal-cloud/internal/memorytool"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"data-terminal-cloud/internal/rtc"
|
||||
"data-terminal-cloud/internal/vikingmemory"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -29,21 +31,32 @@ type ServiceOptions struct {
|
||||
TokenTTL time.Duration
|
||||
HeartbeatInterval int
|
||||
RequirePublishedRouting bool
|
||||
HardwareBotID string
|
||||
HardwareTransport string
|
||||
SubtitleCallbackURL string
|
||||
SubtitleCallbackSecret []byte
|
||||
SubtitlePrimary bool
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
executor *idempotency.Executor
|
||||
repository *Repository
|
||||
audit *audit.Repository
|
||||
outbox *outbox.Store
|
||||
tokens rtc.TokenIssuer
|
||||
leases LeaseCache
|
||||
options ServiceOptions
|
||||
pool *pgxpool.Pool
|
||||
embedder embeddingprovider.Provider
|
||||
now func() time.Time
|
||||
viking *vikingmemory.Service
|
||||
executor *idempotency.Executor
|
||||
repository *Repository
|
||||
audit *audit.Repository
|
||||
outbox *outbox.Store
|
||||
tokens rtc.TokenIssuer
|
||||
leases LeaseCache
|
||||
options ServiceOptions
|
||||
pool *pgxpool.Pool
|
||||
embedder embeddingprovider.Provider
|
||||
hardwareMemoryTool *memorytool.TokenSigner
|
||||
hardwareMemoryToolURL string
|
||||
hardwareMemoryToolTTL time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (s *Service) ConfigureVikingMemory(memory *vikingmemory.Service) { s.viking = memory }
|
||||
|
||||
func (s *Service) ConfigureMemoryEmbedding(pool *pgxpool.Pool, provider embeddingprovider.Provider) error {
|
||||
if pool == nil || provider == nil {
|
||||
return errors.New("memory embedding pool and provider are required")
|
||||
@@ -53,6 +66,16 @@ func (s *Service) ConfigureMemoryEmbedding(pool *pgxpool.Pool, provider embeddin
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfigureHardwareMemoryTool(signer *memorytool.TokenSigner, endpoint string, ttl time.Duration) error {
|
||||
if signer == nil || strings.TrimSpace(endpoint) == "" || ttl <= 0 {
|
||||
return errors.New("hardware memory tool signer, endpoint and TTL are required")
|
||||
}
|
||||
s.hardwareMemoryTool = signer
|
||||
s.hardwareMemoryToolURL = strings.TrimSpace(endpoint)
|
||||
s.hardwareMemoryToolTTL = ttl
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewService(
|
||||
executor *idempotency.Executor,
|
||||
repository *Repository,
|
||||
@@ -71,6 +94,12 @@ func NewService(
|
||||
if options.VoiceProvider == "" {
|
||||
options.VoiceProvider = "mock"
|
||||
}
|
||||
if options.HardwareTransport == "" {
|
||||
options.HardwareTransport = "high_quality"
|
||||
}
|
||||
if options.HardwareTransport != "high_quality" && options.HardwareTransport != "low_load" {
|
||||
return nil, errors.New("hardware agent transport must be high_quality or low_load")
|
||||
}
|
||||
return &Service{executor: executor, repository: repository, audit: auditRepository, outbox: outboxStore,
|
||||
tokens: tokens, leases: leases, options: options, now: time.Now}, nil
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"data-terminal-cloud/internal/ratelimit"
|
||||
"data-terminal-cloud/internal/rtc"
|
||||
"data-terminal-cloud/internal/voiceprovider"
|
||||
"data-terminal-cloud/internal/volcsubtitle"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -207,6 +208,120 @@ func TestServiceIdempotentlyCreatesConversationWithoutPersistingToken(t *testing
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// A power loss changes the session outcome, not the validity of an already
|
||||
// prepared summary. Active sessions and other subjects remain excluded.
|
||||
for _, priorStatus := range []string{"COMPLETED", "FAILED", "ACTIVE"} {
|
||||
t.Run("continuity_"+priorStatus, func(t *testing.T) {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if _, err = tx.Exec(ctx, `UPDATE conversation SET status=$2 WHERE id=$1::uuid`, testPriorConversationID, priorStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summaries, err := selectRecentConversationSummaries(ctx, tx, "10000000-0000-0000-0000-000000000001", testSubjectID, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := 1
|
||||
if priorStatus == "ACTIVE" {
|
||||
want = 0
|
||||
}
|
||||
if len(summaries) != want {
|
||||
t.Fatalf("status=%s summaries=%d want=%d", priorStatus, len(summaries), want)
|
||||
}
|
||||
summaries, err = selectRecentConversationSummaries(ctx, tx, "10000000-0000-0000-0000-000000000001", "90000000-0000-0000-0000-000000009999", 3)
|
||||
if err != nil || len(summaries) != 0 {
|
||||
t.Fatalf("unrelated subject summaries=%d err=%v", len(summaries), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("hardware_terminal_extracts_latest_turns_once", func(t *testing.T) {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type)
|
||||
VALUES('10000000-0000-0000-0000-000000000001',$1::uuid,$2::uuid,'prior-agent-2',2,'agent','2026-08-23T10:01:06Z','2026-08-23T10:01:08Z','明白了。','device')`, testSubjectID, testPriorConversationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := enqueueHardwareContentExtraction(ctx, tx, outbox.NewStore(pool), "10000000-0000-0000-0000-000000000001", testPriorConversationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var count, version int
|
||||
if err := tx.QueryRow(ctx, `SELECT count(*),COALESCE(max((payload->>'generation_version')::int),0) FROM outbox_event WHERE aggregate_id=$1::uuid AND deduplication_key LIKE 'content-extraction-v1:%:hardware-terminal:%'`, testPriorConversationID).Scan(&count, &version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 || version != 2 {
|
||||
t.Fatalf("events=%d generation=%d; want 1 and 2", count, version)
|
||||
}
|
||||
})
|
||||
t.Run("native_subtitles_probe_then_primary_dedup_consent", func(t *testing.T) {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
_, err = tx.Exec(ctx, `INSERT INTO hardware_agent_session(tenant_id,conversation_id,provider_session_ref,bot_id,transport,session_nonce_digest,config_digest,status,capability_snapshot)
|
||||
VALUES('10000000-0000-0000-0000-000000000001',$1::uuid,'native-subtitle-fixture','bot','low_load',repeat('a',64),repeat('b',64),'COMPLETED','{"subtitle_device_user_id":"device","subtitle_agent_user_id":"bot","native_subtitle_primary":false}')`, testPriorConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO consent_record(tenant_id,subject_id,consent_type,policy_version,notice_digest,actor_user_id) VALUES('10000000-0000-0000-0000-000000000001',$1::uuid,'transcript_storage','native-fixture',repeat('a',64),'10000000-0000-0000-0000-000000000101')`, testSubjectID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var publicID string
|
||||
if err = tx.QueryRow(ctx, `SELECT public_id::text FROM conversation WHERE id=$1::uuid`, testPriorConversationID).Scan(&publicID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zero, one := int64(0), int64(1)
|
||||
packet := volcsubtitle.Packet{Type: "subtitle", Data: []volcsubtitle.Segment{
|
||||
{UserID: "device", RoundID: &zero, Sequence: &zero, Definite: true, Text: "第一句"},
|
||||
{UserID: "bot", RoundID: &zero, Sequence: &one, Definite: true, Paragraph: true, Text: "第二句"},
|
||||
}}
|
||||
svc := NewNativeSubtitleService(pool)
|
||||
result, err := svc.processNativeSubtitleTx(ctx, tx, publicID, packet)
|
||||
if err != nil || result.Mode != "probe" || result.Accepted != 0 {
|
||||
t.Fatalf("probe %+v %v", result, err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET capability_snapshot=jsonb_set(capability_snapshot,'{native_subtitle_primary}','true') WHERE conversation_id=$1::uuid`, testPriorConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err = svc.processNativeSubtitleTx(ctx, tx, publicID, packet)
|
||||
if err != nil || result.Accepted != 2 {
|
||||
t.Fatalf("primary %+v %v", result, err)
|
||||
}
|
||||
result, err = svc.processNativeSubtitleTx(ctx, tx, publicID, packet)
|
||||
if err != nil || result.Accepted != 0 || result.Duplicates != 2 {
|
||||
t.Fatalf("duplicate %+v %v", result, err)
|
||||
}
|
||||
var count, version int
|
||||
if err = tx.QueryRow(ctx, `SELECT count(*),max((payload->>'generation_version')::int) FROM outbox_event WHERE aggregate_id=$1::uuid AND deduplication_key LIKE 'content-extraction-v1:%:hardware-terminal:%'`, testPriorConversationID).Scan(&count, &version); err != nil || count != 1 || version != 3 {
|
||||
t.Fatalf("extraction count=%d version=%d err=%v", count, version, err)
|
||||
}
|
||||
packet.Data[0].UserID = "unknown"
|
||||
if _, err = svc.processNativeSubtitleTx(ctx, tx, publicID, packet); !errors.Is(err, ErrNativeSubtitleSpeaker) {
|
||||
t.Fatalf("unknown speaker: %v", err)
|
||||
}
|
||||
packet.Data[0].UserID = "device"
|
||||
packet.Data[0].Text = "conflicting final"
|
||||
if _, err = svc.processNativeSubtitleTx(ctx, tx, publicID, packet); !errors.Is(err, ErrHardwareEventConflict) {
|
||||
t.Fatalf("conflict: %v", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='transcript_storage'`, testSubjectID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = svc.processNativeSubtitleTx(ctx, tx, publicID, packet); !errors.Is(err, ErrTranscriptStorageNotAllowed) {
|
||||
t.Fatalf("withdrawal: %v", err)
|
||||
}
|
||||
})
|
||||
configService, err := opsconfig.NewService(pool, audit.NewRepository())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -141,6 +141,13 @@ func (s *TranscriptBatchService) submit(ctx context.Context, tx pgx.Tx, identity
|
||||
if retentionMode == "none" {
|
||||
return TranscriptBatchResponse{}, "", ErrTranscriptStorageNotAllowed
|
||||
}
|
||||
var nativePrimary bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM hardware_agent_session WHERE conversation_id=$1::uuid AND tenant_id=$2::uuid AND capability_snapshot->>'native_subtitle_primary'='true')`, internalID, identity.TenantID).Scan(&nativePrimary); err != nil {
|
||||
return TranscriptBatchResponse{}, "", err
|
||||
}
|
||||
if nativePrimary {
|
||||
return TranscriptBatchResponse{}, "", ErrConversationStateConflict
|
||||
}
|
||||
var hasConsentHistory, hasActiveConsent bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT
|
||||
|
||||
@@ -32,11 +32,12 @@ type Network struct {
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
DeviceTime time.Time `json:"device_time"`
|
||||
MonotonicMS *int64 `json:"monotonic_ms,omitempty"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
Network Network `json:"network"`
|
||||
RequestedGoal string `json:"requested_goal"`
|
||||
DeviceTime time.Time `json:"device_time"`
|
||||
MonotonicMS *int64 `json:"monotonic_ms,omitempty"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
Network Network `json:"network"`
|
||||
RequestedGoal string `json:"requested_goal"`
|
||||
ConfigRevision int64 `json:"-"`
|
||||
}
|
||||
|
||||
type RTCGrant struct {
|
||||
@@ -62,12 +63,98 @@ type Session struct {
|
||||
}
|
||||
|
||||
type CreateResponse struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Status conversation.Status `json:"status"`
|
||||
FailureCode string `json:"failure_code,omitempty"`
|
||||
RTC RTCGrant `json:"rtc"`
|
||||
Privacy Privacy `json:"privacy"`
|
||||
Session Session `json:"session"`
|
||||
Hardware *HardwareAgentCreateResponse `json:"hardware,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
RuntimeLegacyVoiceChat = "legacy_voicechat"
|
||||
RuntimeVolcHardwareAgent = "volc_hardware_agent"
|
||||
)
|
||||
|
||||
type HardwareAgentCapabilities struct {
|
||||
HardwareAgent bool `json:"hardware_agent"`
|
||||
SDKVersion string `json:"sdk_version"`
|
||||
Transports []string `json:"transports"`
|
||||
InputAudioFormat string `json:"input_audio_format"`
|
||||
OutputAudioFormat string `json:"output_audio_format"`
|
||||
ServerEventCallback bool `json:"server_event_callback"`
|
||||
NativeSubtitleCallback bool `json:"native_subtitle_callback"`
|
||||
TranscriptUpload bool `json:"transcript_upload"`
|
||||
DynamicSessionConfig bool `json:"dynamic_session_config"`
|
||||
CredentialRotation bool `json:"credential_rotation"`
|
||||
NearPCMRecording bool `json:"near_pcm_recording"`
|
||||
FarPCMRecording bool `json:"far_pcm_recording"`
|
||||
}
|
||||
|
||||
type HardwareAgentCreateInput struct {
|
||||
DeviceTime time.Time `json:"device_time"`
|
||||
MonotonicMS *int64 `json:"monotonic_ms,omitempty"`
|
||||
Capabilities HardwareAgentCapabilities `json:"capabilities"`
|
||||
Network Network `json:"network"`
|
||||
RequestedGoal string `json:"requested_goal"`
|
||||
ConfigRevision int64 `json:"config_revision"`
|
||||
}
|
||||
|
||||
type HardwareAgentRuntime struct {
|
||||
Provider string `json:"provider"`
|
||||
BotID string `json:"bot_id"`
|
||||
Transport string `json:"transport"`
|
||||
ProviderSessionRef string `json:"provider_session_ref"`
|
||||
SessionNonce string `json:"session_nonce"`
|
||||
ConfigDigest string `json:"config_digest"`
|
||||
WaitForSessionUpdate bool `json:"wait_for_session_update"`
|
||||
}
|
||||
|
||||
type HardwareAgentSessionConfig struct {
|
||||
DialogID string `json:"dialog_id"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
PersonaTone string `json:"persona_tone,omitempty"`
|
||||
AuthorizedContext string `json:"authorized_context"`
|
||||
PromptBundleVersion string `json:"prompt_bundle_version"`
|
||||
ToolURL string `json:"tool_url,omitempty"`
|
||||
ToolScopeToken string `json:"tool_scope_token,omitempty"`
|
||||
SubtitleCallbackURL string `json:"subtitle_callback_url,omitempty"`
|
||||
SubtitleCallbackSignature string `json:"subtitle_callback_signature,omitempty"`
|
||||
SubtitleSource string `json:"subtitle_source,omitempty"`
|
||||
}
|
||||
|
||||
type HardwareAgentCreateResponse struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Status string `json:"status"`
|
||||
RuntimeType string `json:"runtime_type"`
|
||||
Runtime HardwareAgentRuntime `json:"runtime"`
|
||||
SessionConfig HardwareAgentSessionConfig `json:"session_config"`
|
||||
Privacy Privacy `json:"privacy"`
|
||||
Session Session `json:"session"`
|
||||
}
|
||||
|
||||
type HardwareAgentEventInput struct {
|
||||
EventID string `json:"event_id"`
|
||||
SequenceNo int64 `json:"sequence_no"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
ProviderSessionRef string `json:"provider_session_ref"`
|
||||
SessionNonce string `json:"session_nonce"`
|
||||
ProviderConnectionRef string `json:"provider_connection_ref,omitempty"`
|
||||
BotID string `json:"bot_id,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
ConfigDigest string `json:"config_digest,omitempty"`
|
||||
DynamicConfigApplied bool `json:"dynamic_config_applied,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
type HardwareAgentEventResponse struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Status conversation.Status `json:"status"`
|
||||
FailureCode string `json:"failure_code,omitempty"`
|
||||
RTC RTCGrant `json:"rtc"`
|
||||
Privacy Privacy `json:"privacy"`
|
||||
Session Session `json:"session"`
|
||||
Action string `json:"action"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
AcceptedAt time.Time `json:"accepted_at"`
|
||||
}
|
||||
|
||||
type ActivateInput struct {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVikingStartupContextRequiresMatchingSubject(t *testing.T) {
|
||||
db := os.Getenv("TEST_DATABASE_URL")
|
||||
if db == "" {
|
||||
t.Skip("TEST_DATABASE_URL required")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := database.Open(ctx, db, "viking-context-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
for _, matches := range []bool{true, false} {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity := deviceauth.Identity{ID: "10000000-0000-0000-0000-000000000501", PublicID: "10000000-0000-0000-0000-000000000511", TenantID: "10000000-0000-0000-0000-000000000001", Status: "registered"}
|
||||
subject := "10000000-0000-0000-0000-000000000401"
|
||||
if !matches {
|
||||
subject = "10000000-0000-0000-0000-000000000402"
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE consent_snapshot SET effective_consents='{"voice_chat":true,"transcript_storage":true,"story_extraction":true,"personalized_memory":true}' WHERE tenant_id=$1::uuid`, identity.TenantID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, internalID, err := NewRepository().Create(ctx, tx, identity, CreateInput{RequestedGoal: "open_conversation"}, AllocationOptions{Now: now, AllocationExpires: now.Add(time.Minute), TokenExpires: now.Add(5 * time.Minute), RTCAppID: "test-app", EngineMode: "s2s_hybrid", HeartbeatInterval: 15, VikingSubjectID: subject, VikingContext: "独立记忆测试:喜欢茉莉花茶"})
|
||||
if err != nil {
|
||||
tx.Rollback(ctx)
|
||||
t.Fatal(err)
|
||||
}
|
||||
var summary string
|
||||
err = tx.QueryRow(ctx, `SELECT cx.sanitized_summary FROM conversation c JOIN context_snapshot cx ON cx.id=c.context_snapshot_id WHERE c.id=$1::uuid`, internalID).Scan(&summary)
|
||||
tx.Rollback(ctx)
|
||||
if err != nil || strings.Contains(summary, "独立记忆测试") != matches {
|
||||
t.Fatalf("matches=%v summary=%s err=%v", matches, summary, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ type deletionObjectStore interface {
|
||||
}
|
||||
|
||||
type DeleteProcessor struct {
|
||||
viking interface {
|
||||
DeleteSubject(context.Context, string, string) error
|
||||
}
|
||||
pool *pgxpool.Pool
|
||||
redis *redis.Client
|
||||
store deletionObjectStore
|
||||
@@ -54,6 +57,12 @@ func NewDeleteProcessor(pool *pgxpool.Pool, redisClient *redis.Client, store del
|
||||
return &DeleteProcessor{pool: pool, redis: redisClient, store: store, audit: audit.NewRepository(), providerMemory: providerClient}, nil
|
||||
}
|
||||
|
||||
func (p *DeleteProcessor) ConfigureVikingMemory(memory interface {
|
||||
DeleteSubject(context.Context, string, string) error
|
||||
}) {
|
||||
p.viking = memory
|
||||
}
|
||||
|
||||
func (p *DeleteProcessor) Handle(ctx context.Context, event outbox.Event) error {
|
||||
request, err := p.loadRequest(ctx, event.AggregateID)
|
||||
if err != nil || request.id == "" || request.status == "COMPLETED" {
|
||||
@@ -205,6 +214,11 @@ func (p *DeleteProcessor) executeSubtask(ctx context.Context, request deletionRe
|
||||
_, err := p.pool.Exec(ctx, `UPDATE memory_index_entry SET embedding=NULL,provider_ref=NULL,status='DELETED',last_error=NULL,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND index_type='pgvector'`, request.tenantID, request.subjectID)
|
||||
return "COMPLETED", "", err
|
||||
case "provider_index":
|
||||
if p.viking != nil {
|
||||
if err := p.viking.DeleteSubject(ctx, request.tenantID, request.subjectID); err != nil {
|
||||
return "", "", outbox.Retryable(err)
|
||||
}
|
||||
}
|
||||
rows, err := p.pool.Query(ctx, `SELECT memory_version_id::text,provider_ref FROM memory_index_entry WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND index_type='provider' AND provider_ref IS NOT NULL AND status<>'DELETED' ORDER BY created_at,id`, request.tenantID, request.subjectID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
|
||||
@@ -27,6 +27,7 @@ type ConversationSummary struct {
|
||||
SubjectID string `json:"subject_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Status string `json:"status"`
|
||||
RuntimeType string `json:"runtime_type"`
|
||||
EngineMode string `json:"engine_mode"`
|
||||
RetentionMode string `json:"retention_mode"`
|
||||
AudioRetentionEnabled bool `json:"audio_retention_enabled"`
|
||||
@@ -71,12 +72,23 @@ type RTCTaskSummary struct {
|
||||
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||||
}
|
||||
|
||||
type HardwareAgentTaskSummary struct {
|
||||
Provider string `json:"provider"`
|
||||
Status string `json:"status"`
|
||||
Transport string `json:"transport"`
|
||||
ProviderStartedAt *time.Time `json:"provider_started_at,omitempty"`
|
||||
ProviderEndedAt *time.Time `json:"provider_ended_at,omitempty"`
|
||||
LastEventAt *time.Time `json:"last_event_at,omitempty"`
|
||||
LastEventSequence int64 `json:"last_event_sequence"`
|
||||
}
|
||||
|
||||
type ConversationDetail struct {
|
||||
ConversationSummary
|
||||
HouseholdID string `json:"household_id"`
|
||||
Snapshots ConversationSnapshotSummary `json:"snapshots"`
|
||||
RTC *RTCTaskSummary `json:"rtc,omitempty"`
|
||||
History []ConversationStateEntry `json:"history"`
|
||||
HouseholdID string `json:"household_id"`
|
||||
Snapshots ConversationSnapshotSummary `json:"snapshots"`
|
||||
RTC *RTCTaskSummary `json:"rtc,omitempty"`
|
||||
HardwareAgent *HardwareAgentTaskSummary `json:"hardware_agent,omitempty"`
|
||||
History []ConversationStateEntry `json:"history"`
|
||||
}
|
||||
|
||||
type ConversationUsageSummary struct {
|
||||
@@ -109,7 +121,7 @@ func (s *Service) ListSubjectConversations(ctx context.Context, principal identi
|
||||
cursorID = &decoded.ID
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.public_id::text,$1::text,d.public_id::text,c.status,c.engine_mode,c.retention_mode,
|
||||
SELECT c.public_id::text,$1::text,d.public_id::text,c.status,c.runtime_type,c.engine_mode,c.retention_mode,
|
||||
c.started_at,c.ended_at,CASE WHEN c.started_at IS NOT NULL AND c.ended_at IS NOT NULL THEN (EXTRACT(EPOCH FROM (c.ended_at-c.started_at))*1000)::bigint END,
|
||||
c.stop_reason,c.failure_code,c.created_at,c.updated_at
|
||||
FROM conversation c JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id
|
||||
@@ -154,13 +166,17 @@ func (s *Service) GetConversation(ctx context.Context, principal identity.Princi
|
||||
var rtcProvider, rtcStatus *string
|
||||
var rtcLeaseExpiresAt, rtcLastCheckedAt *time.Time
|
||||
var rtcStartAttempts, rtcStopAttempts *int
|
||||
var hardwareProvider, hardwareStatus, hardwareTransport *string
|
||||
var hardwareStartedAt, hardwareEndedAt, hardwareLastEventAt *time.Time
|
||||
var hardwareLastSequence *int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT c.public_id::text,sp.public_id::text,d.public_id::text,c.status,c.engine_mode,c.retention_mode,
|
||||
SELECT c.public_id::text,sp.public_id::text,d.public_id::text,c.status,c.runtime_type,c.engine_mode,c.retention_mode,
|
||||
c.started_at,c.ended_at,CASE WHEN c.started_at IS NOT NULL AND c.ended_at IS NOT NULL THEN (EXTRACT(EPOCH FROM (c.ended_at-c.started_at))*1000)::bigint END,
|
||||
c.stop_reason,c.failure_code,c.created_at,c.updated_at,h.public_id::text,
|
||||
cs.public_id::text,cs.snapshot_version,xs.public_id::text,xs.profile_version,xs.prompt_bundle_version,
|
||||
xs.preparation_status,xs.preparation_failure_code,
|
||||
r.provider,r.status,r.lease_expires_at,r.start_attempts,r.stop_attempts,r.last_checked_at
|
||||
r.provider,r.status,r.lease_expires_at,r.start_attempts,r.stop_attempts,r.last_checked_at,
|
||||
hs.provider,hs.status,hs.transport,hs.provider_started_at,hs.provider_ended_at,hs.last_event_at,hs.last_event_sequence
|
||||
FROM conversation c
|
||||
JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id AND h.status <> 'deleted'
|
||||
JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id AND sp.status IN ('active','suspended')
|
||||
@@ -168,14 +184,16 @@ func (s *Service) GetConversation(ctx context.Context, principal identity.Princi
|
||||
JOIN consent_snapshot cs ON cs.id=c.consent_snapshot_id AND cs.tenant_id=c.tenant_id
|
||||
JOIN context_snapshot xs ON xs.id=c.context_snapshot_id AND xs.tenant_id=c.tenant_id
|
||||
LEFT JOIN rtc_task r ON r.conversation_id=c.id
|
||||
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id AND hs.tenant_id=c.tenant_id
|
||||
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
|
||||
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
|
||||
WHERE c.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, conversationPublicID, principal.Issuer, principal.Subject).Scan(
|
||||
&result.ID, &result.SubjectID, &result.DeviceID, &result.Status, &result.EngineMode, &result.RetentionMode,
|
||||
&result.ID, &result.SubjectID, &result.DeviceID, &result.Status, &result.RuntimeType, &result.EngineMode, &result.RetentionMode,
|
||||
&result.StartedAt, &result.EndedAt, &duration, &result.StopReason, &result.FailureCode, &result.CreatedAt, &result.UpdatedAt, &result.HouseholdID,
|
||||
&result.Snapshots.ConsentSnapshotID, &result.Snapshots.ConsentSnapshotVersion, &result.Snapshots.ContextSnapshotID, &result.Snapshots.ProfileVersion, &result.Snapshots.PromptBundleVersion,
|
||||
&result.Snapshots.ContextPreparationStatus, &result.Snapshots.ContextPreparationFailureCode,
|
||||
&rtcProvider, &rtcStatus, &rtcLeaseExpiresAt, &rtcStartAttempts, &rtcStopAttempts, &rtcLastCheckedAt)
|
||||
&rtcProvider, &rtcStatus, &rtcLeaseExpiresAt, &rtcStartAttempts, &rtcStopAttempts, &rtcLastCheckedAt,
|
||||
&hardwareProvider, &hardwareStatus, &hardwareTransport, &hardwareStartedAt, &hardwareEndedAt, &hardwareLastEventAt, &hardwareLastSequence)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ConversationDetail{}, ErrAccessDenied
|
||||
}
|
||||
@@ -196,6 +214,19 @@ func (s *Service) GetConversation(ctx context.Context, principal identity.Princi
|
||||
result.RTC.StopAttempts = *rtcStopAttempts
|
||||
}
|
||||
}
|
||||
if hardwareProvider != nil {
|
||||
result.HardwareAgent = &HardwareAgentTaskSummary{Provider: *hardwareProvider, ProviderStartedAt: hardwareStartedAt,
|
||||
ProviderEndedAt: hardwareEndedAt, LastEventAt: hardwareLastEventAt}
|
||||
if hardwareStatus != nil {
|
||||
result.HardwareAgent.Status = *hardwareStatus
|
||||
}
|
||||
if hardwareTransport != nil {
|
||||
result.HardwareAgent.Transport = *hardwareTransport
|
||||
}
|
||||
if hardwareLastSequence != nil {
|
||||
result.HardwareAgent.LastEventSequence = *hardwareLastSequence
|
||||
}
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT h.from_status,h.to_status,h.reason,h.source,h.occurred_at
|
||||
FROM conversation_state_history h JOIN conversation c ON c.id=h.conversation_id
|
||||
@@ -264,7 +295,7 @@ func (s *Service) GetConversationUsage(ctx context.Context, principal identity.P
|
||||
|
||||
func scanConversationSummary(row rowScanner) (ConversationSummary, error) {
|
||||
var item ConversationSummary
|
||||
if err := row.Scan(&item.ID, &item.SubjectID, &item.DeviceID, &item.Status, &item.EngineMode, &item.RetentionMode,
|
||||
if err := row.Scan(&item.ID, &item.SubjectID, &item.DeviceID, &item.Status, &item.RuntimeType, &item.EngineMode, &item.RetentionMode,
|
||||
&item.StartedAt, &item.EndedAt, &item.DurationMS, &item.StopReason, &item.FailureCode, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return ConversationSummary{}, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -106,6 +107,22 @@ func (s *Service) ChangeDeviceBinding(ctx context.Context, principal identity.Pr
|
||||
result.BindingID = &newBindingID
|
||||
result.HouseholdID = &targetAccess.householdPublicID
|
||||
result.SubjectID = &input.TargetSubjectID
|
||||
} else {
|
||||
var activationState string
|
||||
if err := tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, access.deviceID, access.tenantID).Scan(&activationState); err != nil {
|
||||
return fmt.Errorf("read unbound device activation: %w", err)
|
||||
}
|
||||
if activationState == string(deviceactivation.Bound) || activationState == string(deviceactivation.Provisioning) ||
|
||||
activationState == string(deviceactivation.Active) || activationState == string(deviceactivation.Lost) {
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: access.deviceID, TenantID: access.tenantID,
|
||||
Target: deviceactivation.Claimable, Reason: "用户解除设备绑定", Source: "user-unbind", ActorType: "user",
|
||||
ActorPublicID: &access.userPublicID, RequestID: requestID}); err != nil {
|
||||
return fmt.Errorf("transition unbound device activation: %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'`, access.deviceID); err != nil {
|
||||
return fmt.Errorf("suspend unbound device provider credential: %w", err)
|
||||
}
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{
|
||||
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -43,15 +44,15 @@ func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal,
|
||||
return fmt.Errorf("resolve claim subject: %w", err)
|
||||
}
|
||||
|
||||
var claimID, deviceID, devicePublicID string
|
||||
var claimID, deviceID, devicePublicID, activationState string
|
||||
var expiresAt time.Time
|
||||
var revokedAt, usedAt *time.Time
|
||||
var usedBindingID *string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT dc.id::text,dc.device_id::text,d.public_id::text,dc.expires_at,dc.revoked_at,dc.used_at,dc.used_binding_id::text
|
||||
SELECT dc.id::text,dc.device_id::text,d.public_id::text,d.activation_state,dc.expires_at,dc.revoked_at,dc.used_at,dc.used_binding_id::text
|
||||
FROM device_claim dc JOIN device d ON d.id=dc.device_id AND d.tenant_id=dc.tenant_id
|
||||
WHERE dc.token_digest=$1 AND dc.tenant_id=$2::uuid AND d.status='registered'
|
||||
FOR UPDATE OF dc`, digest, access.tenantID).Scan(&claimID, &deviceID, &devicePublicID, &expiresAt, &revokedAt, &usedAt, &usedBindingID); errors.Is(err, pgx.ErrNoRows) {
|
||||
FOR UPDATE OF d,dc`, digest, access.tenantID).Scan(&claimID, &deviceID, &devicePublicID, &activationState, &expiresAt, &revokedAt, &usedAt, &usedBindingID); errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrDeviceClaimInvalid
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("resolve device claim: %w", err)
|
||||
@@ -78,6 +79,9 @@ func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal,
|
||||
replayed = true
|
||||
return nil
|
||||
}
|
||||
if activationState != string(deviceactivation.Claimable) {
|
||||
return ErrDeviceClaimInvalid
|
||||
}
|
||||
var activeBinding bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, deviceID).Scan(&activeBinding); err != nil {
|
||||
return fmt.Errorf("check active device binding: %w", err)
|
||||
@@ -97,6 +101,27 @@ func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal,
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_claim SET used_at=now(),used_binding_id=$1::uuid WHERE id=$2::uuid`, bindingInternalID, claimID); err != nil {
|
||||
return fmt.Errorf("mark device claim used: %w", err)
|
||||
}
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
|
||||
Target: deviceactivation.Bound, Reason: "用户通过一次性绑定码认领设备", Source: "device-claim", ActorType: "user",
|
||||
ActorPublicID: strPtr(access.userPublicID), RequestID: requestID}); err != nil {
|
||||
return fmt.Errorf("activate claimed device binding: %w", err)
|
||||
}
|
||||
var runtime string
|
||||
if err := tx.QueryRow(ctx, `SELECT COALESCE(desired_config->>'voice_runtime','legacy_voicechat') FROM device_twin WHERE device_id=$1::uuid`, deviceID).Scan(&runtime); err != nil {
|
||||
return fmt.Errorf("read claimed device runtime: %w", err)
|
||||
}
|
||||
if runtime == "legacy_voicechat" {
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
|
||||
Target: deviceactivation.Provisioning, Reason: "旧版语音运行时平台凭据预配", Source: "platform-provisioning", ActorType: "user",
|
||||
ActorPublicID: strPtr(access.userPublicID), RequestID: requestID}); err != nil {
|
||||
return fmt.Errorf("provision claimed legacy device: %w", err)
|
||||
}
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
|
||||
Target: deviceactivation.Active, Reason: "旧版语音运行时已具备绑定与平台凭据", Source: "platform-provisioning", ActorType: "user",
|
||||
ActorPublicID: strPtr(access.userPublicID), RequestID: requestID}); err != nil {
|
||||
return fmt.Errorf("activate claimed legacy device: %w", err)
|
||||
}
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{
|
||||
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
|
||||
Action: "device.claim", ResourceType: "device_binding", ResourceID: strPtr(result.BindingID),
|
||||
|
||||
@@ -43,6 +43,7 @@ func (s *Service) ConfigureFirmwareStore(store firmwareObjectStore, ttl time.Dur
|
||||
|
||||
type FirmwareRelease struct {
|
||||
ID string `json:"id"`
|
||||
HouseholdID string `json:"household_id,omitempty"`
|
||||
Model string `json:"model"`
|
||||
HardwareRevision string `json:"hardware_revision,omitempty"`
|
||||
Version string `json:"version"`
|
||||
@@ -53,6 +54,10 @@ type FirmwareRelease struct {
|
||||
SigningKeyID string `json:"signing_key_id"`
|
||||
MinimumSecurityVersion string `json:"minimum_security_version,omitempty"`
|
||||
ReleaseNotes string `json:"release_notes,omitempty"`
|
||||
ReleaseChannel string `json:"release_channel"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
CreatedSource string `json:"created_source"`
|
||||
Status string `json:"status"`
|
||||
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -64,7 +69,7 @@ type FirmwareRelease struct {
|
||||
type FirmwareRollout struct {
|
||||
ID string `json:"id"`
|
||||
ReleaseID string `json:"release_id"`
|
||||
HouseholdID string `json:"household_id"`
|
||||
HouseholdID string `json:"household_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
TargetPercentage int `json:"target_percentage"`
|
||||
@@ -172,6 +177,8 @@ type FirmwareImpactDevice struct {
|
||||
type PrepareFirmwareInput struct {
|
||||
Model, HardwareRevision, Version, ExpectedDigest, Signature, SigningKeyID, MinimumSecurityVersion, ReleaseNotes string
|
||||
ExpectedSize int64
|
||||
ReleaseChannel string
|
||||
Mandatory bool
|
||||
}
|
||||
|
||||
func (s *Service) RegisterFirmwareSigningKey(ctx context.Context, p identity.Principal, householdPublicID, requestID, keyID, publicKeyBase64 string) error {
|
||||
@@ -312,7 +319,7 @@ func (s *Service) PrepareFirmwareRelease(ctx context.Context, p identity.Princip
|
||||
}
|
||||
tenantHash := sha256.Sum256([]byte(a.tenantID))
|
||||
objectRef := fmt.Sprintf("tenants/%s/firmware/%s/%s/%s.bin", hex.EncodeToString(tenantHash[:16]), safePath(in.Model), safePath(in.HardwareRevision), safePath(in.Version))
|
||||
err = tx.QueryRow(ctx, `INSERT INTO firmware_release(tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,created_by) VALUES($1::uuid,$2::uuid,$3,NULLIF($4,''),$5,$6,$7,$8,$9,$10,NULLIF($11,''),NULLIF($12,''),$13::uuid) RETURNING public_id::text,model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),status,verified_at,created_at,updated_at,object_ref`, a.tenantID, a.householdID, in.Model, in.HardwareRevision, in.Version, objectRef, in.ExpectedSize, in.ExpectedDigest, in.Signature, in.SigningKeyID, in.MinimumSecurityVersion, in.ReleaseNotes, a.userID).Scan(firmwareReleaseTargets(&item)...)
|
||||
err = tx.QueryRow(ctx, `INSERT INTO firmware_release(tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,release_channel,mandatory,created_source,created_by) VALUES($1::uuid,$2::uuid,$3,NULLIF($4,''),$5,$6,$7,$8,$9,$10,NULLIF($11,''),NULLIF($12,''),$13,$14,'CUSTOMER',$15::uuid) RETURNING public_id::text,COALESCE(household_id::text,''),model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),release_channel,mandatory,published_at,created_source,status,verified_at,created_at,updated_at,object_ref`, a.tenantID, a.householdID, in.Model, in.HardwareRevision, in.Version, objectRef, in.ExpectedSize, in.ExpectedDigest, in.Signature, in.SigningKeyID, in.MinimumSecurityVersion, in.ReleaseNotes, normalizeReleaseChannel(in.ReleaseChannel), in.Mandatory, a.userID).Scan(firmwareReleaseTargets(&item)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -336,7 +343,15 @@ func safePath(v string) string {
|
||||
return r.Replace(v)
|
||||
}
|
||||
func firmwareReleaseTargets(i *FirmwareRelease) []any {
|
||||
return []any{&i.ID, &i.Model, &i.HardwareRevision, &i.Version, &i.ExpectedSize, &i.ExpectedDigest, &i.SignatureAlgorithm, &i.Signature, &i.SigningKeyID, &i.MinimumSecurityVersion, &i.ReleaseNotes, &i.Status, &i.VerifiedAt, &i.CreatedAt, &i.UpdatedAt, &i.objectRef}
|
||||
return []any{&i.ID, &i.HouseholdID, &i.Model, &i.HardwareRevision, &i.Version, &i.ExpectedSize, &i.ExpectedDigest, &i.SignatureAlgorithm, &i.Signature, &i.SigningKeyID, &i.MinimumSecurityVersion, &i.ReleaseNotes, &i.ReleaseChannel, &i.Mandatory, &i.PublishedAt, &i.CreatedSource, &i.Status, &i.VerifiedAt, &i.CreatedAt, &i.UpdatedAt, &i.objectRef}
|
||||
}
|
||||
|
||||
func normalizeReleaseChannel(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value == "BETA" || value == "INTERNAL" {
|
||||
return value
|
||||
}
|
||||
return "STABLE"
|
||||
}
|
||||
|
||||
func (s *Service) ListFirmwareReleases(ctx context.Context, p identity.Principal, householdPublicID string) ([]FirmwareRelease, error) {
|
||||
@@ -346,7 +361,7 @@ func (s *Service) ListFirmwareReleases(ctx context.Context, p identity.Principal
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `SELECT public_id::text,model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),status,verified_at,created_at,updated_at,object_ref FROM firmware_release WHERE tenant_id=$1::uuid AND household_id=$2::uuid ORDER BY created_at DESC LIMIT 100`, a.tenantID, a.householdID)
|
||||
rows, err := tx.Query(ctx, `SELECT public_id::text,COALESCE(household_id::text,''),model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),release_channel,mandatory,published_at,created_source,status,verified_at,created_at,updated_at,object_ref FROM firmware_release WHERE tenant_id=$1::uuid AND household_id=$2::uuid ORDER BY created_at DESC LIMIT 100`, a.tenantID, a.householdID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -698,7 +713,7 @@ func (s *Service) resolveFirmwareRelease(ctx context.Context, p identity.Princip
|
||||
var item FirmwareRelease
|
||||
var a resourceAccess
|
||||
var role string
|
||||
err := s.pool.QueryRow(ctx, `SELECT fr.public_id::text,fr.model,COALESCE(fr.hardware_revision,''),fr.version,fr.expected_size,fr.expected_digest,fr.signature_algorithm,fr.signature,fr.signing_key_id,COALESCE(fr.minimum_security_version,''),COALESCE(fr.release_notes,''),fr.status,fr.verified_at,fr.created_at,fr.updated_at,fr.object_ref,fr.tenant_id::text,ua.id::text,ua.public_id::text,hm.role,h.id::text,h.public_id::text FROM firmware_release fr JOIN household h ON h.id=fr.household_id AND h.tenant_id=fr.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=fr.tenant_id AND ua.status='active' WHERE fr.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 LIMIT 1`, publicID, p.Issuer, p.Subject).Scan(append(firmwareReleaseTargets(&item), &a.tenantID, &a.userID, &a.userPublicID, &role, &a.householdID, &a.householdPublicID)...)
|
||||
err := s.pool.QueryRow(ctx, `SELECT fr.public_id::text,COALESCE(fr.household_id::text,''),fr.model,COALESCE(fr.hardware_revision,''),fr.version,fr.expected_size,fr.expected_digest,fr.signature_algorithm,fr.signature,fr.signing_key_id,COALESCE(fr.minimum_security_version,''),COALESCE(fr.release_notes,''),fr.release_channel,fr.mandatory,fr.published_at,fr.created_source,fr.status,fr.verified_at,fr.created_at,fr.updated_at,fr.object_ref,fr.tenant_id::text,ua.id::text,ua.public_id::text,hm.role,h.id::text,h.public_id::text FROM firmware_release fr JOIN household h ON h.id=fr.household_id AND h.tenant_id=fr.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=fr.tenant_id AND ua.status='active' WHERE fr.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 LIMIT 1`, publicID, p.Issuer, p.Subject).Scan(append(firmwareReleaseTargets(&item), &a.tenantID, &a.userID, &a.userPublicID, &role, &a.householdID, &a.householdPublicID)...)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return FirmwareRelease{}, resourceAccess{}, ErrAccessDenied
|
||||
}
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
package saas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type opsFirmwareAccess struct {
|
||||
tenantID, tenantPublicID, userID, userPublicID, role string
|
||||
}
|
||||
|
||||
type FirmwareDownload struct {
|
||||
ReleaseID string `json:"release_id"`
|
||||
Version string `json:"version"`
|
||||
ExpectedSize int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Signature string `json:"signature"`
|
||||
SigningKeyID string `json:"signing_key_id"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
DownloadUntil time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func resolveOpsFirmwareAccess(ctx context.Context, q queryRow, principal identity.Principal, tenantPublicID string, mutate bool) (opsFirmwareAccess, error) {
|
||||
var access opsFirmwareAccess
|
||||
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 opsFirmwareAccess{}, ErrAccessDenied
|
||||
}
|
||||
if err != nil {
|
||||
return opsFirmwareAccess{}, fmt.Errorf("resolve operations firmware access: %w", err)
|
||||
}
|
||||
if mutate && access.role != "ops_operator" && access.role != "ops_admin" {
|
||||
return opsFirmwareAccess{}, ErrAccessDenied
|
||||
}
|
||||
return access, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListOpsFirmwareSigningKeys(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]FirmwareSigningKey, error) {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, s.pool, principal, tenantPublicID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.listFirmwareSigningKeys(ctx, access.tenantID)
|
||||
}
|
||||
|
||||
func (s *Service) RegisterOpsFirmwareSigningKey(ctx context.Context, principal identity.Principal, tenantPublicID, requestID, keyID, publicKeyBase64 string) error {
|
||||
decoded, err := base64.StdEncoding.DecodeString(publicKeyBase64)
|
||||
if err != nil || len(decoded) != ed25519.PublicKeySize || base64.StdEncoding.EncodeToString(decoded) != publicKeyBase64 || keyID == "" || len([]rune(keyID)) > 120 {
|
||||
return errors.New("invalid Ed25519 public key")
|
||||
}
|
||||
return database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var existingPublicKey, status string
|
||||
err = tx.QueryRow(ctx, `SELECT public_key_base64,status FROM firmware_signing_key WHERE tenant_id=$1::uuid AND key_id=$2 FOR UPDATE`, access.tenantID, keyID).Scan(&existingPublicKey, &status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO firmware_signing_key(tenant_id,key_id,public_key_base64,created_by) VALUES($1::uuid,$2,$3,$4::uuid)`, access.tenantID, keyID, publicKeyBase64, access.userID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else if existingPublicKey != publicKeyBase64 || status != "ACTIVE" {
|
||||
return ErrFirmwareSigningKeyConflict
|
||||
}
|
||||
fingerprint := sha256.Sum256(decoded)
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.signing_key.register", ResourceType: "firmware_signing_key", RequestID: requestID, Result: "success", Detail: map[string]any{"key_id": keyID, "fingerprint_sha256": hex.EncodeToString(fingerprint[:])}})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) RevokeOpsFirmwareSigningKey(ctx context.Context, principal identity.Principal, tenantPublicID, requestID, keyID string) (FirmwareSigningKey, error) {
|
||||
var internalTenantID string
|
||||
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
internalTenantID = access.tenantID
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, `SELECT status FROM firmware_signing_key WHERE tenant_id=$1::uuid AND key_id=$2 FOR UPDATE`, access.tenantID, keyID).Scan(&status); errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrFirmwareSigningKeyConflict
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == "REVOKED" {
|
||||
return nil
|
||||
}
|
||||
var blocked bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(
|
||||
SELECT 1 FROM firmware_release fr WHERE fr.tenant_id=$1::uuid AND fr.signing_key_id=$2 AND fr.status='UPLOADING'
|
||||
UNION ALL
|
||||
SELECT 1 FROM firmware_rollout ro JOIN firmware_release fr ON fr.id=ro.release_id AND fr.tenant_id=ro.tenant_id WHERE ro.tenant_id=$1::uuid AND fr.signing_key_id=$2 AND ro.status='RUNNING'
|
||||
)`, access.tenantID, keyID).Scan(&blocked); err != nil {
|
||||
return err
|
||||
}
|
||||
if blocked {
|
||||
return ErrFirmwareSigningKeyInUse
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE firmware_signing_key SET status='REVOKED',revoked_at=now() WHERE tenant_id=$1::uuid AND key_id=$2 AND status='ACTIVE'`, access.tenantID, keyID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.signing_key.revoke", ResourceType: "firmware_signing_key", RequestID: requestID, Result: "success", Detail: map[string]any{"key_id": keyID}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareSigningKey{}, err
|
||||
}
|
||||
items, err := s.listFirmwareSigningKeys(ctx, internalTenantID)
|
||||
if err != nil {
|
||||
return FirmwareSigningKey{}, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.KeyID == keyID {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return FirmwareSigningKey{}, ErrFirmwareSigningKeyConflict
|
||||
}
|
||||
|
||||
func (s *Service) ListOpsFirmwareReleases(ctx context.Context, principal identity.Principal, tenantPublicID, model, status string) ([]FirmwareRelease, error) {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, s.pool, principal, tenantPublicID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT public_id::text,COALESCE(household_id::text,''),model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),release_channel,mandatory,published_at,created_source,status,verified_at,created_at,updated_at,object_ref
|
||||
FROM firmware_release WHERE tenant_id=$1::uuid AND (NULLIF($2,'') IS NULL OR model=$2) AND (NULLIF($3,'') IS NULL OR status=$3)
|
||||
ORDER BY created_at DESC LIMIT 200`, access.tenantID, strings.TrimSpace(model), strings.ToUpper(strings.TrimSpace(status)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list operations firmware releases: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]FirmwareRelease, 0)
|
||||
for rows.Next() {
|
||||
var item FirmwareRelease
|
||||
if err := rows.Scan(firmwareReleaseTargets(&item)...); err != nil {
|
||||
return nil, fmt.Errorf("scan operations firmware release: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) PrepareOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, requestID string, input PrepareFirmwareInput) (FirmwareRelease, error) {
|
||||
if s.firmwareStore == nil {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
var item FirmwareRelease
|
||||
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var signingKeyActive bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM firmware_signing_key WHERE tenant_id=$1::uuid AND key_id=$2 AND status='ACTIVE')`, access.tenantID, input.SigningKeyID).Scan(&signingKeyActive); err != nil {
|
||||
return err
|
||||
}
|
||||
if !signingKeyActive {
|
||||
return ErrFirmwareSigningKeyInactive
|
||||
}
|
||||
tenantHash := sha256.Sum256([]byte(access.tenantID))
|
||||
objectRef := fmt.Sprintf("tenants/%s/firmware/%s/%s/%s.bin", hex.EncodeToString(tenantHash[:16]), safePath(input.Model), safePath(input.HardwareRevision), safePath(input.Version))
|
||||
err = tx.QueryRow(ctx, `INSERT INTO firmware_release(tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,release_channel,mandatory,created_source,created_by)
|
||||
VALUES($1::uuid,NULL,$2,NULLIF($3,''),$4,$5,$6,$7,$8,$9,NULLIF($10,''),NULLIF($11,''),$12,$13,'OPS',$14::uuid)
|
||||
RETURNING public_id::text,COALESCE(household_id::text,''),model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),release_channel,mandatory,published_at,created_source,status,verified_at,created_at,updated_at,object_ref`,
|
||||
access.tenantID, input.Model, input.HardwareRevision, input.Version, objectRef, input.ExpectedSize, input.ExpectedDigest, input.Signature, input.SigningKeyID, input.MinimumSecurityVersion, input.ReleaseNotes, normalizeReleaseChannel(input.ReleaseChannel), input.Mandatory, access.userID).Scan(firmwareReleaseTargets(&item)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.prepare", ResourceType: "firmware_release", ResourceID: &item.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"model": input.Model, "version": input.Version, "digest": input.ExpectedDigest, "release_channel": item.ReleaseChannel, "mandatory": item.Mandatory}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
url, err := s.firmwareStore.PresignUpload(ctx, item.objectRef, 15*time.Minute)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
until := time.Now().Add(15 * time.Minute)
|
||||
item.UploadURL, item.UploadUntil = url, &until
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID string, mutate bool) (FirmwareRelease, opsFirmwareAccess, error) {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, s.pool, principal, tenantPublicID, mutate)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, opsFirmwareAccess{}, err
|
||||
}
|
||||
var item FirmwareRelease
|
||||
err = s.pool.QueryRow(ctx, `SELECT public_id::text,COALESCE(household_id::text,''),model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),release_channel,mandatory,published_at,created_source,status,verified_at,created_at,updated_at,object_ref
|
||||
FROM firmware_release WHERE tenant_id=$1::uuid AND public_id=$2::uuid`, access.tenantID, releasePublicID).Scan(firmwareReleaseTargets(&item)...)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return FirmwareRelease{}, opsFirmwareAccess{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, opsFirmwareAccess{}, err
|
||||
}
|
||||
return item, access, nil
|
||||
}
|
||||
|
||||
func (s *Service) FinalizeOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID string) (FirmwareRelease, error) {
|
||||
item, access, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, true)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
if item.Status == "READY" {
|
||||
return item, nil
|
||||
}
|
||||
if item.Status != "UPLOADING" || s.firmwareStore == nil {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
if err := s.firmwareStore.VerifyDigest(ctx, item.objectRef, item.ExpectedDigest, item.ExpectedSize); err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
var publicKeyBase64 string
|
||||
if err := s.pool.QueryRow(ctx, `SELECT public_key_base64 FROM firmware_signing_key WHERE tenant_id=$1::uuid AND key_id=$2 AND status='ACTIVE'`, access.tenantID, item.SigningKeyID).Scan(&publicKeyBase64); err != nil {
|
||||
return FirmwareRelease{}, ErrFirmwareSigningKeyInactive
|
||||
}
|
||||
publicKey, keyErr := base64.StdEncoding.DecodeString(publicKeyBase64)
|
||||
signature, signatureErr := base64.StdEncoding.DecodeString(item.Signature)
|
||||
if keyErr != nil || signatureErr != nil || len(publicKey) != ed25519.PublicKeySize || !ed25519.Verify(ed25519.PublicKey(publicKey), []byte(firmwareManifest(item.Model, item.HardwareRevision, item.Version, item.ExpectedDigest, item.ExpectedSize)), signature) {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
command, err := tx.Exec(ctx, `UPDATE firmware_release SET status='READY',verified_at=now(),published_at=COALESCE(published_at,now()),updated_at=now() WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status='UPLOADING'`, access.tenantID, releasePublicID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrFirmwareStateConflict
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.ready", ResourceType: "firmware_release", ResourceID: &releasePublicID, RequestID: requestID, Result: "success", Detail: map[string]any{"digest": item.ExpectedDigest, "size": item.ExpectedSize}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
return s.currentOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID)
|
||||
}
|
||||
|
||||
func (s *Service) RenewOpsFirmwareUpload(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID string) (FirmwareRelease, error) {
|
||||
item, access, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, true)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
if item.Status != "UPLOADING" || s.firmwareStore == nil {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
url, err := s.firmwareStore.PresignUpload(ctx, item.objectRef, 15*time.Minute)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
until := time.Now().Add(15 * time.Minute)
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
var currentStatus string
|
||||
if err := tx.QueryRow(ctx, `SELECT status FROM firmware_release WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR SHARE`, access.tenantID, releasePublicID).Scan(¤tStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
if currentStatus != "UPLOADING" {
|
||||
return ErrFirmwareStateConflict
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.upload_renew", ResourceType: "firmware_release", ResourceID: &releasePublicID, RequestID: requestID, Result: "success", Detail: map[string]any{"upload_until": until}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
item.UploadURL, item.UploadUntil = url, &until
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) PauseOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID string) (FirmwareRelease, error) {
|
||||
item, access, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, true)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
if item.Status == "PAUSED" {
|
||||
return item, nil
|
||||
}
|
||||
if item.Status != "READY" {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
var releaseID string
|
||||
if err := tx.QueryRow(ctx, `UPDATE firmware_release SET status='PAUSED',updated_at=now() WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status='READY' RETURNING id::text`, access.tenantID, releasePublicID).Scan(&releaseID); err != nil {
|
||||
return ErrFirmwareStateConflict
|
||||
}
|
||||
paused, err := tx.Exec(ctx, `UPDATE firmware_rollout SET status='PAUSED',updated_at=now() WHERE release_id=$1::uuid AND status='RUNNING'`, releaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cancelled, err := tx.Exec(ctx, `UPDATE device_command dc SET status='CANCELLED',completed_at=now(),version=version+1,updated_at=now() FROM firmware_assignment fa JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE fa.command_id=dc.id AND ro.release_id=$1::uuid AND ro.status='PAUSED' AND dc.status='QUEUED'`, releaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.pause", ResourceType: "firmware_release", ResourceID: &releasePublicID, RequestID: requestID, Result: "success", Detail: map[string]any{"paused_rollouts": paused.RowsAffected(), "cancelled_commands": cancelled.RowsAffected()}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
return s.currentOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID)
|
||||
}
|
||||
|
||||
func (s *Service) ResumeOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID string) (FirmwareRelease, error) {
|
||||
item, access, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, true)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
if item.Status == "READY" {
|
||||
return item, nil
|
||||
}
|
||||
if item.Status != "PAUSED" {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
command, err := tx.Exec(ctx, `UPDATE firmware_release fr SET status='READY',updated_at=now() FROM firmware_signing_key fsk WHERE fr.tenant_id=$1::uuid AND fr.public_id=$2::uuid AND fr.status='PAUSED' AND fsk.tenant_id=fr.tenant_id AND fsk.key_id=fr.signing_key_id AND fsk.status='ACTIVE'`, access.tenantID, releasePublicID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrFirmwareSigningKeyInactive
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.resume", ResourceType: "firmware_release", ResourceID: &releasePublicID, RequestID: requestID, Result: "success"})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
return s.currentOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID)
|
||||
}
|
||||
|
||||
func (s *Service) RetireOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID string) (FirmwareRelease, error) {
|
||||
item, access, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, true)
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
if item.Status != "UPLOADING" && item.Status != "READY" && item.Status != "PAUSED" && item.Status != "RETIRED" {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
removeObject := item.Status == "UPLOADING" || item.Status == "RETIRED" && item.VerifiedAt == nil
|
||||
if item.Status != "RETIRED" {
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
var releaseID, status string
|
||||
var verifiedAt *time.Time
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text,status,verified_at FROM firmware_release WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, access.tenantID, releasePublicID).Scan(&releaseID, &status, &verifiedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if status == "READY" || status == "PAUSED" {
|
||||
var running bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM firmware_rollout WHERE release_id=$1::uuid AND status='RUNNING')`, releaseID).Scan(&running); err != nil {
|
||||
return err
|
||||
}
|
||||
if running {
|
||||
return ErrFirmwareReleaseInUse
|
||||
}
|
||||
removeObject = false
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE firmware_release SET status='RETIRED',updated_at=now() WHERE id=$1::uuid AND status=$2`, releaseID, status); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.retire", ResourceType: "firmware_release", ResourceID: &releasePublicID, RequestID: requestID, Result: "success", Detail: map[string]any{"from_status": status, "object_preserved": verifiedAt != nil}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRelease{}, err
|
||||
}
|
||||
item.Status, item.UpdatedAt = "RETIRED", time.Now()
|
||||
}
|
||||
if removeObject {
|
||||
if s.firmwareStore == nil {
|
||||
return FirmwareRelease{}, ErrFirmwareStateConflict
|
||||
}
|
||||
if err := s.firmwareStore.Remove(ctx, item.objectRef); err != nil {
|
||||
return FirmwareRelease{}, fmt.Errorf("remove retired operations firmware object: %w", err)
|
||||
}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) currentOpsFirmwareRelease(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID string) (FirmwareRelease, error) {
|
||||
item, _, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, false)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) OpsFirmwareDownload(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID string) (FirmwareDownload, error) {
|
||||
item, access, err := s.resolveOpsFirmwareRelease(ctx, principal, tenantPublicID, releasePublicID, false)
|
||||
if err != nil {
|
||||
return FirmwareDownload{}, err
|
||||
}
|
||||
if item.VerifiedAt == nil || (item.Status != "READY" && item.Status != "PAUSED" && item.Status != "RETIRED") || s.firmwareStore == nil {
|
||||
return FirmwareDownload{}, ErrFirmwareStateConflict
|
||||
}
|
||||
ttl := s.firmwareURLTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 5 * time.Minute
|
||||
}
|
||||
url, err := s.firmwareStore.PresignDownload(ctx, item.objectRef, ttl, "firmware-"+safePath(item.Model)+"-"+safePath(item.Version)+".bin")
|
||||
if err != nil {
|
||||
return FirmwareDownload{}, err
|
||||
}
|
||||
until := time.Now().Add(ttl)
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.release.download", ResourceType: "firmware_release", ResourceID: &releasePublicID, RequestID: requestID, Result: "success", Detail: map[string]any{"expires_at": until}})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareDownload{}, err
|
||||
}
|
||||
return FirmwareDownload{ReleaseID: item.ID, Version: item.Version, ExpectedSize: item.ExpectedSize, SHA256: item.ExpectedDigest, Signature: item.Signature, SigningKeyID: item.SigningKeyID, DownloadURL: url, DownloadUntil: until}, nil
|
||||
}
|
||||
|
||||
func (s *Service) StartOpsFirmwareRollout(ctx context.Context, principal identity.Principal, tenantPublicID, releasePublicID, requestID, name string, percentage int, deviceIDs []string) (FirmwareRollout, error) {
|
||||
if strings.TrimSpace(name) == "" || percentage < 1 || percentage > 100 || len(deviceIDs) == 0 {
|
||||
return FirmwareRollout{}, ErrFirmwareStateConflict
|
||||
}
|
||||
deviceIDs = append([]string(nil), deviceIDs...)
|
||||
sort.Strings(deviceIDs)
|
||||
uniqueDeviceIDs := deviceIDs[:0]
|
||||
for _, deviceID := range deviceIDs {
|
||||
if len(uniqueDeviceIDs) == 0 || uniqueDeviceIDs[len(uniqueDeviceIDs)-1] != deviceID {
|
||||
uniqueDeviceIDs = append(uniqueDeviceIDs, deviceID)
|
||||
}
|
||||
}
|
||||
deviceIDs = uniqueDeviceIDs
|
||||
var result FirmwareRollout
|
||||
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var releaseID, model, hardware, version, digest, signature, keyID string
|
||||
err = tx.QueryRow(ctx, `SELECT fr.id::text,fr.model,COALESCE(fr.hardware_revision,''),fr.version,fr.expected_digest,fr.signature,fr.signing_key_id
|
||||
FROM firmware_release fr JOIN firmware_signing_key fsk ON fsk.tenant_id=fr.tenant_id AND fsk.key_id=fr.signing_key_id AND fsk.status='ACTIVE'
|
||||
WHERE fr.tenant_id=$1::uuid AND fr.public_id=$2::uuid AND fr.status='READY' FOR SHARE OF fr,fsk`, access.tenantID, releasePublicID).
|
||||
Scan(&releaseID, &model, &hardware, &version, &digest, &signature, &keyID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrFirmwareStateConflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var rolloutID string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO firmware_rollout(tenant_id,household_id,release_id,name,status,target_percentage,created_by,started_at)
|
||||
VALUES($1::uuid,NULL,$2::uuid,$3,'RUNNING',$4,$5::uuid,now()) RETURNING id::text,public_id::text,created_at,updated_at`,
|
||||
access.tenantID, releaseID, strings.TrimSpace(name), percentage, access.userID).Scan(&rolloutID, &result.ID, &result.CreatedAt, &result.UpdatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.ReleaseID, result.Name, result.Status, result.TargetPercentage = releasePublicID, strings.TrimSpace(name), "RUNNING", percentage
|
||||
selected := 0
|
||||
for _, devicePublicID := range deviceIDs {
|
||||
hash := sha256.Sum256([]byte(devicePublicID))
|
||||
if int(hash[0])%100 >= percentage && selected > 0 {
|
||||
continue
|
||||
}
|
||||
var deviceID, previous string
|
||||
err := tx.QueryRow(ctx, `SELECT d.id::text,COALESCE(dt.firmware_version,'') FROM device d JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
|
||||
WHERE d.tenant_id=$1::uuid AND d.public_id=$2::uuid AND d.status='registered' AND d.model=$3 AND ($4='' OR d.hardware_revision=$4)`,
|
||||
access.tenantID, devicePublicID, model, hardware).Scan(&deviceID, &previous)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: rollout target %s is incompatible or inaccessible", ErrFirmwareStateConflict, devicePublicID)
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"release_id": releasePublicID, "version": version, "sha256": digest, "signature": signature, "signing_key_id": keyID, "download_path": "/device/v2/firmware-releases/" + releasePublicID + "/download"})
|
||||
requestDigest := sha256.Sum256(payload)
|
||||
var commandID string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO device_command(tenant_id,device_id,command_type,payload,requested_by,idempotency_key,request_digest,expires_at)
|
||||
VALUES($1::uuid,$2::uuid,'firmware_update',$3::jsonb,$4::uuid,$5,$6,now()+interval '24 hours') RETURNING id::text`,
|
||||
access.tenantID, deviceID, payload, access.userID, "ops-firmware-rollout:"+rolloutID+":"+deviceID, hex.EncodeToString(requestDigest[:])).Scan(&commandID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO firmware_assignment(rollout_id,tenant_id,device_id,command_id,previous_version,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,NULLIF($5,''),'ASSIGNED')`, rolloutID, access.tenantID, deviceID, commandID, previous); err != nil {
|
||||
return err
|
||||
}
|
||||
selected++
|
||||
}
|
||||
if selected == 0 {
|
||||
return errors.New("rollout selected no devices")
|
||||
}
|
||||
result.Targeted = selected
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.rollout.start", ResourceType: "firmware_rollout", ResourceID: &result.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"release_id": releasePublicID, "targeted": selected, "percentage": percentage}})
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) ListOpsFirmwareRollouts(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]FirmwareRollout, error) {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, s.pool, principal, tenantPublicID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT ro.public_id::text,fr.public_id::text,COALESCE(h.public_id::text,''),ro.name,ro.status,ro.target_percentage,
|
||||
count(fa.id),count(fa.id) FILTER(WHERE fa.status='CONFIRMED'),count(fa.id) FILTER(WHERE fa.status='FAILED'),ro.created_at,ro.updated_at
|
||||
FROM firmware_rollout ro JOIN firmware_release fr ON fr.id=ro.release_id LEFT JOIN household h ON h.id=ro.household_id
|
||||
LEFT JOIN firmware_assignment fa ON fa.rollout_id=ro.id WHERE ro.tenant_id=$1::uuid
|
||||
GROUP BY ro.id,fr.public_id,h.public_id ORDER BY ro.created_at DESC LIMIT 200`, access.tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]FirmwareRollout, 0)
|
||||
for rows.Next() {
|
||||
var item FirmwareRollout
|
||||
var targeted, succeeded, failed int64
|
||||
if err := rows.Scan(&item.ID, &item.ReleaseID, &item.HouseholdID, &item.Name, &item.Status, &item.TargetPercentage, &targeted, &succeeded, &failed, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Targeted, item.Succeeded, item.Failed = int(targeted), int(succeeded), int(failed)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) GetOpsFirmwareRollout(ctx context.Context, principal identity.Principal, tenantPublicID, rolloutPublicID string) (FirmwareRollout, error) {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, s.pool, principal, tenantPublicID, false)
|
||||
if err != nil {
|
||||
return FirmwareRollout{}, err
|
||||
}
|
||||
var item FirmwareRollout
|
||||
var targeted, succeeded, failed int64
|
||||
err = s.pool.QueryRow(ctx, `SELECT ro.public_id::text,fr.public_id::text,COALESCE(h.public_id::text,''),ro.name,ro.status,ro.target_percentage,
|
||||
count(fa.id),count(fa.id) FILTER(WHERE fa.status='CONFIRMED'),count(fa.id) FILTER(WHERE fa.status='FAILED'),ro.created_at,ro.updated_at
|
||||
FROM firmware_rollout ro JOIN firmware_release fr ON fr.id=ro.release_id LEFT JOIN household h ON h.id=ro.household_id
|
||||
LEFT JOIN firmware_assignment fa ON fa.rollout_id=ro.id WHERE ro.tenant_id=$1::uuid AND ro.public_id=$2::uuid
|
||||
GROUP BY ro.id,fr.public_id,h.public_id`, access.tenantID, rolloutPublicID).
|
||||
Scan(&item.ID, &item.ReleaseID, &item.HouseholdID, &item.Name, &item.Status, &item.TargetPercentage, &targeted, &succeeded, &failed, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return FirmwareRollout{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return FirmwareRollout{}, err
|
||||
}
|
||||
item.Targeted, item.Succeeded, item.Failed = int(targeted), int(succeeded), int(failed)
|
||||
item.Assignments, err = s.listFirmwareAssignments(ctx, rolloutPublicID)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) PauseOpsFirmwareRollout(ctx context.Context, principal identity.Principal, tenantPublicID, rolloutPublicID, requestID string) (FirmwareRollout, error) {
|
||||
access, err := resolveOpsFirmwareAccess(ctx, s.pool, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return FirmwareRollout{}, err
|
||||
}
|
||||
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
command, err := tx.Exec(ctx, `UPDATE firmware_rollout SET status='PAUSED',updated_at=now() WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status='RUNNING'`, access.tenantID, rolloutPublicID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrFirmwareStateConflict
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_command dc SET status='CANCELLED',completed_at=now(),version=version+1,updated_at=now() FROM firmware_assignment fa JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE fa.command_id=dc.id AND ro.public_id=$1::uuid AND dc.status='QUEUED'`, rolloutPublicID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.firmware.rollout.pause", ResourceType: "firmware_rollout", ResourceID: &rolloutPublicID, RequestID: requestID, Result: "success"})
|
||||
})
|
||||
if err != nil {
|
||||
return FirmwareRollout{}, err
|
||||
}
|
||||
return s.GetOpsFirmwareRollout(ctx, principal, tenantPublicID, rolloutPublicID)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package saas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestOpsFirmwareReleaseLifecycle(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
store := &firmwareIntegrationStore{}
|
||||
service := NewService(pool, audit.NewRepository(), nil, nil)
|
||||
service.ConfigureFirmwareStore(store, 4*time.Minute)
|
||||
var operator identity.Principal
|
||||
if err := pool.QueryRow(ctx, `SELECT oidc_issuer,oidc_subject FROM user_account WHERE public_id='10000000-0000-0000-0000-000000000114'::uuid AND status='active' AND account_kind='operator'`).Scan(&operator.Issuer, &operator.Subject); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const tenantID = "10000000-0000-0000-0000-000000000010"
|
||||
keyID := "ops-firmware-" + uuid.NewString()
|
||||
version := "ops-" + uuid.NewString()
|
||||
var releaseID, rolloutID string
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'ops-firmware-integration-%'`)
|
||||
if rolloutID != "" {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM firmware_rollout WHERE public_id=$1::uuid`, rolloutID)
|
||||
}
|
||||
if releaseID != "" {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_command WHERE payload->>'release_id'=$1`, releaseID)
|
||||
}
|
||||
if releaseID != "" {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM firmware_release WHERE public_id=$1::uuid`, releaseID)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM firmware_signing_key WHERE key_id=$1`, keyID)
|
||||
}()
|
||||
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RegisterOpsFirmwareSigningKey(ctx, operator, tenantID, "ops-firmware-integration-key", keyID, base64.StdEncoding.EncodeToString(publicKey)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firmware := []byte("synthetic operations firmware")
|
||||
digestBytes := sha256.Sum256(firmware)
|
||||
digest := hex.EncodeToString(digestBytes[:])
|
||||
model, hardware := "simulated-terminal", "sim-rev-a"
|
||||
signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(firmwareManifest(model, hardware, version, digest, int64(len(firmware))))))
|
||||
prepared, err := service.PrepareOpsFirmwareRelease(ctx, operator, tenantID, "ops-firmware-integration-prepare", PrepareFirmwareInput{
|
||||
Model: model, HardwareRevision: hardware, Version: version, ExpectedSize: int64(len(firmware)), ExpectedDigest: digest,
|
||||
Signature: signature, SigningKeyID: keyID, ReleaseChannel: "BETA", Mandatory: true, ReleaseNotes: "维护端集成测试",
|
||||
})
|
||||
if err != nil || prepared.Status != "UPLOADING" || prepared.HouseholdID != "" || prepared.CreatedSource != "OPS" || prepared.ReleaseChannel != "BETA" || !prepared.Mandatory || prepared.UploadURL == "" {
|
||||
t.Fatalf("prepared=%+v err=%v", prepared, err)
|
||||
}
|
||||
releaseID = prepared.ID
|
||||
ready, err := service.FinalizeOpsFirmwareRelease(ctx, operator, tenantID, releaseID, "ops-firmware-integration-finalize")
|
||||
if err != nil || ready.Status != "READY" || ready.VerifiedAt == nil || ready.PublishedAt == nil || store.verifiedDigest != digest {
|
||||
t.Fatalf("ready=%+v store=%+v err=%v", ready, store, err)
|
||||
}
|
||||
items, err := service.ListOpsFirmwareReleases(ctx, operator, tenantID, model, "READY")
|
||||
if err != nil || len(items) != 1 || items[0].ID != releaseID {
|
||||
t.Fatalf("items=%+v err=%v", items, err)
|
||||
}
|
||||
download, err := service.OpsFirmwareDownload(ctx, operator, tenantID, releaseID, "ops-firmware-integration-download")
|
||||
if err != nil || download.ReleaseID != releaseID || download.SHA256 != digest || download.DownloadURL == "" {
|
||||
t.Fatalf("download=%+v err=%v", download, err)
|
||||
}
|
||||
rollout, err := service.StartOpsFirmwareRollout(ctx, operator, tenantID, releaseID, "ops-firmware-integration-rollout", "维护端OTA", 100, []string{"10000000-0000-0000-0000-000000000511"})
|
||||
if err != nil || rollout.Targeted != 1 || rollout.HouseholdID != "" || rollout.Status != "RUNNING" {
|
||||
t.Fatalf("rollout=%+v err=%v", rollout, err)
|
||||
}
|
||||
rolloutID = rollout.ID
|
||||
detail, err := service.GetOpsFirmwareRollout(ctx, operator, tenantID, rolloutID)
|
||||
if err != nil || len(detail.Assignments) != 1 || detail.Assignments[0].Status != "ASSIGNED" {
|
||||
t.Fatalf("rollout detail=%+v err=%v", detail, err)
|
||||
}
|
||||
pausedRollout, err := service.PauseOpsFirmwareRollout(ctx, operator, tenantID, rolloutID, "ops-firmware-integration-rollout-pause")
|
||||
if err != nil || pausedRollout.Status != "PAUSED" {
|
||||
t.Fatalf("paused rollout=%+v err=%v", pausedRollout, err)
|
||||
}
|
||||
paused, err := service.PauseOpsFirmwareRelease(ctx, operator, tenantID, releaseID, "ops-firmware-integration-pause")
|
||||
if err != nil || paused.Status != "PAUSED" {
|
||||
t.Fatalf("paused=%+v err=%v", paused, err)
|
||||
}
|
||||
resumed, err := service.ResumeOpsFirmwareRelease(ctx, operator, tenantID, releaseID, "ops-firmware-integration-resume")
|
||||
if err != nil || resumed.Status != "READY" {
|
||||
t.Fatalf("resumed=%+v err=%v", resumed, err)
|
||||
}
|
||||
retired, err := service.RetireOpsFirmwareRelease(ctx, operator, tenantID, releaseID, "ops-firmware-integration-retire")
|
||||
if err != nil || retired.Status != "RETIRED" || len(store.removed) != 0 {
|
||||
t.Fatalf("retired=%+v removed=%v err=%v", retired, store.removed, err)
|
||||
}
|
||||
customer := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
|
||||
if _, err := service.ListOpsFirmwareReleases(ctx, customer, tenantID, "", ""); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("customer ops access error=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -574,25 +574,26 @@ func (s *Service) stopConversationsAfterConsentWithdrawal(
|
||||
return []string{}, nil
|
||||
}
|
||||
type candidate struct {
|
||||
internalID, publicID, status string
|
||||
internalID, publicID, status, runtimeType string
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT c.id::text,c.public_id::text,c.status
|
||||
SELECT c.id::text,c.public_id::text,c.status,c.runtime_type
|
||||
FROM conversation c
|
||||
JOIN consent_snapshot cs ON cs.id=c.consent_snapshot_id AND cs.tenant_id=c.tenant_id
|
||||
JOIN rtc_task rt ON rt.conversation_id=c.id
|
||||
LEFT JOIN rtc_task rt ON rt.conversation_id=c.id
|
||||
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id
|
||||
WHERE c.tenant_id=$1::uuid AND c.subject_id=$2::uuid
|
||||
AND c.status IN ('PREPARING','ROOM_ISSUED','STARTING_AGENT','ACTIVE')
|
||||
AND cs.effective_consents @> jsonb_build_object($3::text,true)
|
||||
ORDER BY c.created_at,c.id
|
||||
FOR UPDATE OF c,rt`, tenantID, subjectID, consentType)
|
||||
FOR UPDATE OF c`, tenantID, subjectID, consentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find conversations affected by consent withdrawal: %w", err)
|
||||
}
|
||||
candidates := make([]candidate, 0)
|
||||
for rows.Next() {
|
||||
var item candidate
|
||||
if err := rows.Scan(&item.internalID, &item.publicID, &item.status); err != nil {
|
||||
if err := rows.Scan(&item.internalID, &item.publicID, &item.status, &item.runtimeType); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("scan consent withdrawal conversation: %w", err)
|
||||
}
|
||||
@@ -621,7 +622,11 @@ func (s *Service) stopConversationsAfterConsentWithdrawal(
|
||||
if tag.RowsAffected() != 1 {
|
||||
return nil, fmt.Errorf("conversation changed during consent withdrawal")
|
||||
}
|
||||
if toStatus == "EXPIRED" {
|
||||
if item.runtimeType == "volc_hardware_agent" && toStatus == "EXPIRED" {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='EXPIRED',updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
|
||||
} else if item.runtimeType == "volc_hardware_agent" {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='STOPPING',updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
|
||||
} else if toStatus == "EXPIRED" {
|
||||
_, err = tx.Exec(ctx, `UPDATE rtc_task SET status='COMPLETED',lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `UPDATE rtc_task SET status=CASE WHEN status='ACTIVE' THEN 'STOPPING' ELSE 'UNKNOWN' END,lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
|
||||
@@ -634,7 +639,7 @@ func (s *Service) stopConversationsAfterConsentWithdrawal(
|
||||
VALUES($1::uuid,$2,$3,$4,'platform')`, item.internalID, item.status, toStatus, "consent withdrawn: "+consentType); err != nil {
|
||||
return nil, fmt.Errorf("record consent withdrawal stop: %w", err)
|
||||
}
|
||||
if toStatus == "STOPPING" {
|
||||
if toStatus == "STOPPING" && item.runtimeType != "volc_hardware_agent" {
|
||||
deduplicationKey := "consent-withdrawal-stop:" + item.internalID + ":" + consentType
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"conversation_id": item.publicID,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// Package vikingmemory integrates Volcano's managed memory collection, not OpenViking.
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
)
|
||||
|
||||
const AssistantID = "platform_companion_v1"
|
||||
|
||||
type Options struct {
|
||||
BaseURL, ResourceID, APIKey, EventType, ProfileType string
|
||||
Timeout time.Duration
|
||||
}
|
||||
type Client struct {
|
||||
options Options
|
||||
http *http.Client
|
||||
}
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
RoleID string `json:"role_id"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
UserID []string `json:"user_id"`
|
||||
AssistantID []string `json:"assistant_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Score float64 `json:"score"`
|
||||
MemoryInfo map[string]json.RawMessage `json:"memory_info"`
|
||||
Content string `json:"-"`
|
||||
}
|
||||
|
||||
func NewClient(options Options) (*Client, error) {
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api-knowledgebase.mlp.cn-beijing.volces.com"
|
||||
}
|
||||
u, e := url.Parse(options.BaseURL)
|
||||
if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return nil, errors.New("Viking requires an HTTPS endpoint")
|
||||
}
|
||||
if options.ResourceID == "" || strings.TrimSpace(options.APIKey) == "" || options.EventType == "" || options.ProfileType == "" || options.Timeout <= 0 {
|
||||
return nil, errors.New("Viking resource, key, event/profile types and timeout are required")
|
||||
}
|
||||
options.BaseURL = strings.TrimRight(options.BaseURL, "/")
|
||||
options.APIKey = strings.TrimSpace(options.APIKey)
|
||||
return &Client{options: options, http: &http.Client{Timeout: options.Timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
|
||||
}
|
||||
func UserID(tenantID, subjectID string) string {
|
||||
digest := sha256.Sum256([]byte(tenantID + ":" + subjectID))
|
||||
return "u_" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
func SessionID(conversationID string, batch int) string {
|
||||
return "c_" + strings.ReplaceAll(conversationID, "-", "") + fmt.Sprintf("_%d", batch)
|
||||
}
|
||||
func (c *Client) call(ctx context.Context, path string, payload map[string]any, target any) error {
|
||||
payload["resource_id"] = c.options.ResourceID
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.options.BaseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.options.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return outbox.Retryable(errors.New("Viking request unavailable"))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Code *int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
err = json.NewDecoder(io.LimitReader(resp.Body, 2*1024*1024)).Decode(&result)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 || err != nil || result.Code == nil || *result.Code != 0 {
|
||||
code := -1
|
||||
if result.Code != nil {
|
||||
code = *result.Code
|
||||
}
|
||||
failure := fmt.Errorf("Viking %s HTTP %d code %d", path, resp.StatusCode, code)
|
||||
if resp.StatusCode == 429 || resp.StatusCode >= 500 || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
|
||||
return outbox.Retryable(failure)
|
||||
}
|
||||
return failure
|
||||
}
|
||||
if target != nil {
|
||||
if err := json.Unmarshal(result.Data, target); err != nil {
|
||||
return errors.New("Viking returned invalid data")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Client) AddSession(ctx context.Context, sessionID, userID string, messages []Message, when int64) error {
|
||||
if sessionID == "" || userID == "" || len(messages) == 0 || len(messages) > 100 {
|
||||
return errors.New("invalid Viking session")
|
||||
}
|
||||
return c.call(ctx, "/api/memory/session/add", map[string]any{"session_id": sessionID, "messages": messages, "metadata": map[string]any{"default_user_id": userID, "default_assistant_id": AssistantID, "time": when}, "extract_memory_type": []string{c.options.EventType, c.options.ProfileType}}, nil)
|
||||
}
|
||||
func (c *Client) Search(ctx context.Context, userID, query string, limit int) ([]Item, error) {
|
||||
if userID == "" || limit < 1 || limit > 5 {
|
||||
return nil, errors.New("Viking user and bounded limit are required")
|
||||
}
|
||||
items := []Item{}
|
||||
remaining := 800
|
||||
for _, kind := range []struct{ path, rule string }{{"profile", c.options.ProfileType}, {"event", c.options.EventType}} {
|
||||
payload := map[string]any{"filter": map[string]any{"user_id": []string{userID}, "assistant_id": []string{AssistantID}, "memory_type": []string{kind.rule}}, "limit": limit}
|
||||
if strings.TrimSpace(query) != "" {
|
||||
payload["query"] = query
|
||||
}
|
||||
var data struct {
|
||||
Items []Item `json:"result_list"`
|
||||
}
|
||||
if err := c.call(ctx, "/api/memory/"+kind.path+"/search", payload, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range data.Items {
|
||||
if !contains(item.UserID, userID) || !contains(item.AssistantID, AssistantID) {
|
||||
continue
|
||||
}
|
||||
parts := []string{}
|
||||
for _, key := range []string{"summary", "user_profile"} {
|
||||
raw := item.MemoryInfo[key]
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(raw, &text) != nil {
|
||||
text = string(raw)
|
||||
}
|
||||
if text != "" && text != "{}" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
content := []rune(strings.Join(parts, ";"))
|
||||
if len(content) > 400 {
|
||||
content = content[:400]
|
||||
}
|
||||
if len(content) > remaining {
|
||||
content = content[:remaining]
|
||||
}
|
||||
if len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
remaining -= len(content)
|
||||
item.Content = string(content)
|
||||
items = append(items, item)
|
||||
if len(items) >= limit || remaining <= 0 {
|
||||
return items, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func contains(values []string, expected string) bool {
|
||||
for _, v := range values {
|
||||
if v == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Client) DeleteUser(ctx context.Context, userID string) error {
|
||||
if userID == "" {
|
||||
return errors.New("Viking deletion requires an exact user")
|
||||
}
|
||||
for _, kind := range []string{"event", "profile"} {
|
||||
if err := c.call(ctx, "/api/memory/"+kind+"/batch_delete", map[string]any{"delete_type": "full", "filter": map[string]any{"user_id": []string{userID}, "assistant_id": []string{AssistantID}}}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testClient(t *testing.T, handler http.HandlerFunc) *Client {
|
||||
t.Helper()
|
||||
server := httptest.NewTLSServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
c, err := NewClient(Options{BaseURL: server.URL, ResourceID: "mem-test", APIKey: "test-secret", EventType: "event_v1", ProfileType: "profile_v1", Timeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.http = server.Client()
|
||||
return c
|
||||
}
|
||||
func TestSearchBindsUserAndRejectsForeignResults(t *testing.T) {
|
||||
calls := 0
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
var input map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&input)
|
||||
filter := input["filter"].(map[string]any)
|
||||
if filter["user_id"].([]any)[0] != "alice" || filter["assistant_id"].([]any)[0] != AssistantID || input["resource_id"] != "mem-test" {
|
||||
t.Errorf("incorrect scope: %v", input)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-secret" {
|
||||
t.Error("missing auth")
|
||||
}
|
||||
fmt.Fprint(w, `{"code":0,"data":{"result_list":[{"id":"foreign","user_id":["bob"],"assistant_id":["platform_companion_v1"],"memory_info":{"summary":"must not return"}},{"id":"ours","user_id":["alice"],"assistant_id":["platform_companion_v1"],"memory_info":{"summary":"喜欢茶"}}]}}`)
|
||||
})
|
||||
items, err := c.Search(context.Background(), "alice", "茶", 2)
|
||||
if err != nil || len(items) != 2 || items[0].ID != "ours" || calls != 2 {
|
||||
t.Fatalf("items=%v calls=%d err=%v", items, calls, err)
|
||||
}
|
||||
if _, err = c.Search(context.Background(), "", "", 1); err == nil {
|
||||
t.Fatal("empty identity accepted")
|
||||
}
|
||||
}
|
||||
func TestVikingErrorsDoNotLeakServerBodyOrKey(t *testing.T) {
|
||||
for _, status := range []int{200, 401, 429, 503} {
|
||||
t.Run(fmt.Sprint(status), func(t *testing.T) {
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprint(w, `{"code":900,"message":"test-secret private transcript"}`)
|
||||
})
|
||||
err := c.AddSession(context.Background(), "c_test", "alice", []Message{{Role: "user", Content: "test"}}, 1)
|
||||
if err == nil || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "transcript") {
|
||||
t.Fatalf("unsafe error %v", err)
|
||||
}
|
||||
if outbox.IsRetryable(err) != (status != 401) {
|
||||
t.Fatalf("retry classification %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestStableNamespacedIdentityAndSessionIDs(t *testing.T) {
|
||||
if UserID("a", "x") == UserID("b", "x") || UserID("a", "x") == UserID("a", "y") {
|
||||
t.Fatal("identity collision")
|
||||
}
|
||||
id := SessionID("12345678-1234-1234-1234-123456789abc", 1)
|
||||
if !regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,127}$`).MatchString(id) || id == SessionID("12345678-1234-1234-1234-123456789abc", 0) {
|
||||
t.Fatal(id)
|
||||
}
|
||||
}
|
||||
func TestDeleteUsesExactUserForBothMemoryTypes(t *testing.T) {
|
||||
calls := 0
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
var input map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&input)
|
||||
f := input["filter"].(map[string]any)
|
||||
if f["user_id"].([]any)[0] != "alice" {
|
||||
t.Error("unscoped delete")
|
||||
}
|
||||
fmt.Fprint(w, `{"code":0}`)
|
||||
})
|
||||
if err := c.DeleteUser(context.Background(), ""); err == nil || calls != 0 {
|
||||
t.Fatal("empty deletion accepted")
|
||||
}
|
||||
if err := c.DeleteUser(context.Background(), "alice"); err != nil || calls != 2 {
|
||||
t.Fatal(err, calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const SyncEvent = "conversation.viking_sync_requested"
|
||||
const eligibleSQL = `SELECT EXISTS(SELECT 1 FROM subject_profile sp WHERE sp.id=$2::uuid AND sp.tenant_id=$1::uuid AND sp.status='active'
|
||||
AND NOT EXISTS(SELECT 1 FROM privacy_request pr WHERE pr.tenant_id=sp.tenant_id AND pr.subject_id=sp.id AND pr.request_type='DELETE')
|
||||
AND EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=sp.tenant_id AND cr.subject_id=sp.id AND cr.consent_type='personalized_memory' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))
|
||||
AND (NOT EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=sp.tenant_id AND cr.subject_id=sp.id AND cr.consent_type='transcript_storage')
|
||||
OR EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=sp.tenant_id AND cr.subject_id=sp.id AND cr.consent_type='transcript_storage' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))))`
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
client *Client
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, client *Client) *Service {
|
||||
return &Service{pool: pool, client: client}
|
||||
}
|
||||
func (s *Service) Eligible(ctx context.Context, tenant, subject string) (bool, error) {
|
||||
var ok bool
|
||||
err := s.pool.QueryRow(ctx, eligibleSQL, tenant, subject).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
func (s *Service) Search(ctx context.Context, tenant, subject, query string, limit int) ([]Item, error) {
|
||||
ok, err := s.Eligible(ctx, tenant, subject)
|
||||
if err != nil || !ok {
|
||||
return []Item{}, err
|
||||
}
|
||||
items, err := s.client.Search(ctx, UserID(tenant, subject), query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Recheck after network I/O so a concurrent withdrawal cannot expose results.
|
||||
ok, err = s.Eligible(ctx, tenant, subject)
|
||||
if err != nil || !ok {
|
||||
return []Item{}, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (s *Service) PrepareForDevice(ctx context.Context, tenant, device string) (string, string, error) {
|
||||
var subject string
|
||||
err := s.pool.QueryRow(ctx, `SELECT b.subject_id::text FROM device_binding b JOIN device d ON d.id=b.device_id AND d.tenant_id=b.tenant_id WHERE b.device_id=$1::uuid AND b.tenant_id=$2::uuid AND b.unbound_at IS NULL AND d.status='registered'`, device, tenant).Scan(&subject)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Empty query retrieves recent memories and the user profile at session start.
|
||||
items, err := s.Search(ctx, tenant, subject, "", 4)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
parts := []string{}
|
||||
for _, item := range items {
|
||||
parts = append(parts, item.Content)
|
||||
}
|
||||
return subject, strings.Join(parts, "\n"), nil
|
||||
}
|
||||
func (s *Service) Enqueue(ctx context.Context, event outbox.Event) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
var tenant string
|
||||
var generation int64
|
||||
err = tx.QueryRow(ctx, `SELECT c.tenant_id::text,COALESCE(max(ct.sequence_no),0) FROM conversation c LEFT JOIN conversation_turn ct ON ct.conversation_id=c.id WHERE c.id=$1::uuid AND c.status IN ('COMPLETED','FAILED') GROUP BY c.tenant_id`, event.AggregateID).Scan(&tenant, &generation)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if generation == 0 {
|
||||
return nil
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]int64{"generation_version": generation})
|
||||
key := fmt.Sprintf("viking-v1:%s:%d", event.AggregateID, generation)
|
||||
_, err = outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenant, AggregateType: "conversation", AggregateID: event.AggregateID, EventType: SyncEvent, DeduplicationKey: &key, Payload: payload})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Handle(ctx context.Context, event outbox.Event) error {
|
||||
var tenant, subject, publicID string
|
||||
var when time.Time
|
||||
var frozen []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT c.tenant_id::text,c.subject_id::text,c.public_id::text,COALESCE(c.started_at,c.created_at),cs.effective_consents FROM conversation c JOIN consent_snapshot cs ON cs.id=c.consent_snapshot_id AND cs.tenant_id=c.tenant_id WHERE c.id=$1::uuid AND c.status IN ('COMPLETED','FAILED')`, event.AggregateID).Scan(&tenant, &subject, &publicID, &when, &frozen)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
consents := map[string]bool{}
|
||||
if json.Unmarshal(frozen, &consents) != nil || !consents["personalized_memory"] || !consents["transcript_storage"] {
|
||||
return nil
|
||||
}
|
||||
ok, err := s.Eligible(ctx, tenant, subject)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
// Serialize external writes and erasure per user, including retries from another worker.
|
||||
conn, err := s.pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Release()
|
||||
user := UserID(tenant, subject)
|
||||
if _, err = conn.Exec(ctx, `SELECT pg_advisory_lock(hashtextextended($1,0))`, user); err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtextextended($1,0))`, user)
|
||||
ok, err = s.Eligible(ctx, tenant, subject)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
rows, err := conn.Query(ctx, `SELECT speaker,final_text,start_at FROM conversation_turn WHERE conversation_id=$1::uuid ORDER BY sequence_no,id`, event.AggregateID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages := []Message{}
|
||||
hasUser, hasAssistant := false, false
|
||||
for rows.Next() {
|
||||
var speaker, text string
|
||||
var at time.Time
|
||||
if err = rows.Scan(&speaker, &text, &at); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
role, id := "", user
|
||||
if speaker == "subject" {
|
||||
role = "user"
|
||||
hasUser = true
|
||||
} else if speaker == "agent" {
|
||||
role = "assistant"
|
||||
id = AssistantID
|
||||
hasAssistant = true
|
||||
}
|
||||
if role != "" && strings.TrimSpace(text) != "" {
|
||||
messages = append(messages, Message{Role: role, Content: text, RoleID: id, Time: at.UnixMilli()})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasUser || !hasAssistant || len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
serialized, _ := json.Marshal(messages)
|
||||
sum := sha256.Sum256(serialized)
|
||||
digest := hex.EncodeToString(sum[:])
|
||||
var already bool
|
||||
err = conn.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM audit_event WHERE tenant_id=$1::uuid AND resource_id=$2::uuid AND action='memory.viking.session_sync' AND detail->>'digest'=$3 AND detail->>'collection'=$4)`, tenant, publicID, digest, s.client.options.ResourceID).Scan(&already)
|
||||
if err != nil || already {
|
||||
return err
|
||||
}
|
||||
for start := 0; start < len(messages); start += 100 {
|
||||
end := min(start+100, len(messages))
|
||||
if err = s.client.AddSession(ctx, SessionID(publicID, start/100), user, messages[start:end], when.UnixMilli()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ok, err = s.Eligible(ctx, tenant, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return s.client.DeleteUser(ctx, user)
|
||||
}
|
||||
detail, _ := json.Marshal(map[string]any{"digest": digest, "collection": s.client.options.ResourceID, "message_count": len(messages), "user_id": user})
|
||||
_, err = conn.Exec(ctx, `INSERT INTO audit_event(tenant_id,actor_type,action,resource_type,resource_id,result,detail) VALUES($1::uuid,'system','memory.viking.session_sync','conversation',$2::uuid,'success',$3::jsonb)`, tenant, publicID, detail)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) DeleteSubject(ctx context.Context, tenant, subject string) error {
|
||||
if tenant == "" || subject == "" {
|
||||
return errors.New("Viking erasure requires tenant and subject")
|
||||
}
|
||||
conn, err := s.pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Release()
|
||||
user := UserID(tenant, subject)
|
||||
if _, err = conn.Exec(ctx, `SELECT pg_advisory_lock(hashtextextended($1,0))`, user); err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtextextended($1,0))`, user)
|
||||
return s.client.DeleteUser(ctx, user)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionSyncDedupAndConsent(t *testing.T) {
|
||||
db := os.Getenv("TEST_DATABASE_URL")
|
||||
if db == "" {
|
||||
t.Skip("TEST_DATABASE_URL required")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
tenant := "10000000-0000-0000-0000-000000000001"
|
||||
subject := "10000000-0000-0000-0000-000000000401"
|
||||
cs, csPublic, cx, cxPublic, conv, convPublic := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
pool.Exec(ctx, `DELETE FROM outbox_event WHERE aggregate_id=$1::uuid`, conv)
|
||||
pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, convPublic)
|
||||
pool.Exec(ctx, `DELETE FROM conversation_turn WHERE conversation_id=$1::uuid`, conv)
|
||||
pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, conv)
|
||||
pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, cx)
|
||||
pool.Exec(ctx, `DELETE FROM consent_snapshot WHERE id=$1::uuid`, cs)
|
||||
}()
|
||||
exec(`INSERT INTO consent_snapshot(id,public_id,tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,1,'{"personalized_memory":true,"transcript_storage":true,"story_extraction":true}','[]')`, cs, csPublic, tenant, subject)
|
||||
exec(`INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,1,$5::uuid,'[]','viking-test','s2s_hybrid',$6,'test')`, cx, cxPublic, tenant, subject, cs, strings.Repeat("a", 64))
|
||||
exec(`INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,'10000000-0000-0000-0000-000000000201',$4::uuid,'10000000-0000-0000-0000-000000000501',$5::uuid,$6::uuid,$7,'s2s_hybrid','transcript_only','COMPLETED',now())`, conv, convPublic, tenant, subject, cs, cx, "viking-"+conv)
|
||||
exec(`INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type) VALUES($1::uuid,$2::uuid,$3::uuid,$4,1,'subject',now(),now(),'我喜欢茶','provider'),($1::uuid,$2::uuid,$3::uuid,$5,2,'agent',now(),now(),'记住了','provider')`, tenant, subject, conv, "u"+conv, "a"+conv)
|
||||
calls := 0
|
||||
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
var payload map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if r.URL.Path != "/api/memory/session/add" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
metadata := payload["metadata"].(map[string]any)
|
||||
if metadata["default_user_id"] != UserID(tenant, subject) || len(payload["messages"].([]any)) != 2 {
|
||||
t.Errorf("unexpected payload %v", payload)
|
||||
}
|
||||
fmt.Fprint(w, `{"code":0}`)
|
||||
})
|
||||
service := NewService(pool, client)
|
||||
event := outbox.Event{AggregateID: conv, Payload: []byte(`{"generation_version":2}`)}
|
||||
if err := service.Enqueue(ctx, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Enqueue(ctx, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var queued int
|
||||
pool.QueryRow(ctx, `SELECT count(*) FROM outbox_event WHERE aggregate_id=$1::uuid AND event_type=$2`, conv, SyncEvent).Scan(&queued)
|
||||
if queued != 1 {
|
||||
t.Fatalf("queued=%d", queued)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := service.Handle(ctx, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("duplicate upstream writes=%d", calls)
|
||||
}
|
||||
// A late assistant correction must replace this session using the same ID.
|
||||
exec(`UPDATE conversation_turn SET final_text='好的,我会记住' WHERE conversation_id=$1::uuid AND speaker='agent'`, conv)
|
||||
if err := service.Handle(ctx, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatal(calls)
|
||||
}
|
||||
exec(`UPDATE consent_snapshot SET effective_consents='{"transcript_storage":true}' WHERE id=$1::uuid`, cs)
|
||||
exec(`UPDATE conversation_turn SET final_text='撤回后不可写入' WHERE conversation_id=$1::uuid AND speaker='agent'`, conv)
|
||||
if err := service.Handle(ctx, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatal("frozen consent ignored")
|
||||
}
|
||||
// Unknown or cross-tenant identities cannot retrieve or call the provider.
|
||||
items, err := service.Search(ctx, uuid.NewString(), subject, "茶", 2)
|
||||
if err != nil || len(items) != 0 || calls != 2 {
|
||||
t.Fatalf("scope gate items=%v err=%v calls=%d", items, err, calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package volcsubtitle implements the native RTS subtitle callback documented at
|
||||
// https://docs.volcengine.com/docs/6348/1337284 (verified 2026-09-06).
|
||||
package volcsubtitle
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var ErrInvalid = errors.New("invalid native subtitle packet")
|
||||
|
||||
type Envelope struct {
|
||||
Message string `json:"message"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
type Segment struct {
|
||||
UserID string `json:"userId"`
|
||||
RoundID *int64 `json:"roundId"`
|
||||
Sequence *int64 `json:"sequence"`
|
||||
Text string `json:"text"`
|
||||
Definite bool `json:"definite"`
|
||||
Paragraph bool `json:"paragraph"`
|
||||
}
|
||||
type Packet struct {
|
||||
Type string `json:"type"`
|
||||
Data []Segment `json:"data"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
ProductKey string `json:"productKey,omitempty"`
|
||||
}
|
||||
|
||||
// Bind authentication to the platform conversation. A delayed callback can
|
||||
// never be reassigned by looking up the device's newest active conversation.
|
||||
func Signature(secret []byte, conversationID string) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte("volc-subtitle-v1\n" + conversationID))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func Decode(message string) (Packet, error) {
|
||||
var p Packet
|
||||
if len(message) > 350000 {
|
||||
return p, ErrInvalid
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(message)
|
||||
if err != nil || len(data) == 0 {
|
||||
return p, ErrInvalid
|
||||
}
|
||||
if data[0] != '{' {
|
||||
if len(data) < 8 || string(data[:4]) != "subv" || uint64(binary.BigEndian.Uint32(data[4:8])) != uint64(len(data)-8) {
|
||||
return p, ErrInvalid
|
||||
}
|
||||
data = data[8:]
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return p, ErrInvalid
|
||||
}
|
||||
if json.Unmarshal(data, &p) != nil || p.Type != "subtitle" || len(p.Data) == 0 || len(p.Data) > 100 || len(p.DeviceName) > 128 || len(p.ProductKey) > 200 {
|
||||
return Packet{}, ErrInvalid
|
||||
}
|
||||
for _, s := range p.Data {
|
||||
if s.UserID == "" || len(s.UserID) > 200 || s.RoundID == nil || s.Sequence == nil ||
|
||||
*s.RoundID < 0 || *s.Sequence < 0 || len([]rune(s.Text)) > 20000 {
|
||||
return Packet{}, ErrInvalid
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package volcsubtitle
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func frame(body string) string {
|
||||
b := append(make([]byte, 8), []byte(body)...)
|
||||
copy(b, "subv")
|
||||
binary.BigEndian.PutUint32(b[4:8], uint32(len(b)-8))
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func TestDecodeNativeSubtitle(t *testing.T) {
|
||||
valid := `{"type":"subtitle","data":[{"userId":"device-a","roundId":0,"sequence":0,"definite":true,"paragraph":false,"text":"你好"},{"userId":"bot-a","roundId":0,"sequence":1,"definite":true,"paragraph":true,"text":"下午好"}]}`
|
||||
p, err := Decode(frame(valid))
|
||||
if err != nil || len(p.Data) != 2 || p.Data[0].Paragraph || p.Data[0].Text != "你好" {
|
||||
t.Fatalf("%+v %v", p, err)
|
||||
}
|
||||
for _, s := range []string{"", "not base64!", base64.StdEncoding.EncodeToString([]byte("subv")), frame(`{"type":"other","data":[]}`), frame(`{"type":"subtitle","data":[{"userId":"a","sequence":0}]}`), frame(`{"type":"subtitle","data":[{"userId":"a","roundId":0,"sequence":-1}]}`)} {
|
||||
if _, err := Decode(s); err == nil {
|
||||
t.Errorf("accepted malformed packet %q", s)
|
||||
}
|
||||
}
|
||||
decoded, _ := base64.StdEncoding.DecodeString(frame(valid))
|
||||
decoded[7]++
|
||||
if _, err := Decode(base64.StdEncoding.EncodeToString(decoded)); err == nil {
|
||||
t.Fatal("accepted incorrect binary length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureIsConversationScoped(t *testing.T) {
|
||||
secret := []byte("dedicated-test-secret-32-bytes-long")
|
||||
if Signature(secret, "a") == Signature(secret, "b") || Signature(secret, "a") == Signature([]byte("other"), "a") {
|
||||
t.Fatal("signature must bind both secret and conversation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardwareJSONSubtitle(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"type":"subtitle","deviceName":"test-device","productKey":"test-product","data":[{"userId":"input","roundId":1,"sequence":0,"text":"测试","definite":true}]}`,
|
||||
`{"type":"subtitle","deviceName":"test-device","productKey":"test-product","data":[{"userId":"output","roundId":1,"sequence":1,"text":"","definite":true,"paragraph":true}]}`,
|
||||
} {
|
||||
for _, encoded := range []string{base64.StdEncoding.EncodeToString([]byte(body)), frame(body)} {
|
||||
p, err := Decode(encoded)
|
||||
if err != nil || p.DeviceName != "test-device" || p.ProductKey != "test-product" {
|
||||
t.Fatalf("hardware packet=%+v err=%v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user