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.
306 lines
11 KiB
Go
306 lines
11 KiB
Go
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)
|
|
}
|