package onboarding import ( "context" "encoding/json" "errors" "time" "data-terminal-cloud/internal/deviceactivation" "data-terminal-cloud/internal/outbox" "github.com/jackc/pgx/v5" ) // Production registers in the worker. The legacy path is retained for migration tests. func (s *Service) ProcessActivation(ctx context.Context, event outbox.Event) error { if !s.options.LegacyDeviceRegistration { return s.processServerActivation(ctx, event) } 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 } if !deviceAllowed(d) || d.Generation != in.Generation || j.Generation != d.Generation || j.ID != in.ActivationID { return nil } var bound bool if 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); err != nil { return err } if !bound { return nil } if err = s.expireAttempt(ctx, tx, d, &j); err != nil { return err } if oneOf(j.Status, "NOT_STARTED", "RESERVED", "REGISTERED_OBSERVED", "READY") { if err = s.ensureConfig(ctx, tx, d, &j); err != nil { return err } } return tx.Commit(ctx) } func (s *Service) expireAttempt(ctx context.Context, tx pgx.Tx, d Device, j *activationJob) error { // Permit expiry means a call may already have started. It is never proof of // failure. Leave room for a 60-second conversation and late result delivery. tag, err := tx.Exec(ctx, `UPDATE device_activation_attempt SET status='UNKNOWN' WHERE activation_id=$1::uuid AND status='CONTACT_POSSIBLE' AND permit_expires_at < $2 AND executor IN ('DEVICE','SERVER')`, j.ID, s.now().Add(-90*time.Second)) if err != nil { return err } if tag.RowsAffected() == 0 { return nil } _, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='RECONCILE_REQUIRED',last_error_code='DEVICE_RESULT_UNCERTAIN',version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID) if err != nil { return err } j.Status = "RECONCILE_REQUIRED" return bump(ctx, tx, d.ID) } func (s *Service) ensureConfig(ctx context.Context, tx pgx.Tx, d Device, j *activationJob) error { if !s.options.LegacyDeviceRegistration && (j.Registered == nil || len(j.Secret) == 0 || j.RTCAppID == "") { return nil } if j.Generation != d.Generation || !oneOf(j.Status, "NOT_STARTED", "RESERVED", "REGISTERED_OBSERVED", "READY") { return errState } if len(j.Config) > 0 && j.Expires != nil && j.Expires.After(s.now()) { if s.options.LegacyDeviceRegistration { return nil } cached, e := s.crypt.open(j.Config, "config:"+j.ID) if e != nil { return e } var old struct { IoT map[string]any `json:"iot"` } if json.Unmarshal(cached, &old) == nil && old.IoT["product_secret"] == nil && old.IoT["device_secret"] != nil { return nil } } if !s.options.ActivationEnabled { return fail(503, "ACTIVATION_PAUSED", "激活暂未开放") } // Never rotate the configuration under a potentially running SDK call. 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'))`, j.ID).Scan(&busy); err != nil { return err } if busy { return fail(409, "ATTEMPT_RECONCILIATION_REQUIRED", "请先确认原执行结果") } if j.Instance == "" { j.Instance = s.options.InstanceID } if j.Product == "" { j.Product = s.options.ProductKey } if j.Bot == "" { j.Bot = s.options.BotID } if j.Product != s.options.ProductKey || j.Instance != s.options.InstanceID { return fail(409, "ACTIVATION_PRODUCT_CHANGED", "设备所属产品配置已变化,请运维核对") } if len(j.Config) > 0 { j.ConfigVersion++ } expires := s.now().Add(24 * time.Hour) iot := map[string]any{"instance_id": j.Instance, "product_key": j.Product, "device_name": j.Name} if s.options.LegacyDeviceRegistration { iot["product_secret"] = s.options.ProductSecret } else { secret, e := s.crypt.open(j.Secret, "provider:"+j.ID) if e != nil { return e } iot["device_secret"], iot["RTCAppID"] = string(secret), j.RTCAppID } raw, err := json.Marshal(map[string]any{"contract_version": ContractVersion, "device_id": d.FactoryID, "activation_id": j.ID, "config_version": j.ConfigVersion, "binding_generation": d.Generation, "expires_at_ms": expires.UnixMilli(), "iot": iot, "bot_id": j.Bot}) if err != nil { return err } if len(raw) > 32<<10 { return errors.New("activation configuration exceeds limit") } j.Config, j.Digest, j.Expires, j.Ack = s.crypt.seal(raw, "config:"+j.ID), digest(raw), &expires, nil j.Status = "RESERVED" if j.Registered != nil { j.Status = "REGISTERED_OBSERVED" if s.options.LegacyDeviceRegistration { j.Engine = j.Registered } else { j.Engine = nil } } _, err = tx.Exec(ctx, `UPDATE device_activation_job SET provider_instance_id=$2,provider_product_key=$3,provider_bot_id=$4,config_version=$5,config_ciphertext=$6,config_digest=$7,config_expires_at=$8,config_ack_at=NULL,engine_ready_at=$9,status=$10,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, j.Instance, j.Product, j.Bot, j.ConfigVersion, j.Config, j.Digest, expires, j.Engine, j.Status) if err != nil { return err } if d.Lifecycle == "BOUND" { if err = requireLifecycle(ctx, tx, d, deviceactivation.Provisioning, j.ID); err != nil { return err } } // Reuse an existing platform credential when available. The C09 contract // deliberately does not ask a device to upload its supplier secret. if len(j.Secret) > 0 { secret, e := s.crypt.open(j.Secret, "provider:"+j.ID) if e != nil { return e } if err = s.storeRuntimeCredential(ctx, tx, d, *j, secret); err != nil { return err } } return bump(ctx, tx, d.ID) } func (s *Service) activationCommand(ctx context.Context, tx pgx.Tx, d Device) (any, error) { j, err := readJob(ctx, tx, d.ID) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } if j.Generation != d.Generation { return nil, nil } if err = s.expireAttempt(ctx, tx, d, &j); err != nil { return nil, err } if !s.options.ActivationEnabled || !oneOf(j.Status, "NOT_STARTED", "RESERVED", "REGISTERED_OBSERVED", "READY") { return nil, nil } if !s.options.LegacyDeviceRegistration && (j.Registered == nil || len(j.Secret) == 0 || j.RTCAppID == "") { return nil, nil } if err = s.ensureConfig(ctx, tx, d, &j); err != nil { return nil, err } raw, err := s.crypt.open(j.Config, "config:"+j.ID) if err != nil { return nil, err } var version int64 var encrypted []byte if err = tx.QueryRow(ctx, `SELECT key_version,ciphertext FROM device_onboarding_key WHERE device_id=$1::uuid AND status='ACTIVE'`, d.ID).Scan(&version, &encrypted); err != nil { return nil, err } key, err := s.crypt.open(encrypted, keyAAD(d.ID, version)) if err != nil { return nil, err } encoded := encode64(raw) return map[string]any{"command_id": j.ID, "type": "APPLY_ACTIVATION", "expires_at_ms": j.Expires.UnixMilli(), "payload": map[string]any{"config_b64": encoded, "config_mac": domainMAC(key, "CFG1\n"+encoded)}}, nil } func validConfig(s *Service, d Device, j activationJob, id string, version int64) error { if j.ID != id || j.Generation != d.Generation || j.ConfigVersion != version || j.Status == "BLOCKED" || j.Expires == nil || !j.Expires.After(s.now()) { return fail(409, "ACTIVATION_CONFIG_CHANGED", "请拉取当前激活配置") } return nil } type ActivationAckInput struct { ActivationID string `json:"activation_id"` LocalGeneration int64 `json:"local_generation"` ConfigVersion int64 `json:"config_version"` } func (s *Service) ActivationAck(ctx context.Context, d Device, id string, raw []byte, in ActivationAckInput) (map[string]any, error) { if !uuidV4(in.ActivationID) || in.LocalGeneration < 0 || in.ConfigVersion < 1 { return nil, fail(400, "VALIDATION_FAILED", "激活保存回执格式不正确") } return s.mutate(ctx, d, "device:"+d.ID, "activation-ack", id, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) { j, err := readJob(ctx, tx, d.ID) if err != nil { return nil, err } if err = validConfig(s, current, j, in.ActivationID, in.ConfigVersion); err != nil { return nil, err } if j.Registered == nil || !oneOf(j.Status, "REGISTERED_OBSERVED", "READY") { return nil, fail(409, "ACTIVATION_RESULT_NOT_READY", "尚无已确认的激活结果") } var previous int64 if err = tx.QueryRow(ctx, `SELECT CASE WHEN config_ack_boot_id=$2::uuid THEN COALESCE(config_local_generation,0) ELSE 0 END FROM device_activation_job WHERE id=$1::uuid`, j.ID, current.BootID).Scan(&previous); err != nil { return nil, err } if in.LocalGeneration < previous { return nil, errState } _, err = tx.Exec(ctx, `UPDATE device_activation_job SET config_ack_at=COALESCE(config_ack_at,now()),engine_ready_at=now(),config_local_generation=$2,config_ack_boot_id=$3::uuid,updated_at=now() WHERE id=$1::uuid`, j.ID, in.LocalGeneration, current.BootID) if err != nil { return nil, err } if j.Status != "READY" && j.ReadyGeneration != nil && *j.ReadyGeneration == current.Generation { if _, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='READY',version=version+1 WHERE id=$1::uuid`, j.ID); err != nil { return nil, err } if err = bump(ctx, tx, d.ID); err != nil { return nil, err } } st, err := s.state(ctx, tx, current) return map[string]any{"accepted": true, "state": st}, err }) } type AttemptInput struct { ActivationID string `json:"activation_id"` ConfigVersion int64 `json:"config_version"` AttemptID string `json:"attempt_id"` } func (s *Service) Attempt(ctx context.Context, d Device, id string, raw []byte, in AttemptInput) (map[string]any, error) { if !uuidV4(in.ActivationID) || !uuidV4(in.AttemptID) || in.ConfigVersion < 1 { return nil, fail(400, "VALIDATION_FAILED", "执行许可参数不正确") } return s.mutate(ctx, d, "device:"+d.ID, "attempt", id, raw, 30*time.Second, func(tx pgx.Tx, current Device) (map[string]any, error) { j, err := readJob(ctx, tx, d.ID) if err != nil { return nil, err } if err = validConfig(s, current, j, in.ActivationID, in.ConfigVersion); err != nil { return nil, err } if !s.options.ActivationEnabled { return nil, fail(503, "ACTIVATION_PAUSED", "激活暂未开放") } var status string var version, gen int64 var expires time.Time err = tx.QueryRow(ctx, `SELECT status,config_version,binding_generation,permit_expires_at FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid AND executor='DEVICE'`, in.AttemptID, j.ID).Scan(&status, &version, &gen, &expires) if err == nil { if version != j.ConfigVersion || gen != current.Generation { return nil, errState } if status != "CONTACT_POSSIBLE" || !expires.After(s.now()) { return nil, fail(409, "ATTEMPT_ALREADY_ISSUED", "执行编号已使用或过期,请上报原执行结果") } return s.attemptResponse(ctx, tx, current, in.AttemptID, expires) } if !errors.Is(err, pgx.ErrNoRows) { return nil, err } var busy bool var recent int if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_activation_attempt WHERE activation_id=$1::uuid AND status IN ('CONTACT_POSSIBLE','UNKNOWN')),count(*) FILTER(WHERE created_at>now()-interval '1 hour') FROM device_activation_attempt WHERE activation_id=$1::uuid AND executor='DEVICE'`, j.ID).Scan(&busy, &recent); err != nil { return nil, err } if busy || oneOf(j.Status, "RECONCILE_REQUIRED", "CONTACT_POSSIBLE") { return nil, fail(409, "ATTEMPT_RECONCILIATION_REQUIRED", "已有执行尚未确认结果") } if recent >= 10 { return nil, fail(429, "ACTIVATION_ATTEMPT_LIMIT", "激活尝试过于频繁") } stage := "SDK_REGISTER" if !s.options.LegacyDeviceRegistration && j.Registered == nil { return nil, fail(409, "SERVER_REGISTRATION_REQUIRED", "火山注册由平台执行,请获取语音配置") } if j.Registered != nil { if j.Ack == nil { return nil, fail(409, "ACTIVATION_STAGE_NOT_READY", "请先保存并确认注册结果") } if j.Status == "READY" { return nil, fail(409, "ACTIVATION_ALREADY_READY", "本代绑定已完成激活") } stage = "CONVERSATION_TEST" } expires = s.now().Add(30 * time.Second) _, 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,'DEVICE',$3,$4,$5,'CONTACT_POSSIBLE',$6)`, in.AttemptID, j.ID, stage, current.Generation, j.ConfigVersion, expires) if err != nil { return nil, err } _, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='CONTACT_POSSIBLE',current_attempt_id=$2::uuid,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, in.AttemptID) if err != nil { return nil, err } if err = bump(ctx, tx, d.ID); err != nil { return nil, err } return s.attemptResponse(ctx, tx, current, in.AttemptID, expires) }) } func (s *Service) attemptResponse(ctx context.Context, tx pgx.Tx, d Device, id string, expires time.Time) (map[string]any, error) { st, err := s.state(ctx, tx, d) return map[string]any{"allowed": true, "attempt_id": id, "permit_expires_at_ms": expires.UnixMilli(), "state": st}, err } type ResultInput struct { ActivationID string `json:"activation_id"` AttemptID string `json:"attempt_id"` Stage string `json:"stage"` SDKResult int `json:"sdk_result"` Outcome string `json:"outcome"` } func (s *Service) ActivationResult(ctx context.Context, d Device, id string, raw []byte, in ResultInput) (map[string]any, error) { if !uuidV4(in.ActivationID) || !uuidV4(in.AttemptID) || !oneOf(in.Stage, "SDK_REGISTER", "CONVERSATION_TEST") || !oneOf(in.Outcome, "SUCCESS_OBSERVED", "DEFINITE_FAILURE", "UNKNOWN") { return nil, fail(400, "VALIDATION_FAILED", "激活结果格式不正确") } if in.Outcome == "SUCCESS_OBSERVED" && in.SDKResult != 0 { return nil, fail(400, "SDK_RESULT_CONFLICT", "成功结果需要SDK成功码") } return s.mutate(ctx, d, "device:"+d.ID, "activation-result", id, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) { j, err := readJob(ctx, tx, d.ID) if err != nil { return nil, err } var stage, status string var gen, version int64 var stored, conversation, callSession *string err = tx.QueryRow(ctx, `SELECT stage,status,binding_generation,config_version,result_digest,conversation_id::text,call_session_id::text FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid AND executor='DEVICE' FOR UPDATE`, in.AttemptID, in.ActivationID).Scan(&stage, &status, &gen, &version, &stored, &conversation, &callSession) if errors.Is(err, pgx.ErrNoRows) { return nil, errAccess } if err != nil { return nil, err } // Result delivery can occur after permit/config expiry, but never after // configuration replacement or a binding change. if j.ID != in.ActivationID || j.Generation != current.Generation || gen != current.Generation || version != j.ConfigVersion || stage != in.Stage || j.Status == "BLOCKED" { return nil, errState } if stored != nil { if *stored != digest(raw) { return nil, fail(409, "ATTEMPT_RESULT_CONFLICT", "执行已有不同结果,请运维对账") } return s.activationResultResponse(ctx, tx, current, j.ID) } if !oneOf(status, "CONTACT_POSSIBLE", "UNKNOWN") { return nil, errState } if in.Stage == "CONVERSATION_TEST" && in.Outcome == "SUCCESS_OBSERVED" { if j.Registered == nil || j.Ack == nil { return nil, errState } // Direct SDK calls report their observation in C09. If the test used // SaaS orchestration, validate the already stored events by linkage. if conversation != nil { if err = validateLinkedTest(ctx, tx, current, *conversation); err != nil { return nil, err } } if callSession != nil { var valid bool err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM call_session c JOIN call_event a ON a.session_id=c.id AND a.event_type='MEDIA_CONNECTED' JOIN call_event b ON b.session_id=c.id AND b.event_type='FIRST_AUDIO_RECEIVED' JOIN call_event p ON p.session_id=c.id AND p.event_type='FIRST_PLAYBACK_STARTED' WHERE c.id=$1::uuid AND c.device_id=$2::uuid AND c.binding_generation=$3 AND c.purpose='FIRST_TEST' AND c.call_state='ENDED' AND c.local_audio_off AND c.sdk_stopped AND a.seq