424 lines
18 KiB
Go
424 lines
18 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"
|
|
)
|
|
|
|
// The worker prepares encrypted configuration. Only the device executes SDK
|
|
// registration, after a durable C08 permit. An outbox retry makes no SDK call.
|
|
func (s *Service) ProcessActivation(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
|
|
}
|
|
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)`, 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='DEVICE'`, 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 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.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)
|
|
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})
|
|
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"
|
|
j.Engine = j.Registered
|
|
}
|
|
_, 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 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()),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 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 *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)
|
|
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
|
|
}
|
|
}
|
|
}
|
|
_, 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
|
|
}
|