Files

218 lines
11 KiB
Go
Raw Permalink Normal View History

2026-08-29 21:56:51 +08:00
package saas
import (
"context"
"errors"
"fmt"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
2026-09-06 17:04:11 +08:00
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/devicebinding"
2026-08-29 21:56:51 +08:00
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
const (
DeviceBindingActionUnbind = "unbind"
DeviceBindingActionTransfer = "transfer"
)
type DeviceBindingImpact struct {
DeviceID string `json:"device_id"`
BindingID string `json:"binding_id"`
Action string `json:"action"`
SourceHouseholdID string `json:"source_household_id"`
SourceSubjectID string `json:"source_subject_id"`
SourceSubjectName string `json:"source_subject_name"`
TargetHouseholdID *string `json:"target_household_id,omitempty"`
TargetSubjectID *string `json:"target_subject_id,omitempty"`
TargetSubjectName *string `json:"target_subject_name,omitempty"`
ActiveConversationID *string `json:"active_conversation_id,omitempty"`
PendingCommandCount int `json:"pending_command_count"`
ActiveFirmwareTaskCount int `json:"active_firmware_task_count"`
Allowed bool `json:"allowed"`
RequiresPhysicalConfirmation bool `json:"requires_physical_confirmation,omitempty"`
Blockers []string `json:"blockers"`
EvaluatedAt time.Time `json:"evaluated_at"`
2026-08-29 21:56:51 +08:00
}
type ChangeDeviceBindingInput struct {
Action string
ExpectedBindingID string
TargetSubjectID string
Reason string
Acknowledged bool
}
type DeviceBindingChangeResult struct {
DeviceID string `json:"device_id"`
Action string `json:"action"`
PreviousBindingID string `json:"previous_binding_id"`
BindingID *string `json:"binding_id,omitempty"`
HouseholdID *string `json:"household_id,omitempty"`
SubjectID *string `json:"subject_id,omitempty"`
ChangedAt time.Time `json:"changed_at"`
}
func (s *Service) GetDeviceBindingImpact(ctx context.Context, principal identity.Principal, devicePublicID, action, targetSubjectPublicID string) (DeviceBindingImpact, error) {
access, device, err := resolveDevice(ctx, s.pool, principal, devicePublicID, identity.CapabilityDeviceManage, false)
if err != nil {
return DeviceBindingImpact{}, err
}
return s.readDeviceBindingImpact(ctx, s.pool, principal, access, device, action, targetSubjectPublicID)
}
func (s *Service) ChangeDeviceBinding(ctx context.Context, principal identity.Principal, devicePublicID, requestID string, input ChangeDeviceBindingInput) (DeviceBindingChangeResult, error) {
var result DeviceBindingChangeResult
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, device, err := resolveDevice(ctx, tx, principal, devicePublicID, identity.CapabilityDeviceManage, true)
if err != nil {
return err
}
if device.AccountBindingID != "" {
result, err = s.releaseAccountDevice(ctx, tx, principal, access, device, requestID, input)
return err
}
2026-08-29 21:56:51 +08:00
var bindingID string
if input.Action == DeviceBindingActionTransfer {
if err = devicebinding.RequireLegacy(ctx, tx, access.deviceID); err != nil {
return err
}
}
2026-08-29 21:56:51 +08:00
if err := tx.QueryRow(ctx, `SELECT public_id::text FROM device_binding WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND unbound_at IS NULL FOR UPDATE`, access.deviceID, access.tenantID).Scan(&bindingID); errors.Is(err, pgx.ErrNoRows) {
return ErrDeviceBindingStale
} else if err != nil {
return fmt.Errorf("lock active device binding: %w", err)
}
if bindingID != input.ExpectedBindingID {
return ErrDeviceBindingStale
}
impact, err := s.readDeviceBindingImpact(ctx, tx, principal, access, device, input.Action, input.TargetSubjectID)
if err != nil {
return err
}
if !impact.Allowed {
return ErrDeviceBindingBlocked
}
now := time.Now().UTC()
if _, err := tx.Exec(ctx, `UPDATE device_binding SET unbound_at=$1,unbound_by=$2::uuid,reason=$3 WHERE public_id=$4::uuid AND unbound_at IS NULL`, now, access.userID, input.Action+":"+strings.TrimSpace(input.Reason), bindingID); err != nil {
return fmt.Errorf("close active device binding: %w", err)
}
result = DeviceBindingChangeResult{DeviceID: devicePublicID, Action: input.Action, PreviousBindingID: bindingID, ChangedAt: now}
if input.Action == DeviceBindingActionTransfer {
targetAccess, _, err := resolveSubject(ctx, tx, principal, input.TargetSubjectID, identity.CapabilityDeviceManage, true)
if err != nil || targetAccess.tenantID != access.tenantID {
return ErrAccessDenied
}
var newBindingID string
if err := tx.QueryRow(ctx, `
INSERT INTO device_binding (tenant_id,device_id,household_id,subject_id,bound_by,reason,bound_at)
VALUES ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6,$7)
RETURNING public_id::text`, access.tenantID, access.deviceID, targetAccess.householdID, targetAccess.subjectID, access.userID, "transfer:"+strings.TrimSpace(input.Reason), now).Scan(&newBindingID); err != nil {
return fmt.Errorf("create transferred device binding: %w", err)
}
result.BindingID = &newBindingID
result.HouseholdID = &targetAccess.householdPublicID
result.SubjectID = &input.TargetSubjectID
2026-09-06 17:04:11 +08:00
} else {
var activationState string
if err := tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, access.deviceID, access.tenantID).Scan(&activationState); err != nil {
return fmt.Errorf("read unbound device activation: %w", err)
}
if activationState == string(deviceactivation.Bound) || activationState == string(deviceactivation.Provisioning) ||
activationState == string(deviceactivation.Active) || activationState == string(deviceactivation.Lost) {
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: access.deviceID, TenantID: access.tenantID,
Target: deviceactivation.Claimable, Reason: "用户解除设备绑定", Source: "user-unbind", ActorType: "user",
ActorPublicID: &access.userPublicID, RequestID: requestID}); err != nil {
return fmt.Errorf("transition unbound device activation: %w", err)
}
}
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_provider_credential SET status='SUSPENDED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`, access.deviceID); err != nil {
return fmt.Errorf("suspend unbound device provider credential: %w", err)
}
2026-08-29 21:56:51 +08:00
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "device.binding." + input.Action, ResourceType: "device", ResourceID: strPtr(devicePublicID),
RequestID: requestID, Result: "success", Detail: map[string]any{
"previous_binding_id": bindingID, "new_binding_id": result.BindingID, "target_household_id": result.HouseholdID,
"target_subject_id": result.SubjectID, "reason": strings.TrimSpace(input.Reason),
},
})
})
return result, err
}
func (s *Service) readDeviceBindingImpact(ctx context.Context, q queryRow, principal identity.Principal, access deviceAccess, device ManagedDevice, action, targetSubjectPublicID string) (DeviceBindingImpact, error) {
if action != DeviceBindingActionUnbind && action != DeviceBindingActionTransfer {
return DeviceBindingImpact{}, ErrNotFound
}
if device.AccountBindingID != "" && action != DeviceBindingActionUnbind {
return DeviceBindingImpact{}, ErrNotFound
}
2026-08-29 21:56:51 +08:00
impact := DeviceBindingImpact{
DeviceID: device.ID, BindingID: device.BindingID, Action: action,
SourceHouseholdID: device.HouseholdID, SourceSubjectID: device.SubjectID, SourceSubjectName: device.SubjectName,
Blockers: make([]string, 0), EvaluatedAt: time.Now().UTC(),
}
if action == DeviceBindingActionTransfer {
if err := q.QueryRow(ctx, `SELECT enrollment_mode='physical_confirm' FROM device WHERE id=$1::uuid`, access.deviceID).Scan(&impact.RequiresPhysicalConfirmation); err != nil {
return DeviceBindingImpact{}, err
}
2026-08-29 21:56:51 +08:00
if targetSubjectPublicID == "" || targetSubjectPublicID == device.SubjectID {
impact.Blockers = append(impact.Blockers, "target_subject_required")
} else {
targetAccess, target, err := resolveSubject(ctx, q, principal, targetSubjectPublicID, identity.CapabilityDeviceManage, false)
if err != nil || targetAccess.tenantID != access.tenantID {
return DeviceBindingImpact{}, ErrAccessDenied
}
impact.TargetHouseholdID = &target.HouseholdID
impact.TargetSubjectID = &target.ID
impact.TargetSubjectName = &target.PreferredName
}
}
var activeConversation string
err := q.QueryRow(ctx, `SELECT public_id::text FROM conversation WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED') ORDER BY created_at DESC LIMIT 1`, access.deviceID, access.tenantID).Scan(&activeConversation)
if err == nil {
impact.ActiveConversationID = &activeConversation
impact.Blockers = append(impact.Blockers, "active_conversation")
} else if !errors.Is(err, pgx.ErrNoRows) {
return DeviceBindingImpact{}, fmt.Errorf("check active device conversation: %w", err)
}
var activeCall bool
if err := q.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM call_session WHERE device_id=$1::uuid AND call_state IN ('PREPARING','STARTING','TALKING','STOPPING'))`, access.deviceID).Scan(&activeCall); err != nil {
return DeviceBindingImpact{}, err
}
if activeCall {
impact.Blockers = append(impact.Blockers, "active_call")
}
2026-08-29 21:56:51 +08:00
if err := q.QueryRow(ctx, `SELECT count(*) FROM device_command WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now()`, access.deviceID, access.tenantID).Scan(&impact.PendingCommandCount); err != nil {
return DeviceBindingImpact{}, fmt.Errorf("count pending device commands: %w", err)
}
if impact.PendingCommandCount > 0 {
impact.Blockers = append(impact.Blockers, "pending_device_commands")
}
if err := q.QueryRow(ctx, `SELECT count(*) FROM firmware_assignment WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('PENDING','COMMAND_QUEUED','DOWNLOADING','INSTALLING')`, access.deviceID, access.tenantID).Scan(&impact.ActiveFirmwareTaskCount); err != nil {
return DeviceBindingImpact{}, fmt.Errorf("count active firmware assignments: %w", err)
}
if impact.ActiveFirmwareTaskCount > 0 {
impact.Blockers = append(impact.Blockers, "active_firmware_task")
}
if s.Fleet != nil {
var active int
if err := q.QueryRow(ctx, `SELECT count(*) FROM fleet_ota WHERE device_id=$1 AND status IN ('pending','accepted')`, access.deviceID).Scan(&active); err != nil {
return DeviceBindingImpact{}, err
}
impact.ActiveFirmwareTaskCount += active
if active > 0 {
impact.Blockers = append(impact.Blockers, "active_mqtt_ota")
}
}
2026-08-29 21:56:51 +08:00
impact.Allowed = len(impact.Blockers) == 0
return impact, nil
}