Files
story/internal/saas/device_binding_service.go
T
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

218 lines
11 KiB
Go

package saas
import (
"context"
"errors"
"fmt"
"strings"
"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"
)
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"`
}
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
}
var bindingID string
if input.Action == DeviceBindingActionTransfer {
if err = devicebinding.RequireLegacy(ctx, tx, access.deviceID); err != nil {
return err
}
}
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
} 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)
}
}
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
}
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
}
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")
}
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")
}
}
impact.Allowed = len(impact.Blockers) == 0
return impact, nil
}