Files
2026-09-12 08:57:29 +08:00

140 lines
6.6 KiB
Go

package saas
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/devicebinding"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
type DeviceClaimResult struct {
BindingID string `json:"binding_id"`
DeviceID string `json:"device_id"`
HouseholdID string `json:"household_id"`
SubjectID string `json:"subject_id"`
BoundAt time.Time `json:"bound_at"`
}
func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal, claimCode, householdPublicID, subjectPublicID, requestID string) (DeviceClaimResult, bool, error) {
var result DeviceClaimResult
var replayed bool
digestBytes := sha256.Sum256([]byte(claimCode))
digest := hex.EncodeToString(digestBytes[:])
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityDeviceManage, true)
if err != nil {
return err
}
var subjectID string
if err := tx.QueryRow(ctx, `
SELECT id::text FROM subject_profile
WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND household_id=$3::uuid AND status <> 'deleted'
FOR SHARE`, subjectPublicID, access.tenantID, access.householdID).Scan(&subjectID); errors.Is(err, pgx.ErrNoRows) {
return ErrAccessDenied
} else if err != nil {
return fmt.Errorf("resolve claim subject: %w", err)
}
var claimID, deviceID, devicePublicID, activationState string
var expiresAt time.Time
var revokedAt, usedAt *time.Time
var usedBindingID *string
if err := tx.QueryRow(ctx, `
SELECT dc.id::text,dc.device_id::text,d.public_id::text,d.activation_state,dc.expires_at,dc.revoked_at,dc.used_at,dc.used_binding_id::text
FROM device_claim dc JOIN device d ON d.id=dc.device_id AND d.tenant_id=dc.tenant_id
WHERE dc.token_digest=$1 AND dc.tenant_id=$2::uuid AND d.status='registered'
FOR UPDATE OF d,dc`, digest, access.tenantID).Scan(&claimID, &deviceID, &devicePublicID, &activationState, &expiresAt, &revokedAt, &usedAt, &usedBindingID); errors.Is(err, pgx.ErrNoRows) {
return ErrDeviceClaimInvalid
} else if err != nil {
return fmt.Errorf("resolve device claim: %w", err)
}
if err = devicebinding.RequireLegacy(ctx, tx, deviceID); err != nil {
return err
}
if revokedAt != nil || !expiresAt.After(time.Now()) {
return ErrDeviceClaimInvalid
}
if usedAt != nil {
if usedBindingID == nil {
return ErrDeviceClaimInvalid
}
if err := tx.QueryRow(ctx, `
SELECT b.public_id::text,d.public_id::text,h.public_id::text,sp.public_id::text,b.bound_at
FROM device_binding b
JOIN device d ON d.id=b.device_id AND d.tenant_id=b.tenant_id
JOIN household h ON h.id=b.household_id AND h.tenant_id=b.tenant_id
JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id
WHERE b.id=$1::uuid AND b.unbound_at IS NULL AND b.generation=d.binding_generation`, *usedBindingID).Scan(&result.BindingID, &result.DeviceID, &result.HouseholdID, &result.SubjectID, &result.BoundAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrDeviceClaimUsed
}
return fmt.Errorf("read used device claim binding: %w", err)
}
if result.HouseholdID != householdPublicID || result.SubjectID != subjectPublicID {
return ErrDeviceClaimUsed
}
replayed = true
return nil
}
if activationState != string(deviceactivation.Claimable) {
return ErrDeviceClaimInvalid
}
var activeBinding bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, deviceID).Scan(&activeBinding); err != nil {
return fmt.Errorf("check active device binding: %w", err)
}
if activeBinding {
return ErrDeviceAlreadyBound
}
var bindingInternalID string
if 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,'one_time_claim')
RETURNING id::text,public_id::text,$6::text,$7::text,$8::text,bound_at`,
access.tenantID, deviceID, access.householdID, subjectID, access.userID,
devicePublicID, householdPublicID, subjectPublicID).Scan(&bindingInternalID, &result.BindingID, &result.DeviceID, &result.HouseholdID, &result.SubjectID, &result.BoundAt); err != nil {
return fmt.Errorf("insert claimed device binding: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE device_claim SET used_at=now(),used_binding_id=$1::uuid WHERE id=$2::uuid`, bindingInternalID, claimID); err != nil {
return fmt.Errorf("mark device claim used: %w", err)
}
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
Target: deviceactivation.Bound, Reason: "用户通过一次性绑定码认领设备", Source: "device-claim", ActorType: "user",
ActorPublicID: strPtr(access.userPublicID), RequestID: requestID}); err != nil {
return fmt.Errorf("activate claimed device binding: %w", err)
}
var runtime string
if err := tx.QueryRow(ctx, `SELECT COALESCE(desired_config->>'voice_runtime','legacy_voicechat') FROM device_twin WHERE device_id=$1::uuid`, deviceID).Scan(&runtime); err != nil {
return fmt.Errorf("read claimed device runtime: %w", err)
}
if runtime == "legacy_voicechat" {
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
Target: deviceactivation.Provisioning, Reason: "旧版语音运行时平台凭据预配", Source: "platform-provisioning", ActorType: "user",
ActorPublicID: strPtr(access.userPublicID), RequestID: requestID}); err != nil {
return fmt.Errorf("provision claimed legacy device: %w", err)
}
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
Target: deviceactivation.Active, Reason: "旧版语音运行时已具备绑定与平台凭据", Source: "platform-provisioning", ActorType: "user",
ActorPublicID: strPtr(access.userPublicID), RequestID: requestID}); err != nil {
return fmt.Errorf("activate claimed legacy device: %w", err)
}
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "device.claim", ResourceType: "device_binding", ResourceID: strPtr(result.BindingID),
RequestID: requestID, Result: "success", Detail: map[string]any{"device_id": devicePublicID, "household_id": householdPublicID, "subject_id": subjectPublicID},
})
})
return result, replayed, err
}