package onboarding import ( "context" "errors" "data-terminal-cloud/internal/identity" "github.com/jackc/pgx/v5" ) type BLEDeviceInput struct { DeviceID string `json:"device_id"` } // BLEDeviceState does not authenticate the radio. It authorizes the signed-in // account and returns the authoritative cloud binding state, never owner data. func (s *Service) BLEDeviceState(ctx context.Context, p identity.Principal, in BLEDeviceInput) (map[string]any, error) { a, err := readBLEDeviceAccess(ctx, s.pool, p, in.DeviceID, false) if err != nil { return nil, err } return map[string]any{"device_id": in.DeviceID, "id": a.Device.PublicID, "model": a.Device.Model, "bound": a.Bound}, nil } type bleDeviceAccess struct { Device Device UserID string Bound bool } // Keep state checks and token issuance consistent. Errors describe the failed // condition, never the identity or profile of another device owner. func readBLEDeviceAccess(ctx context.Context, q queryer, p identity.Principal, factoryID string, lock bool) (bleDeviceAccess, error) { var a bleDeviceAccess if !factoryPattern.MatchString(factoryID) { return a, fail(400, "DEVICE_ID_INVALID", "设备编号格式不正确,请重新搜索并选择设备") } d := &a.Device var tenantStatus, userStatus, accountKind string var knownAccount, familyBound, accountBound, accountCurrent, ownAccount, manageFamily bool suffix := "" if lock { suffix = " FOR UPDATE OF d" } err := q.QueryRow(ctx, `SELECT d.id::text,d.public_id::text,d.tenant_id::text,d.factory_device_id,d.model, d.status,d.activation_state,d.enrollment_mode,d.binding_generation,d.onboarding_released_at IS NOT NULL,t.status, COALESCE(ua.id::text,''),COALESCE(ua.status,''),COALESCE(ua.account_kind,''), EXISTS(SELECT 1 FROM user_account WHERE oidc_issuer=$2 AND oidc_subject=$3), EXISTS(SELECT 1 FROM device_binding WHERE device_id=d.id AND unbound_at IS NULL), EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=d.id AND released_at IS NULL), EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=d.id AND released_at IS NULL AND generation=d.binding_generation), EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=d.id AND released_at IS NULL AND generation=d.binding_generation AND user_id=ua.id), EXISTS(SELECT 1 FROM device_binding db JOIN household_member hm ON hm.household_id=db.household_id AND hm.tenant_id=db.tenant_id JOIN household h ON h.id=hm.household_id AND h.status='active' WHERE db.device_id=d.id AND db.unbound_at IS NULL AND hm.user_id=ua.id AND hm.status='active' AND hm.role IN ('owner','admin','editor')) FROM device d JOIN tenant t ON t.id=d.tenant_id LEFT JOIN user_account ua ON ua.tenant_id=d.tenant_id AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 WHERE d.factory_device_id=$1`+suffix, factoryID, p.Issuer, p.Subject).Scan( &d.ID, &d.PublicID, &d.TenantID, &d.FactoryID, &d.Model, &d.Status, &d.Lifecycle, &d.Mode, &d.Generation, &d.Released, &tenantStatus, &a.UserID, &userStatus, &accountKind, &knownAccount, &familyBound, &accountBound, &accountCurrent, &ownAccount, &manageFamily) if errors.Is(err, pgx.ErrNoRows) { return a, fail(404, "DEVICE_NOT_REGISTERED", "平台未找到该设备的注册记录,请联系管理员登记设备") } if err != nil { return a, err } if a.UserID == "" { if knownAccount { return a, fail(403, "DEVICE_TENANT_MISMATCH", "设备不属于当前账号所在组织,请切换到设备所属组织的账号") } return a, fail(403, "ACCOUNT_NOT_FOUND", "当前登录身份没有对应的平台账号,请重新登录或联系管理员") } if userStatus != "active" { if userStatus == "deleted" { return a, fail(403, "ACCOUNT_DELETED", "当前账号已删除,请使用其他有效账号登录") } return a, fail(403, "ACCOUNT_DISABLED", "当前账号已停用,请联系管理员恢复账号") } if accountKind != "customer" { return a, fail(403, "CUSTOMER_REQUIRED", "当前登录的是管理账号,请切换到家庭账号添加设备") } if tenantStatus != "active" { return a, fail(403, "TENANT_INACTIVE", "设备所属组织已停用,请联系平台管理员") } if d.Status == "revoked" || d.Lifecycle == "REVOKED" { return a, fail(403, "DEVICE_REVOKED", "设备已被停用,无法添加或配网,请联系管理员") } if d.Lifecycle == "LOST" { return a, fail(403, "DEVICE_LOST", "设备已被标记为丢失,请先联系管理员解除丢失状态") } if d.Status == "quarantined" { return a, fail(403, "DEVICE_QUARANTINED", "设备已被隔离,暂时禁止添加或配网,请联系管理员检查设备") } if d.Mode != "physical_confirm" { return a, fail(409, "DEVICE_ENROLLMENT_UNSUPPORTED", "该设备尚未启用蓝牙实体确认绑定,请联系管理员完成设备接入配置") } if !d.Released { return a, fail(409, "DEVICE_NOT_RELEASED", "设备已登记但尚未放行,请联系管理员完成出厂放行") } if accountBound && !accountCurrent && !manageFamily { return a, fail(409, "DEVICE_BINDING_STALE", "设备归属记录与当前设备状态不一致,请联系管理员处理") } if accountBound && !ownAccount && !manageFamily { return a, fail(403, "DEVICE_OWNED_BY_OTHER_ACCOUNT", "设备已添加到其他账号,请使用原账号操作;如需更换账号,请先由原账号移除设备") } if familyBound && !manageFamily && !ownAccount { return a, fail(403, "DEVICE_MANAGE_PERMISSION_REQUIRED", "设备已绑定家庭,当前账号没有该家庭的设备管理权限,请联系家庭管理员授权") } a.Bound = familyBound || accountCurrent return a, nil } // DeviceBLEState is read-only: an empty BLE bind_token must never create a // binding or bypass a cloud reset. Device authentication remains HMAC + HTTPS. func (s *Service) DeviceBLEState(ctx context.Context, d Device) (map[string]any, error) { if !deviceAllowed(d) { return nil, errAccess } var bound bool err := s.pool.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 ab JOIN device d ON d.id=ab.device_id WHERE ab.device_id=$1::uuid AND ab.released_at IS NULL AND ab.generation=d.binding_generation)`, d.ID).Scan(&bound) return map[string]any{"bound": bound}, err }