Files
story/internal/onboarding/device_activation.go
T
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
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.
2026-10-08 10:06:44 +08:00

471 lines
20 KiB
Go

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<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 {
return nil, err
}
target := "RESERVED"
if j.Registered != nil {
target = "REGISTERED_OBSERVED"
}
if in.Outcome == "UNKNOWN" {
target = "RECONCILE_REQUIRED"
}
if in.Outcome == "SUCCESS_OBSERVED" {
if in.Stage == "SDK_REGISTER" {
target = "REGISTERED_OBSERVED"
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET sdk_registered_at=COALESCE(sdk_registered_at,now()),engine_ready_at=now() WHERE id=$1::uuid`, j.ID)
} else {
target = "READY"
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET ready_binding_generation=$2 WHERE id=$1::uuid`, j.ID, current.Generation)
if err == nil {
err = requireLifecycle(ctx, tx, current, deviceactivation.Active, id)
}
}
}
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status=$2,last_error_code=CASE WHEN $2='RECONCILE_REQUIRED' THEN 'DEVICE_RESULT_UNCERTAIN' ELSE NULL END,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, target)
if err != nil {
return nil, err
}
if err = bump(ctx, tx, d.ID); err != nil {
return nil, err
}
return s.activationResultResponse(ctx, tx, current, j.ID)
})
}
func (s *Service) activationResultResponse(ctx context.Context, tx pgx.Tx, d Device, id string) (map[string]any, error) {
st, err := s.state(ctx, tx, d)
return map[string]any{"activation_id": id, "state": st, "reconcile_required": st.ActivationState == "RECONCILE_REQUIRED"}, err
}
func validateLinkedTest(ctx context.Context, tx pgx.Tx, d Device, conversation string) error {
var valid bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM conversation c JOIN hardware_agent_event a ON a.conversation_id=c.id AND a.event_type='session_started' JOIN hardware_agent_event b ON b.conversation_id=c.id AND b.event_type='first_audio_received' JOIN hardware_agent_event z ON z.conversation_id=c.id AND z.event_type='session_ended' WHERE c.id=$1::uuid AND c.device_id=$2::uuid AND c.onboarding_generation=$3 AND c.status='COMPLETED' AND a.sequence_no<b.sequence_no AND b.sequence_no<z.sequence_no AND z.created_at-a.created_at<=interval '90 seconds')`, conversation, d.ID, d.Generation).Scan(&valid)
if err != nil {
return err
}
if !valid {
return fail(409, "TEST_EVIDENCE_REQUIRED", "关联首通尚未完成连接、首音频及结束")
}
return nil
}