410 lines
17 KiB
Go
410 lines
17 KiB
Go
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
|
||
|
|
}
|