Files

124 lines
6.2 KiB
Go
Raw Permalink Normal View History

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
}