package onboarding import ( "context" "data-terminal-cloud/internal/deviceactivation" "data-terminal-cloud/internal/identity" "data-terminal-cloud/internal/outbox" "encoding/json" "errors" "github.com/jackc/pgx/v5" ) type RotationInput struct { Reason string `json:"reason"` ExpectedVersion int64 `json:"expected_key_version"` } func (s *Service) Rotate(ctx context.Context, p identity.Principal, tenant, device, idempotencyKey, requestID string, raw []byte, in RotationInput) (map[string]any, error) { if !uuidValid(device) || !nonempty(in.Reason, 200) || in.ExpectedVersion < 1 || len(idempotencyKey) < 16 || len(idempotencyKey) > 160 { return nil, fail(400, "VALIDATION_FAILED", "密钥轮换参数不正确") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) a, err := operatorAccess(ctx, tx, p, tenant, true) if err != nil { return nil, err } var internal string err = tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid`, device, a.TenantID).Scan(&internal) if errors.Is(err, pgx.ErrNoRows) { return nil, errAccess } if err != nil { return nil, err } d, err := readDevice(ctx, tx, internal, true) if err != nil { return nil, err } if !deviceAllowed(d) { return nil, errAccess } var job, stored string err = tx.QueryRow(ctx, `SELECT id::text,request_digest FROM device_enrollment_job WHERE device_id=$1::uuid AND actor_id=$2::uuid AND request_key=$3`, d.ID, a.ID, idempotencyKey).Scan(&job, &stored) if err == nil { if stored != digest(raw) { return nil, fail(409, "REQUEST_BODY_CONFLICT", "轮换请求号已用于其他内容") } return inventoryResult(d, job), tx.Commit(ctx) } if !errors.Is(err, pgx.ErrNoRows) { return nil, err } var active, latest int64 var pending bool err = tx.QueryRow(ctx, `SELECT COALESCE(max(key_version) FILTER(WHERE status='ACTIVE'),0),max(key_version),bool_or(status='PENDING') FROM device_onboarding_key WHERE device_id=$1::uuid`, d.ID).Scan(&active, &latest, &pending) if err != nil { return nil, err } if active != in.ExpectedVersion { return nil, fail(409, "KEY_VERSION_CONFLICT", "当前凭据版本已变化") } if pending { return nil, fail(409, "DELIVERY_PENDING", "已有凭据交付任务待完成") } _, err = tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status) VALUES($1::uuid,$2::uuid,$3,$4,'PENDING')`, d.ID, d.TenantID, latest+1, s.crypt.seal(randomBytes(32), keyAAD(d.ID, latest+1))) if err != nil { return nil, err } err = tx.QueryRow(ctx, `INSERT INTO device_enrollment_job(tenant_id,device_id,actor_id,request_key,request_digest,batch_no,key_version) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,'credential-rotation',$6) RETURNING id::text`, d.TenantID, d.ID, a.ID, idempotencyKey, digest(raw), latest+1).Scan(&job) if err != nil { return nil, err } if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "onboarding.key.rotation.prepare", requestID, map[string]any{"job_id": job, "key_version": latest + 1, "reason": in.Reason}); err != nil { return nil, err } result := inventoryResult(d, job) result["key_version"] = latest + 1 return result, tx.Commit(ctx) } func (s *Service) OpsList(ctx context.Context, p identity.Principal, tenant string) (map[string]any, error) { a, err := operatorAccess(ctx, s.pool, p, tenant, false) if err != nil { return nil, err } rows, err := s.pool.Query(ctx, `SELECT d.public_id::text,d.factory_device_id,d.model,d.binding_generation,d.onboarding_revision,d.onboarding_released_at IS NOT NULL,COALESCE(j.id::text,''),COALESCE(j.status,'NOT_STARTED'),COALESCE(j.provider_device_name,''),COALESCE(j.last_error_code,''),COALESCE(j.version,0),COALESCE((SELECT a.id::text FROM device_activation_attempt a WHERE a.activation_id=j.id AND a.executor='DEVICE' AND a.status IN ('CONTACT_POSSIBLE','UNKNOWN') ORDER BY a.created_at DESC LIMIT 1),j.current_attempt_id::text,''),COALESCE((SELECT max(key_version) FROM device_onboarding_key k WHERE k.device_id=d.id AND k.status='PENDING'),0),COALESCE((SELECT max(key_version) FROM device_onboarding_key k WHERE k.device_id=d.id AND k.status='ACTIVE'),0),COALESCE((SELECT ej.id::text FROM device_enrollment_job ej WHERE ej.device_id=d.id ORDER BY created_at DESC LIMIT 1),'') FROM device d LEFT JOIN device_activation_job j ON j.device_id=d.id WHERE d.tenant_id=$1::uuid AND d.enrollment_mode='physical_confirm' ORDER BY d.created_at DESC LIMIT 200`, a.TenantID) if err != nil { return nil, err } defer rows.Close() items := []map[string]any{} for rows.Next() { var id, factory, model, job, status, name, code, enrollment, attempt string var gen, rev, ver, key, pendingKey int64 var released bool if err = rows.Scan(&id, &factory, &model, &gen, &rev, &released, &job, &status, &name, &code, &ver, &attempt, &pendingKey, &key, &enrollment); err != nil { return nil, err } items = append(items, map[string]any{"platform_device_id": id, "device_id": factory, "model": model, "binding_generation": gen, "device_revision": rev, "released": released, "activation_id": job, "activation_state": status, "provider_device_name": name, "last_error_code": code, "activation_version": ver, "key_version": key, "pending_key_version": pendingKey, "current_attempt_id": attempt, "enrollment_job_id": enrollment}) } return map[string]any{"items": items, "limit": 200}, rows.Err() } type ReconcileInput struct { ExpectedVersion int64 `json:"expected_version"` Conclusion string `json:"conclusion"` EvidenceRef string `json:"evidence_ref"` Reason string `json:"reason"` AttemptID string `json:"attempt_id,omitempty"` DeviceSecret string `json:"device_secret,omitempty"` RTCAppID string `json:"rtc_app_id,omitempty"` } func (s *Service) Reconcile(ctx context.Context, p identity.Principal, tenant, activationID, requestID string, in ReconcileInput) (map[string]any, error) { if !uuidV4(activationID) || in.ExpectedVersion < 1 || !nonempty(in.EvidenceRef, 500) || !nonempty(in.Reason, 200) || !oneOf(in.Conclusion, "OBSERVED_REGISTERED", "CONFIRMED_NOT_REGISTERED", "DEVICE_ATTEMPT_STOPPED") { return nil, fail(400, "VALIDATION_FAILED", "对账结论、版本和证据必填") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) a, err := operatorAccess(ctx, tx, p, tenant, true) if err != nil { return nil, err } if a.Role != "ops_admin" { return nil, errAccess } var device string err = tx.QueryRow(ctx, `SELECT device_id::text FROM device_activation_job WHERE id=$1::uuid AND tenant_id=$2::uuid`, activationID, a.TenantID).Scan(&device) if errors.Is(err, pgx.ErrNoRows) { return nil, errAccess } if err != nil { return nil, err } d, err := readDevice(ctx, tx, device, true) if err != nil { return nil, err } j, err := readJob(ctx, tx, d.ID) if err != nil { return nil, err } if j.Version != in.ExpectedVersion { return nil, fail(409, "ACTIVATION_VERSION_CONFLICT", "任务已变化,请刷新后核对") } if in.Conclusion == "DEVICE_ATTEMPT_STOPPED" { if !uuidV4(in.AttemptID) { return nil, fail(400, "VALIDATION_FAILED", "请指定设备尝试") } tag, err := tx.Exec(ctx, `UPDATE device_activation_attempt SET status='DEFINITE_FAILURE',completed_at=now(),evidence=jsonb_build_object('operator_evidence',$3::text) WHERE id=$1::uuid AND activation_id=$2::uuid AND executor='DEVICE' AND stage='CONVERSATION_TEST' AND status IN ('CONTACT_POSSIBLE','UNKNOWN') AND permit_expires_at 0 { return nil, errState } var attempt string var ended bool err = tx.QueryRow(ctx, `SELECT id::text,permit_expires_at 4096 || len(in.RTCAppID) > 200 { return nil, fail(400, "VALIDATION_FAILED", "请提供核验后的设备凭据") } if in.DeviceSecret != "" { j.Secret = s.crypt.seal([]byte(in.DeviceSecret), "provider:"+j.ID) } observed := s.now() j.Registered = &observed j.RTCAppID = in.RTCAppID status := "BLOCKED" var bound bool if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL AND generation=$2)`, d.ID, d.Generation).Scan(&bound); err != nil { return nil, err } if bound && deviceAllowed(d) && j.Generation == d.Generation { status = "REGISTERED_OBSERVED" } _, err = tx.Exec(ctx, `UPDATE device_activation_job SET provider_secret_ciphertext=$2,provider_rtc_app_id=$3,status=$4,sdk_registered_at=COALESCE(sdk_registered_at,now()),engine_ready_at=now() WHERE id=$1::uuid`, j.ID, j.Secret, j.RTCAppID, status) if err != nil { return nil, err } _, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='SUCCESS_OBSERVED',completed_at=now(),evidence=jsonb_build_object('operator_evidence',$2::text) WHERE id=$1::uuid`, attempt, in.EvidenceRef) if err != nil { return nil, err } j.Status = status if status == "REGISTERED_OBSERVED" { if d.Lifecycle == "BOUND" { if err = requireLifecycle(ctx, tx, d, deviceactivation.Provisioning, requestID); err != nil { return nil, err } } if err = s.ensureConfig(ctx, tx, d, &j); err != nil { return nil, err } } } else { _, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='DEFINITE_FAILURE',completed_at=now(),evidence=jsonb_build_object('operator_evidence',$2::text) WHERE id=$1::uuid`, attempt, in.EvidenceRef) if err != nil { return nil, err } _, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='NOT_STARTED',current_attempt_id=NULL WHERE id=$1::uuid`, j.ID) if err != nil { return nil, err } dedup := "reconcile:" + requestID payload, _ := json.Marshal(map[string]any{"device_id": d.ID, "activation_id": j.ID, "binding_generation": d.Generation}) _, err = s.outbox.Enqueue(ctx, tx, outbox.NewEvent{TenantID: &d.TenantID, AggregateType: "device", AggregateID: d.ID, EventType: ActivationEvent, DeduplicationKey: &dedup, Payload: payload}) if err != nil { return nil, err } } } _, err = tx.Exec(ctx, `UPDATE device_activation_job SET version=version+1,reconciliation_evidence=$2,last_error_code=NULL,status=CASE WHEN $3='DEVICE_ATTEMPT_STOPPED' THEN 'REGISTERED_OBSERVED' ELSE status END,updated_at=now() WHERE id=$1::uuid`, j.ID, in.EvidenceRef, in.Conclusion) if err != nil { return nil, err } if err = bump(ctx, tx, d.ID); err != nil { return nil, err } if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "onboarding.activation.reconcile", requestID, map[string]any{"activation_id": j.ID, "conclusion": in.Conclusion, "evidence_ref": in.EvidenceRef, "reason": in.Reason}); err != nil { return nil, err } st, err := s.state(ctx, tx, d) if err != nil { return nil, err } return map[string]any{"state": st}, tx.Commit(ctx) } // This status route remains available after the temporary setup closes. func (s *Service) AccountDevices(ctx context.Context, p identity.Principal) (map[string]any, error) { rows, err := s.pool.Query(ctx, `SELECT d.public_id::text,d.factory_device_id,d.binding_generation,d.onboarding_revision,COALESCE(j.status,'NOT_STARTED'),h.name,sp.preferred_name FROM device d JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL JOIN household h ON h.id=b.household_id AND h.status='active' JOIN subject_profile sp ON sp.id=b.subject_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN tenant t ON t.id=d.tenant_id AND t.status='active' LEFT JOIN device_activation_job j ON j.device_id=d.id WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND d.enrollment_mode='physical_confirm' ORDER BY d.registered_at DESC LIMIT 200`, p.Issuer, p.Subject) if err != nil { return nil, err } defer rows.Close() items := []map[string]any{} for rows.Next() { var id, factory, status, household, name string var gen, rev int64 if err = rows.Scan(&id, &factory, &gen, &rev, &status, &household, &name); err != nil { return nil, err } items = append(items, map[string]any{"platform_device_id": id, "device_id": factory, "binding_generation": gen, "device_revision": rev, "activation_state": status, "household_name": household, "preferred_name": name}) } return map[string]any{"items": items}, rows.Err() } // Recovered settings never reset ownership; the regular authorized unbind // route must first close the binding, then firmware may clear local user data. func requireLifecycle(ctx context.Context, tx pgx.Tx, d Device, target deviceactivation.State, requestID string) error { _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: d.ID, TenantID: d.TenantID, Target: target, Reason: "首次接入状态同步", Source: "onboarding", ActorType: "system", RequestID: requestID}) return err }