feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -11,9 +11,11 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// The worker prepares encrypted configuration. Only the device executes SDK
|
||||
// registration, after a durable C08 permit. An outbox retry makes no SDK call.
|
||||
// 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"))
|
||||
}
|
||||
@@ -42,7 +44,7 @@ func (s *Service) ProcessActivation(ctx context.Context, event outbox.Event) err
|
||||
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)`, d.ID, d.Generation).Scan(&bound); err != nil {
|
||||
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 {
|
||||
@@ -62,7 +64,7 @@ func (s *Service) ProcessActivation(ctx context.Context, event outbox.Event) err
|
||||
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='DEVICE'`, j.ID, s.now().Add(-90*time.Second))
|
||||
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
|
||||
}
|
||||
@@ -78,11 +80,26 @@ func (s *Service) expireAttempt(ctx context.Context, tx pgx.Tx, d Device, j *act
|
||||
}
|
||||
|
||||
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()) {
|
||||
return nil
|
||||
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", "激活暂未开放")
|
||||
@@ -111,7 +128,17 @@ func (s *Service) ensureConfig(ctx context.Context, tx pgx.Tx, d Device, j *acti
|
||||
j.ConfigVersion++
|
||||
}
|
||||
expires := s.now().Add(24 * time.Hour)
|
||||
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": map[string]any{"instance_id": j.Instance, "product_key": j.Product, "product_secret": s.options.ProductSecret, "device_name": j.Name}, "bot_id": j.Bot})
|
||||
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
|
||||
}
|
||||
@@ -122,7 +149,11 @@ func (s *Service) ensureConfig(ctx context.Context, tx pgx.Tx, d Device, j *acti
|
||||
j.Status = "RESERVED"
|
||||
if j.Registered != nil {
|
||||
j.Status = "REGISTERED_OBSERVED"
|
||||
j.Engine = j.Registered
|
||||
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 {
|
||||
@@ -164,6 +195,9 @@ func (s *Service) activationCommand(ctx context.Context, tx pgx.Tx, d Device) (a
|
||||
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
|
||||
}
|
||||
@@ -219,7 +253,7 @@ func (s *Service) ActivationAck(ctx context.Context, d Device, id string, raw []
|
||||
if in.LocalGeneration < previous {
|
||||
return nil, errState
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET config_ack_at=COALESCE(config_ack_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)
|
||||
_, 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
|
||||
}
|
||||
@@ -285,6 +319,9 @@ func (s *Service) Attempt(ctx context.Context, d Device, id string, raw []byte,
|
||||
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", "请先保存并确认注册结果")
|
||||
@@ -336,8 +373,8 @@ func (s *Service) ActivationResult(ctx context.Context, d Device, id string, raw
|
||||
}
|
||||
var stage, status string
|
||||
var gen, version int64
|
||||
var stored, conversation *string
|
||||
err = tx.QueryRow(ctx, `SELECT stage,status,binding_generation,config_version,result_digest,conversation_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)
|
||||
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
|
||||
}
|
||||
@@ -369,6 +406,16 @@ func (s *Service) ActivationResult(ctx context.Context, d Device, id string, raw
|
||||
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<b.seq AND b.seq<=p.seq AND c.ended_at-c.created_at<=interval '90 seconds')`, *callSession, current.ID, current.Generation).Scan(&valid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !valid {
|
||||
return nil, fail(409, "TEST_EVIDENCE_REQUIRED", "首通尚缺少连接、首音频、首播放或停止证据")
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status=$2,sdk_result=$3,result_digest=$4,completed_at=now() WHERE id=$1::uuid`, in.AttemptID, in.Outcome, in.SDKResult, digest(raw))
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user