Files

152 lines
7.6 KiB
Go
Raw Permalink Normal View History

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)
}