package onboarding import ( "context" "errors" "time" "data-terminal-cloud/internal/deviceactivation" "data-terminal-cloud/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) func bleClaimPermission(ctx context.Context, tx pgx.Tx, c bleClaim) error { if c.SubjectID != "" { return claimPermission(ctx, tx, c.claimRecord) } var active bool err := tx.QueryRow(ctx, `SELECT true FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ua.id=$1::uuid AND ua.tenant_id=$2::uuid AND ua.status='active' AND ua.account_kind='customer' FOR SHARE OF ua,t`, c.UserID, c.TenantID).Scan(&active) if errors.Is(err, pgx.ErrNoRows) { return errAccess } return err } // This runs only after the authenticated device submits the physically confirmed token. func (s *Service) commitBLEAccount(ctx context.Context, tx pgx.Tx, d Device, c bleClaim, requestID string) (map[string]any, error) { 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", "设备已添加到账号") } var ownerID string err := tx.QueryRow(ctx, `INSERT INTO device_account_binding(device_id,tenant_id,user_id,generation) VALUES($1::uuid,$2::uuid,$3::uuid,$4) RETURNING id::text`, d.ID, d.TenantID, c.UserID, d.Generation+1).Scan(&ownerID) if err != nil { return nil, err } if _, err = tx.Exec(ctx, `UPDATE device SET binding_generation=binding_generation+1,onboarding_revision=onboarding_revision+1 WHERE id=$1::uuid`, d.ID); err != nil { return nil, err } if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='COMMITTED',account_binding_id=$2::uuid,completed_at=now() WHERE id=$1::uuid`, c.ID, ownerID); 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 = 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 } // Preserve the existing device response (including the five provider fields). // Chat still requires a real subject binding; account ownership alone cannot start it. if err = s.enqueueActivation(ctx, tx, d, d.Generation+1, requestID); err != nil { return nil, err } if err = s.auditEvent(ctx, tx, d, "device", d.PublicID, "device.account.ble", requestID, map[string]any{"account_binding_id": ownerID, "binding_generation": d.Generation + 1}); err != nil { return nil, err } return map[string]any{"status": "success"}, tx.Commit(ctx) } type AssignDeviceSubjectInput struct { DeviceID string `json:"device_id"` ElderID string `json:"elder_id"` ExpectedAccountBindingID string `json:"expected_account_binding_id"` } // Account ownership already proves physical possession; later subject selection // requires both that same owner and management access to the selected family. func (s *Service) AssignDeviceSubject(ctx context.Context, p identity.Principal, requestID string, in AssignDeviceSubjectInput) (map[string]any, error) { if !uuidValid(in.DeviceID) || !uuidValid(in.ElderID) || !uuidValid(in.ExpectedAccountBindingID) || !uuidV4(requestID) { return nil, fail(400, "VALIDATION_FAILED", "设备、老人或归属版本不正确") } if !s.Enabled() || s.options.PauseClaims { return nil, fail(503, "ENROLLMENT_PAUSED", "设备绑定暂时暂停") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var deviceID string err = tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid`, in.DeviceID).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 } var userID, userPublicID, assignedBindingID string err = tx.QueryRow(ctx, `SELECT ua.id::text,ua.public_id::text,COALESCE(ab.assigned_binding_id::text,'') FROM device_account_binding ab JOIN user_account ua ON ua.id=ab.user_id AND ua.tenant_id=ab.tenant_id JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ab.id=$1::uuid AND ab.device_id=$2::uuid AND ((ab.released_at IS NULL AND ab.generation=$3) OR EXISTS(SELECT 1 FROM device_binding b JOIN subject_profile sp ON sp.id=b.subject_id WHERE b.id=ab.assigned_binding_id AND b.unbound_at IS NULL AND b.generation=$3 AND sp.public_id=$6::uuid)) AND ua.oidc_issuer=$4 AND ua.oidc_subject=$5 AND ua.status='active' AND ua.account_kind='customer' FOR SHARE OF ua,t`, in.ExpectedAccountBindingID, d.ID, d.Generation, p.Issuer, p.Subject, in.ElderID).Scan(&userID, &userPublicID, &assignedBindingID) if errors.Is(err, pgx.ErrNoRows) { return nil, errAccess } if err != nil { return nil, err } c := bleClaim{claimRecord: claimRecord{ID: uuid.NewString(), DeviceID: d.ID, TenantID: d.TenantID, UserID: userID, Generation: d.Generation, Expires: s.now().Add(5 * time.Minute)}, BindingStatus: "PENDING"} err = tx.QueryRow(ctx, `SELECT id::text,household_id::text FROM subject_profile WHERE public_id=$1::uuid AND tenant_id=$2::uuid`, in.ElderID, d.TenantID).Scan(&c.SubjectID, &c.HouseholdID) if errors.Is(err, pgx.ErrNoRows) { return nil, errAccess } if err != nil { return nil, err } if err = claimPermission(ctx, tx, c.claimRecord); err != nil { return nil, err } if assignedBindingID != "" { return map[string]any{"status": "success", "device_id": in.DeviceID, "elder_id": in.ElderID}, tx.Commit(ctx) } // Expire stale legacy setup claims before inserting the assignment claim. if err = s.expireClaims(ctx, tx, d.ID); err != nil { return nil, err } var busy bool err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING') OR EXISTS(SELECT 1 FROM fleet_ota WHERE device_id=$1::uuid AND status IN ('pending','accepted'))`, d.ID).Scan(&busy) if err != nil { return nil, err } if busy { return nil, fail(409, "DEVICE_BUSY", "设备有待确认操作或升级,请完成后重试") } token := randomToken(32) 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, userID); err != nil { return nil, err } _, err = tx.Exec(ctx, `INSERT INTO device_ble_binding(id,token_hash,device_id,tenant_id,user_id,household_id,subject_id,expected_generation,expires_at) VALUES($1::uuid,$2,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7::uuid,$8,$9)`, c.ID, digest([]byte(token)), d.ID, d.TenantID, userID, c.HouseholdID, c.SubjectID, d.Generation, c.Expires) if err != nil { return nil, err } if _, err = tx.Exec(ctx, `SELECT set_config('app.device_account_binding',$1,true)`, in.ExpectedAccountBindingID); err != nil { return nil, err } c.AccountBindingID = in.ExpectedAccountBindingID if _, err = s.commitBLESubject(ctx, tx, d, c, requestID, token); err != nil { return nil, err } if err = s.auditEvent(ctx, tx, d, "user", userPublicID, "device.account.assign_subject", requestID, map[string]any{"elder_id": in.ElderID, "account_binding_id": in.ExpectedAccountBindingID}); err != nil { return nil, err } return map[string]any{"status": "success", "device_id": in.DeviceID, "elder_id": in.ElderID}, tx.Commit(ctx) }