Files
story/internal/saas/account_device_service.go
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

122 lines
6.7 KiB
Go

package saas
import (
"context"
"errors"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
const accountDeviceSelect = `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,d.id::text,
d.public_id::text,ab.id::text,d.model,d.hardware_revision,d.status,d.minimum_security_version,d.minimum_firmware_version,
dt.firmware_version,dt.desired_revision,dt.reported_revision,dt.last_reported_at,ab.bound_at,d.registered_at,d.version
FROM device_account_binding ab JOIN device d ON d.id=ab.device_id AND d.tenant_id=ab.tenant_id
JOIN tenant t ON t.id=d.tenant_id AND t.status='active'
JOIN user_account ua ON ua.id=ab.user_id AND ua.tenant_id=ab.tenant_id AND ua.status='active' AND ua.account_kind='customer'
JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
WHERE ab.released_at IS NULL AND ab.generation=d.binding_generation AND ua.oidc_issuer=$1 AND ua.oidc_subject=$2
AND d.status='registered' AND d.activation_state NOT IN ('REVOKED','LOST')
AND NOT EXISTS(SELECT 1 FROM device_binding b WHERE b.device_id=d.id AND b.unbound_at IS NULL)`
func scanAccountDevice(row interface{ Scan(...any) error }) (deviceAccess, ManagedDevice, error) {
var a deviceAccess
var d ManagedDevice
err := row.Scan(&a.tenantID, &a.tenantPublicID, &a.userID, &a.userPublicID, &a.deviceID, &d.ID, &d.AccountBindingID, &d.Model, &d.HardwareRevision, &d.Status, &d.MinimumSecurityVersion, &d.MinimumFirmwareVersion, &d.FirmwareVersion, &d.DesiredRevision, &d.ReportedRevision, &d.LastReportedAt, &d.BoundAt, &d.RegisteredAt, &d.Version)
a.role = identity.Role("owner")
a.desiredRevision = d.DesiredRevision
d.BindingID = d.AccountBindingID
return a, d, err
}
func (s *Service) listAccountDevices(ctx context.Context, p identity.Principal) ([]ManagedDevice, error) {
rows, err := s.pool.Query(ctx, accountDeviceSelect+` ORDER BY ab.bound_at DESC,d.public_id`, p.Issuer, p.Subject)
if err != nil {
return nil, err
}
defer rows.Close()
result := []ManagedDevice{}
for rows.Next() {
_, d, e := scanAccountDevice(rows)
if e != nil {
return nil, e
}
result = append(result, d)
}
return result, rows.Err()
}
func resolveAccountDevice(ctx context.Context, q queryRow, p identity.Principal, public string, capability identity.Capability, lock bool) (deviceAccess, ManagedDevice, error) {
// Only device capabilities may be granted without a household.
if capability != identity.CapabilityHouseholdRead && capability != identity.CapabilityDeviceManage && capability != identity.CapabilityFirmwareManage {
return deviceAccess{}, ManagedDevice{}, ErrAccessDenied
}
sql := accountDeviceSelect + ` AND d.public_id=$3::uuid`
if lock {
sql += ` FOR UPDATE OF d,dt`
}
a, d, err := scanAccountDevice(q.QueryRow(ctx, sql, p.Issuer, p.Subject, public))
if errors.Is(err, pgx.ErrNoRows) {
err = ErrAccessDenied
}
return a, d, err
}
func (s *Service) releaseAccountDevice(ctx context.Context, tx pgx.Tx, p identity.Principal, a deviceAccess, d ManagedDevice, requestID string, in ChangeDeviceBindingInput) (DeviceBindingChangeResult, error) {
if in.Action != DeviceBindingActionUnbind || in.ExpectedBindingID != d.AccountBindingID {
return DeviceBindingChangeResult{}, ErrDeviceBindingStale
}
impact, err := s.readDeviceBindingImpact(ctx, tx, p, a, d, in.Action, "")
if err != nil {
return DeviceBindingChangeResult{}, err
}
if !impact.Allowed {
return DeviceBindingChangeResult{}, ErrDeviceBindingBlocked
}
var lifecycle string
if err = tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid FOR UPDATE`, a.deviceID).Scan(&lifecycle); err != nil {
return DeviceBindingChangeResult{}, err
}
now := time.Now().UTC()
if _, err = tx.Exec(ctx, `UPDATE device_account_binding SET released_at=$2 WHERE id=$1::uuid AND released_at IS NULL`, d.AccountBindingID, now); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE device SET binding_generation=binding_generation+1,onboarding_revision=onboarding_revision+1 WHERE id=$1::uuid`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE device_activation_job SET config_ack_at=NULL,engine_ready_at=NULL,config_ciphertext=NULL,config_digest=NULL,status=CASE WHEN status IN ('CONTACT_POSSIBLE','RECONCILE_REQUIRED') THEN 'RECONCILE_REQUIRED' ELSE 'BLOCKED' END,last_error_code='BINDING_CHANGED',version=version+1,updated_at=now() WHERE device_id=$1::uuid`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, 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'`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='BINDING_CHANGED' WHERE device_id=$1::uuid`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE onboarding_web_session SET revoked_at=COALESCE(revoked_at,now()) WHERE setup_id IN (SELECT id FROM device_setup_session WHERE device_id=$1::uuid)`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
transition := deviceactivation.Input{DeviceID: a.deviceID, TenantID: a.tenantID, Target: deviceactivation.Claimable, Reason: "用户移除账号设备", Source: "onboarding", ActorType: "user", ActorPublicID: &a.userPublicID, RequestID: requestID}
if lifecycle == "PROVISIONING" {
transition.Target = deviceactivation.Bound
if _, err = deviceactivation.Apply(ctx, tx, transition); err != nil {
return DeviceBindingChangeResult{}, err
}
transition.Target = deviceactivation.Claimable
}
if _, err = deviceactivation.Apply(ctx, tx, transition); err != nil {
return DeviceBindingChangeResult{}, err
}
err = s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "device.account.unbind", ResourceType: "device", ResourceID: &d.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"account_binding_id": d.AccountBindingID}})
return DeviceBindingChangeResult{DeviceID: d.ID, Action: in.Action, PreviousBindingID: d.AccountBindingID, ChangedAt: now}, err
}