package onboarding import ( "context" "encoding/json" "errors" "time" "data-terminal-cloud/internal/hardwareprovision" "data-terminal-cloud/internal/outbox" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // Commit the execution fence before contacting the provider. A lost response or // process crash is uncertain, never permission to register the same device again. func (s *Service) processServerActivation(ctx context.Context, event outbox.Event) error { if !s.options.ActivationEnabled { return outbox.Retryable(errors.New("onboarding activation paused")) } var in struct { DeviceID string `json:"device_id"` ActivationID string `json:"activation_id"` Generation int64 `json:"binding_generation"` } if json.Unmarshal(event.Payload, &in) != nil || !uuidValid(in.DeviceID) { return errors.New("invalid activation event") } tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) d, err := readDevice(ctx, tx, in.DeviceID, true) if err != nil { return err } j, err := readJob(ctx, tx, d.ID) if err != nil { return err } bound, err := voiceBound(ctx, tx, d) if err != nil { return err } if !bound || !deviceAllowed(d) || j.ID != in.ActivationID || j.Generation != d.Generation || in.Generation != d.Generation { return nil } if j.Product != s.options.ProductKey || j.Instance != s.options.InstanceID { return fail(409, "ACTIVATION_PRODUCT_CHANGED", "设备所属火山产品已变化,请核对") } if j.Registered != nil { // Old device-side registrations may not have a server copy of the secret. // Wait for migration; never re-register them to recover a missing secret. if len(j.Secret) > 0 && j.RTCAppID != "" && oneOf(j.Status, "REGISTERED_OBSERVED", "READY") { if err = s.ensureConfig(ctx, tx, d, &j); err != nil { return err } } return tx.Commit(ctx) } if err = s.expireAttempt(ctx, tx, d, &j); err != nil { return err } var busy bool if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_activation_attempt WHERE activation_id=$1::uuid AND status IN ('CONTACT_POSSIBLE','UNKNOWN','SUCCESS_OBSERVED') AND stage IN ('SERVER_REGISTER','SDK_REGISTER'))`, j.ID).Scan(&busy); err != nil { return err } if busy || !oneOf(j.Status, "NOT_STARTED", "RESERVED") { return tx.Commit(ctx) } if s.options.Registrar == nil { return errors.New("server registrar unavailable") } attempt := uuid.NewString() _, err = tx.Exec(ctx, `INSERT INTO device_activation_attempt(id,activation_id,executor,stage,binding_generation,config_version,status,permit_expires_at) VALUES($1::uuid,$2::uuid,'SERVER','SERVER_REGISTER',$3,$4,'CONTACT_POSSIBLE',$5)`, attempt, j.ID, d.Generation, j.ConfigVersion, s.now().Add(30*time.Second)) if err != nil { return err } _, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='CONTACT_POSSIBLE',current_attempt_id=$2::uuid,last_error_code=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, attempt) if err != nil { return err } if err = tx.Commit(ctx); err != nil { return err } callCtx, cancel := context.WithTimeout(ctx, 20*time.Second) result, callErr := s.options.Registrar.Register(callCtx, hardwareprovision.RegisterInput{InstanceID: j.Instance, ProductKey: j.Product, DeviceName: j.Name}) cancel() // Do not discard successful credentials just because the worker was cancelled. saveCtx, saveCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) defer saveCancel() return s.saveServerRegistration(saveCtx, d.ID, j.ID, attempt, result, callErr) } func voiceBound(ctx context.Context, tx pgx.Tx, d Device) (bool, error) { var bound bool err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND generation=$2 AND unbound_at IS NULL) OR EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND generation=$2 AND released_at IS NULL)`, d.ID, d.Generation).Scan(&bound) return bound, err } func (s *Service) saveServerRegistration(ctx context.Context, device, job, attempt string, result hardwareprovision.RegisterResult, callErr error) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) d, err := readDevice(ctx, tx, device, true) if err != nil { return err } j, err := readJob(ctx, tx, device) if err != nil { return err } if j.ID != job { return errState } var status string if err = tx.QueryRow(ctx, `SELECT status FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid AND executor='SERVER' FOR UPDATE`, attempt, job).Scan(&status); err != nil { return err } if !oneOf(status, "CONTACT_POSSIBLE", "UNKNOWN") { return tx.Commit(ctx) } good := callErr == nil && validVoiceSecret(result.DeviceSecret) && validRTCAppID(result.RTCAppID) if !good { _, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='UNKNOWN',completed_at=now() WHERE id=$1::uuid`, attempt) if err != nil { return err } _, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='RECONCILE_REQUIRED',last_error_code='SERVER_RESULT_UNCERTAIN',version=version+1,updated_at=now() WHERE id=$1::uuid`, job) if err != nil { return err } return tx.Commit(ctx) } // A rebind/unbind while the HTTP call ran does not erase the physical device's // newly acquired identity. Save it, but only deliver it to a current binding. j.Secret = s.crypt.seal([]byte(result.DeviceSecret), "provider:"+job) j.RTCAppID = result.RTCAppID now := s.now() j.Registered = &now bound, err := voiceBound(ctx, tx, d) if err != nil { return err } j.Status = "BLOCKED" if bound && deviceAllowed(d) && j.Generation == d.Generation { j.Status = "REGISTERED_OBSERVED" } _, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='SUCCESS_OBSERVED',completed_at=now() WHERE id=$1::uuid`, attempt) if err != nil { return err } _, err = tx.Exec(ctx, `UPDATE device_activation_job SET provider_secret_ciphertext=$2,provider_rtc_app_id=$3,sdk_registered_at=now(),engine_ready_at=NULL,config_ciphertext=NULL,config_digest=NULL,config_expires_at=NULL,config_ack_at=NULL,status=$4,last_error_code=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, job, j.Secret, j.RTCAppID, j.Status) if err != nil { return err } j.Config = nil j.Expires = nil j.Ack = nil j.Engine = nil if j.Status == "REGISTERED_OBSERVED" { if err = s.ensureConfig(ctx, tx, d, &j); err != nil { return err } } return tx.Commit(ctx) } type DeviceVoiceCredential struct { InstanceID string `json:"instance_id"` ProductKey string `json:"product_key"` DeviceName string `json:"device_name"` DeviceSecret string `json:"device_secret"` RTCAppID string `json:"RTCAppID"` } func validVoiceSecret(v string) bool { return len(v) >= 16 && len(v) <= 256 } func validRTCAppID(v string) bool { return len(v) > 0 && len(v) <= 128 } // Does not expose credentials through mini-program/account endpoints. func (s *Service) voiceFields(ctx context.Context, tx pgx.Tx, d Device) (map[string]any, error) { out := map[string]any{"voice_status": "preparing", "voice": nil} if !s.options.ActivationEnabled { out["voice_status"] = "paused" return out, nil } j, err := readJob(ctx, tx, d.ID) if errors.Is(err, pgx.ErrNoRows) { return out, nil } if err != nil { return nil, err } if j.Generation != d.Generation { return out, nil } if j.Registered != nil && (len(j.Secret) == 0 || j.RTCAppID == "") { out["voice_status"] = "migration_required" return out, nil } if oneOf(j.Status, "BLOCKED", "RECONCILE_REQUIRED") { out["voice_status"] = "reconcile_required" return out, nil } if j.Registered == nil || len(j.Secret) == 0 || j.RTCAppID == "" { return out, nil } secret, err := s.crypt.open(j.Secret, "provider:"+j.ID) if err != nil { return nil, err } if !validVoiceSecret(string(secret)) || !validRTCAppID(j.RTCAppID) { return nil, errors.New("invalid stored provider credential") } out["voice_status"] = "ready" out["voice"] = DeviceVoiceCredential{j.Instance, j.Product, j.Name, string(secret), j.RTCAppID} return out, nil } // Callable immediately after binding, including before boot registration. func (s *Service) VoiceConfig(ctx context.Context, d Device) (map[string]any, error) { tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) current, err := readDevice(ctx, tx, d.ID, true) if err != nil { return nil, err } bound, err := voiceBound(ctx, tx, current) if err != nil { return nil, err } if !deviceAllowed(current) || !bound { return nil, fail(409, "DEVICE_UNBOUND", "绑定错误:设备没有绑定人员") } out, err := s.voiceFields(ctx, tx, current) if err != nil { return nil, err } return out, tx.Commit(ctx) } // One-time import of credentials held only by old, already-registered firmware. // Refuses unknown/unregistered devices and never overwrites a different secret. func (s *Service) ImportVoiceCredential(ctx context.Context, d Device, in DeviceVoiceCredential) (map[string]any, error) { if !validVoiceSecret(in.DeviceSecret) || !validRTCAppID(in.RTCAppID) { return nil, fail(400, "INVALID_VOICE_CREDENTIAL", "语音凭据格式错误") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) current, err := readDevice(ctx, tx, d.ID, true) if err != nil { return nil, err } bound, err := voiceBound(ctx, tx, current) if err != nil { return nil, err } if !bound || !deviceAllowed(current) { return nil, errAccess } j, err := readJob(ctx, tx, d.ID) if err != nil { return nil, err } if j.Registered == nil || j.Generation != current.Generation || j.Instance != in.InstanceID || j.Product != in.ProductKey || j.Name != in.DeviceName { return nil, fail(409, "VOICE_MIGRATION_NOT_ALLOWED", "仅可迁移本设备已完成注册的原凭据") } if len(j.Secret) > 0 { old, e := s.crypt.open(j.Secret, "provider:"+j.ID) if e != nil { return nil, e } if !sameSecret(string(old), in.DeviceSecret) || (j.RTCAppID != "" && j.RTCAppID != in.RTCAppID) { return nil, fail(409, "VOICE_CREDENTIAL_CONFLICT", "平台已有不同凭据,请核对") } if j.RTCAppID == in.RTCAppID { return map[string]any{"accepted": true}, tx.Commit(ctx) } } if !oneOf(j.Status, "REGISTERED_OBSERVED", "READY") { return nil, errState } j.Secret = s.crypt.seal([]byte(in.DeviceSecret), "provider:"+j.ID) j.RTCAppID = in.RTCAppID _, err = tx.Exec(ctx, `UPDATE device_activation_job SET provider_secret_ciphertext=$2,provider_rtc_app_id=$3,config_ciphertext=NULL,config_expires_at=NULL,config_ack_at=NULL,engine_ready_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, j.Secret, j.RTCAppID) if err != nil { return nil, err } j.Config = nil j.Expires = nil j.Ack = nil if err = s.ensureConfig(ctx, tx, current, &j); err != nil { return nil, err } return map[string]any{"accepted": true}, tx.Commit(ctx) }