package store import ( "context" "database/sql" "encoding/json" "errors" "fmt" ) // Create вставляет новое устройство. Конфликт (namespace, name) или // (namespace, device_id) возвращает ErrAlreadyExists. func (s *DeviceStore) Create(ctx context.Context, d *Device) error { metadata, err := json.Marshal(d.Metadata) if err != nil { return fmt.Errorf("store: marshal metadata: %w", err) } _, err = s.db.ExecContext(ctx, ` INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata) VALUES ($1, $2, $3, $4, $5, $6)`, d.Namespace, d.Name, d.DeviceID, d.Enabled, d.MQTTPassword, metadata, ) if err != nil { if isUniqueViolation(err) { return ErrAlreadyExists } return fmt.Errorf("store: insert device: %w", err) } return nil } // List возвращает устройства namespace (без сортировки по паролям — пароли на месте, но API их не отдаёт). func (s *DeviceStore) List(ctx context.Context, namespace string) ([]Device, error) { rows, err := s.db.QueryContext(ctx, ` SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase, last_connected, created_at FROM iot_devices WHERE namespace = $1 ORDER BY name`, namespace) if err != nil { return nil, fmt.Errorf("store: list devices: %w", err) } defer rows.Close() return scanDevices(rows) } // GetByName возвращает устройство по (namespace, name). func (s *DeviceStore) GetByName(ctx context.Context, namespace, name string) (*Device, error) { return s.get(ctx, `SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase, last_connected, created_at FROM iot_devices WHERE namespace = $1 AND name = $2`, namespace, name) } // GetByDeviceID возвращает устройство по (namespace, device_id) — для MQTT auth. func (s *DeviceStore) GetByDeviceID(ctx context.Context, namespace, deviceID string) (*Device, error) { return s.get(ctx, `SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase, last_connected, created_at FROM iot_devices WHERE namespace = $1 AND device_id = $2`, namespace, deviceID) } // get — общая выборка одного устройства. func (s *DeviceStore) get(ctx context.Context, query string, args ...any) (*Device, error) { d, err := scanDevice(s.db.QueryRowContext(ctx, query, args...)) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("store: get device: %w", err) } return d, nil } // UpdateEnabled включает/выключает устройство. func (s *DeviceStore) UpdateEnabled(ctx context.Context, namespace, name string, enabled bool) error { res, err := s.db.ExecContext(ctx, `UPDATE iot_devices SET enabled = $3, phase = $4 WHERE namespace = $1 AND name = $2`, namespace, name, enabled, phaseOf(enabled)) if err != nil { return fmt.Errorf("store: update device: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // Delete удаляет устройство. func (s *DeviceStore) Delete(ctx context.Context, namespace, name string) error { res, err := s.db.ExecContext(ctx, `DELETE FROM iot_devices WHERE namespace = $1 AND name = $2`, namespace, name) if err != nil { return fmt.Errorf("store: delete device: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // TouchLastConnected обновляет last_connected (best-effort при MQTT auth). func (s *DeviceStore) TouchLastConnected(ctx context.Context, namespace, deviceID string) { _, _ = s.db.ExecContext(ctx, ` UPDATE iot_devices SET last_connected = now() WHERE namespace = $1 AND device_id = $2`, namespace, deviceID) } // Count возвращает количество устройств в namespace (для админ-статистики). func (s *DeviceStore) Count(ctx context.Context, namespace string) (int64, error) { var n int64 err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM iot_devices WHERE namespace = $1`, namespace).Scan(&n) if err != nil { return 0, fmt.Errorf("store: count devices: %w", err) } return n, nil } // scanDevices читает все строки результата. func scanDevices(rows *sql.Rows) ([]Device, error) { var out []Device for rows.Next() { d, err := scanDevice(rows) if err != nil { return nil, err } out = append(out, *d) } return out, rows.Err() } // scanDevice читает одну строку устройства. type rowScanner interface { Scan(dest ...any) error } func scanDevice(r rowScanner) (*Device, error) { var ( d Device metadata []byte lastConn sql.NullTime ) err := r.Scan(&d.Namespace, &d.Name, &d.DeviceID, &d.Enabled, &d.MQTTPassword, &metadata, &d.Phase, &lastConn, &d.CreatedAt) if err != nil { return nil, err } if len(metadata) > 0 { _ = json.Unmarshal(metadata, &d.Metadata) } if lastConn.Valid { t := lastConn.Time d.LastConnected = &t } return &d, nil } // phaseOf — статус устройства в зависимости от enabled. func phaseOf(enabled bool) string { if enabled { return "Active" } return "Disabled" } // isUniqueViolation определяет нарушение уникальности PostgreSQL. func isUniqueViolation(err error) bool { return err != nil && err.Error() != "" && hasSQLState(err, "23505") } func hasSQLState(err error, state string) bool { type stater interface{ SQLState() string } var e stater return errors.As(err, &e) && e.SQLState() == state }