package onboarding import ( "context" "errors" "net/http" "strings" "time" "data-terminal-cloud/internal/deviceactivation" "data-terminal-cloud/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) type BLETokenInput struct { DeviceID string `json:"device_id"` ElderID string `json:"elder_id,omitempty"` ExpectedBindingID string `json:"expected_binding_id,omitempty"` } type BLEBindInput struct { Token string `json:"bind_token"` } type bleClaim struct { claimRecord AccountBindingID string BindingStatus string } func readBLEClaim(ctx context.Context, tx pgx.Tx, token, deviceID string) (bleClaim, error) { var c bleClaim err := tx.QueryRow(ctx, `SELECT id::text,device_id::text,tenant_id::text,user_id::text,COALESCE(household_id::text,''),COALESCE(subject_id::text,''),expected_generation,status,expires_at,COALESCE(binding_id::text,''),COALESCE(account_binding_id::text,'') FROM device_ble_binding WHERE token_hash=$1 AND device_id=$2::uuid FOR UPDATE`, digest([]byte(token)), deviceID).Scan(&c.ID, &c.DeviceID, &c.TenantID, &c.UserID, &c.HouseholdID, &c.SubjectID, &c.Generation, &c.BindingStatus, &c.Expires, &c.BindingID, &c.AccountBindingID) if errors.Is(err, pgx.ErrNoRows) { return c, fail(404, "BIND_TOKEN_INVALID", "绑定申请不存在或不可访问") } return c, err } func (s *Service) bleWebHTTP(r *http.Request) (any, error) { if r.URL.RawQuery != "" { return nil, errAccess } p, err := s.browserPrincipal(r, true) if err != nil { return nil, err } if err = s.limit(r.Context(), "ble-account:"+p.Issuer+":"+p.Subject, 90, time.Minute); err != nil { return nil, err } raw, err := readBody(r) if err != nil { return nil, err } if strings.HasSuffix(r.URL.Path, "ble-device-state") { return decodeCall(raw, func(in BLEDeviceInput) (map[string]any, error) { return s.BLEDeviceState(r.Context(), p, in) }) } if strings.HasSuffix(r.URL.Path, "device-subject") { return decodeCall(raw, func(in AssignDeviceSubjectInput) (map[string]any, error) { return s.AssignDeviceSubject(r.Context(), p, r.Header.Get("X-Request-ID"), in) }) } if strings.HasSuffix(r.URL.Path, "ble-bind-tokens") { var in BLETokenInput if err = StrictJSON(raw, &in); err != nil { return nil, err } return s.CreateBLEToken(r.Context(), p, in) } var in BLEBindInput if err = StrictJSON(raw, &in); err != nil { return nil, err } return s.BLEStatus(r.Context(), p, in) } func (s *Service) CreateBLEToken(ctx context.Context, p identity.Principal, in BLETokenInput) (map[string]any, error) { if !s.Enabled() || s.options.PauseClaims { return nil, fail(503, "ENROLLMENT_PAUSED", "设备绑定暂时暂停") } if in.ElderID != "" && !uuidValid(in.ElderID) { return nil, fail(400, "ELDER_ID_INVALID", "老人编号格式不正确,请重新选择老人") } if in.ExpectedBindingID != "" && (!uuidValid(in.ExpectedBindingID) || in.ElderID == "") { return nil, fail(400, "BINDING_INVALID", "更换使用老人需要原绑定版本和新的老人") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) if err = lockBLEControl(ctx, tx, "", in.DeviceID); err != nil { return nil, err } a, err := readBLEDeviceAccess(ctx, tx, p, in.DeviceID, true) if err != nil { return nil, err } d := a.Device var user string err = tx.QueryRow(ctx, `SELECT id::text FROM user_account WHERE tenant_id=$1::uuid AND oidc_issuer=$2 AND oidc_subject=$3 AND status='active' AND account_kind='customer' FOR SHARE`, d.TenantID, p.Issuer, p.Subject).Scan(&user) if errors.Is(err, pgx.ErrNoRows) { return nil, errAccess } if err != nil { return nil, err } 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) OR EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND released_at IS NULL)`, d.ID).Scan(&bound); err != nil { return nil, err } if bound && in.ExpectedBindingID == "" { return nil, fail(409, "ALREADY_BOUND", "设备已有归属,无需重复添加;如需更换 Wi-Fi,请使用重新配网") } var sourceBindingID string if in.ExpectedBindingID != "" { err = tx.QueryRow(ctx, `SELECT id::text FROM device_binding WHERE device_id=$1::uuid AND public_id=$2::uuid AND unbound_at IS NULL`, d.ID, in.ExpectedBindingID).Scan(&sourceBindingID) if errors.Is(err, pgx.ErrNoRows) { return nil, fail(409, "BINDING_CHANGED", "设备归属已变化,请刷新后重新选择") } if err != nil { return nil, err } } // Omitting elder_id claims the device for this account without creating profiles. if in.ElderID == "" { if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND user_id=$2::uuid AND status='PENDING'`, d.ID, user); err != nil { return nil, err } token := randomToken(32) _, err = tx.Exec(ctx, `INSERT INTO device_ble_binding(token_hash,device_id,tenant_id,user_id,expected_generation,expires_at) VALUES($1,$2::uuid,$3::uuid,$4::uuid,$5,$6)`, digest([]byte(token)), d.ID, d.TenantID, user, d.Generation, s.now().Add(5*time.Minute)) if err != nil { return nil, err } return map[string]any{"bind_token": token}, tx.Commit(ctx) } // Explicit elder_id retains the existing one-step binding contract. rows, err := tx.Query(ctx, `SELECT sp.id::text,sp.household_id::text FROM subject_profile sp JOIN household_member hm ON hm.household_id=sp.household_id AND hm.tenant_id=sp.tenant_id JOIN household h ON h.id=sp.household_id AND h.status='active' WHERE sp.tenant_id=$1::uuid AND hm.user_id=$2::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') AND sp.status='active' AND ($3='' OR sp.public_id::text=$3) ORDER BY sp.id LIMIT 2`, d.TenantID, user, in.ElderID) if err != nil { return nil, err } var candidates []claimRecord for rows.Next() { c := claimRecord{UserID: user, TenantID: d.TenantID} if err = rows.Scan(&c.SubjectID, &c.HouseholdID); err != nil { rows.Close() return nil, err } candidates = append(candidates, c) } err = rows.Err() rows.Close() if err != nil { return nil, err } if len(candidates) != 1 { return nil, fail(400, "PROFILE_REQUIRED", "请选择一个有管理权限的使用者") } c := candidates[0] if err = claimPermission(ctx, tx, c); err != nil { return nil, err } if sourceBindingID != "" { if err = s.checkBLETransfer(ctx, tx, d, c, sourceBindingID); err != nil { return nil, err } } if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND user_id=$2::uuid AND status='PENDING'`, d.ID, user); err != nil { return nil, err } token := randomToken(32) // For a pending transfer binding_id pins the source; commit replaces it with // the new binding. A normal add keeps it NULL until physical confirmation. _, err = tx.Exec(ctx, `INSERT INTO device_ble_binding(token_hash,device_id,tenant_id,user_id,household_id,subject_id,expected_generation,expires_at,binding_id) VALUES($1,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7,$8,NULLIF($9,'')::uuid)`, digest([]byte(token)), d.ID, d.TenantID, user, c.HouseholdID, c.SubjectID, d.Generation, s.now().Add(5*time.Minute), sourceBindingID) if err != nil { return nil, err } if err = tx.Commit(ctx); err != nil { return nil, err } return map[string]any{"bind_token": token}, nil } func (s *Service) BLEStatus(ctx context.Context, p identity.Principal, in BLEBindInput) (map[string]any, error) { if !validB64(in.Token, 32) { return nil, fail(400, "VALIDATION_FAILED", "绑定令牌格式不正确") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var deviceID string err = tx.QueryRow(ctx, `SELECT b.device_id::text FROM device_ble_binding b JOIN user_account ua ON ua.id=b.user_id AND ua.status='active' WHERE b.token_hash=$1 AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, digest([]byte(in.Token)), p.Issuer, p.Subject).Scan(&deviceID) if errors.Is(err, pgx.ErrNoRows) { return nil, errAccess } if err != nil { return nil, err } d, err := readDevice(ctx, tx, deviceID, true) if err != nil { return nil, err } if !deviceAllowed(d) { return nil, errAccess } c, err := readBLEClaim(ctx, tx, in.Token, d.ID) if err != nil { return nil, err } if err = bleClaimPermission(ctx, tx, c); err != nil { return nil, err } status, err := bleResult(ctx, tx, d, c, s.now()) if err != nil { return nil, err } return status, tx.Commit(ctx) } func bleResult(ctx context.Context, tx pgx.Tx, d Device, c bleClaim, now time.Time) (map[string]any, error) { if c.BindingStatus == "COMMITTED" && c.AccountBindingID != "" { var active bool err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_account_binding ab WHERE ab.id=$1::uuid AND ab.device_id=$2::uuid AND ab.user_id=$3::uuid AND ((ab.released_at IS NULL AND ab.generation=$4) OR EXISTS(SELECT 1 FROM device_binding b WHERE b.id=ab.assigned_binding_id AND b.unbound_at IS NULL AND b.generation=$4)))`, c.AccountBindingID, d.ID, c.UserID, d.Generation).Scan(&active) if err != nil { return nil, err } if active { return map[string]any{"status": "success"}, nil } return map[string]any{"status": "failed", "message": "设备归属已变化,请刷新设备列表"}, nil } if c.BindingStatus == "COMMITTED" { var active bool err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE id=$1::uuid AND device_id=$2::uuid AND unbound_at IS NULL AND generation=$3)`, c.BindingID, d.ID, c.Generation+1).Scan(&active) if err != nil { return nil, err } if active && d.Generation == c.Generation+1 { return map[string]any{"status": "success"}, nil } return map[string]any{"status": "failed", "message": "设备归属已变化,请刷新设备列表"}, nil } if c.BindingStatus != "PENDING" || !c.Expires.After(now) || c.Generation != d.Generation { return map[string]any{"status": "failed", "message": "绑定申请已过期或失效,请重新配网"}, nil } return map[string]any{"status": "pending"}, nil } // Only an authenticated device calls this after its physical confirmation. The // request ID is the device's persisted confirmation event; no user-supplied owner // or Wi-Fi password crosses this boundary. func (s *Service) BindBLE(ctx context.Context, authenticated Device, requestID string, in BLEBindInput) (map[string]any, error) { result, err := s.commitBLEBinding(ctx, authenticated, requestID, in) if err != nil || s.options.LegacyDeviceRegistration { return result, err } // The binding is durable before any provider HTTP call. A retry of the same // token resumes this step without inserting another binding/registration. return s.finishBLEVoice(ctx, authenticated, in) } func (s *Service) commitBLEBinding(ctx context.Context, authenticated Device, requestID string, in BLEBindInput) (map[string]any, error) { if !validB64(in.Token, 32) || !uuidV4(requestID) { return nil, fail(400, "VALIDATION_FAILED", "绑定请求不正确") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) if err = lockBLEControl(ctx, tx, authenticated.ID, ""); err != nil { return nil, err } d, err := readDevice(ctx, tx, authenticated.ID, true) if err != nil { return nil, err } if !deviceAllowed(d) { return nil, errAccess } var keyActive bool err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_onboarding_key WHERE device_id=$1::uuid AND key_version=$2 AND status='ACTIVE')`, d.ID, authenticated.KeyVersion).Scan(&keyActive) if err != nil { return nil, err } if !keyActive { return nil, errAuth } c, err := readBLEClaim(ctx, tx, in.Token, d.ID) if err != nil { return nil, err } if err = bleClaimPermission(ctx, tx, c); err != nil { return nil, err } result, err := bleResult(ctx, tx, d, c, s.now()) if err != nil { return nil, err } if result["status"] == "success" { return result, tx.Commit(ctx) } if result["status"] != "pending" { return nil, fail(409, "BIND_TOKEN_EXPIRED", "绑定申请已过期或归属发生变化") } if !s.Enabled() || s.options.PauseClaims { return nil, fail(503, "ENROLLMENT_PAUSED", "设备绑定暂时暂停") } if c.BindingID != "" { return s.commitBLETransfer(ctx, tx, d, c, requestID, in.Token) } var active bool err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, d.ID).Scan(&active) if err != nil { return nil, err } if active { return nil, fail(409, "ALREADY_BOUND", "设备已有有效绑定") } if err = s.expireClaims(ctx, tx, d.ID); err != nil { return nil, err } var pending bool err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING')`, d.ID).Scan(&pending) if err != nil { return nil, err } if pending { return nil, fail(409, "CLAIM_BUSY", "设备还有另一笔待确认绑定") } if c.SubjectID == "" { return s.commitBLEAccount(ctx, tx, d, c, requestID) } var owned bool if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND released_at IS NULL)`, d.ID).Scan(&owned); err != nil { return nil, err } if owned { return nil, fail(409, "ALREADY_BOUND", "设备已添加到账号,请从设备管理绑定老人") } result, err = s.commitBLESubject(ctx, tx, d, c, requestID, in.Token) if err != nil { return nil, err } return result, tx.Commit(ctx) } func (s *Service) commitBLESubject(ctx context.Context, tx pgx.Tx, d Device, c bleClaim, requestID, token string) (map[string]any, error) { var err error // Reuse the existing database guard and activation pipeline. BLE does not // depend on a web handoff or a pre-network device boot registration. setupID, bootID := uuid.NewString(), d.BootID if bootID == "" { bootID = uuid.NewString() } _, err = tx.Exec(ctx, `INSERT INTO device_setup_session(id,device_id,tenant_id,boot_id,local_setup_id,binding_generation,mode,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6,'ENROLL',$7)`, setupID, d.ID, d.TenantID, bootID, c.ID, d.Generation, c.Expires) if err != nil { return nil, err } _, err = tx.Exec(ctx, `INSERT INTO device_binding_claim(id,device_id,tenant_id,setup_id,user_id,household_id,subject_id,expected_generation,challenge_hash,challenge_ciphertext,binding_digest,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7::uuid,$8,$9,$10,$11,$12)`, c.ID, d.ID, d.TenantID, setupID, c.UserID, c.HouseholdID, c.SubjectID, d.Generation, digest([]byte(token)), s.crypt.seal([]byte(token), "claim:"+c.ID), bindingDigest(d, c.UserID, c.HouseholdID, c.SubjectID, c.ID), c.Expires) if err != nil { return nil, err } if _, err = tx.Exec(ctx, `SELECT set_config('app.onboarding_claim',$1,true)`, c.ID); err != nil { return nil, err } var bindingID string err = tx.QueryRow(ctx, `INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,'ble_physical_confirm') RETURNING id::text`, d.TenantID, d.ID, c.HouseholdID, c.SubjectID, c.UserID).Scan(&bindingID) if err != nil { return nil, err } if _, err = tx.Exec(ctx, `UPDATE device_binding_claim SET status='CONFIRMED',press_event_id=$2::uuid,binding_id=$3::uuid,completed_at=now() WHERE id=$1::uuid`, c.ID, requestID, bindingID); err != nil { return nil, err } if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='COMMITTED',binding_id=$2::uuid,completed_at=now() WHERE id=$1::uuid`, c.ID, bindingID); err != nil { return nil, err } if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, d.ID); err != nil { return nil, err } if _, err = tx.Exec(ctx, `UPDATE device_setup_session SET binding_generation=$2,closed_at=now(),close_reason='BLE_BOUND' WHERE id=$1::uuid`, setupID, d.Generation+1); err != nil { return nil, err } if d.Lifecycle == "CLAIMABLE" { if _, err = deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: d.ID, TenantID: d.TenantID, Target: deviceactivation.Bound, Reason: "蓝牙配网实体确认绑定", Source: "onboarding", ActorType: "device", ActorPublicID: &d.PublicID, RequestID: requestID}); err != nil { return nil, err } } if err = s.enqueueActivation(ctx, tx, d, d.Generation+1, requestID); err != nil { return nil, err } _, err = tx.Exec(ctx, `INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) SELECT $1::uuid,$2::uuid,1,'{"voice_chat":true,"transcript_storage":false,"raw_audio_storage":false,"story_extraction":false,"personalized_memory":false,"family_sharing":false}'::jsonb,'[]'::jsonb WHERE NOT EXISTS(SELECT 1 FROM consent_snapshot WHERE tenant_id=$1::uuid AND subject_id=$2::uuid)`, d.TenantID, c.SubjectID) if err != nil { return nil, err } if c.AccountBindingID == "" { if err = s.auditEvent(ctx, tx, d, "device", d.PublicID, "device.binding.ble", requestID, map[string]any{"binding_generation": d.Generation + 1}); err != nil { return nil, err } } if _, err = s.state(ctx, tx, d); err != nil { return nil, err } return map[string]any{"status": "success"}, nil }