golang

Делаем форму обратного звонка: лендинг, Go и SMS-уведомления

  • среда, 6 ноября 2024 г. в 00:00:07
https://habr.com/ru/companies/ru_mts/articles/856232/

Привет, Хабр! Меня зовут Екатерина Саяпина, я Product Owner личного кабинета платформы МТС Exolve. Сегодня расскажу, как создать простую, но эффективную форму обратного звонка с SMS-уведомлениями. Дам пример для сценария, когда клиент оставляет заявку через форму, а менеджер связывается с ним через Callback API. После успешного разговора система автоматически отправляет SMS через SMS API с подтверждением договоренностей и следующими шагами.

SMS-уведомления здесь играют роль надежного канала для закрепления результатов разговора и напоминания о договоренностях. Они не требуют интернета или установки приложений и работают везде, даже при слабом сигнале связи.

Причем пример будет без громоздких фреймворков — только Go и чистый HTML с щепоткой JavaScript.

Зачем это нужно в 2024

Неожиданно, но формы обратного звонка все еще актуальны. И дело не в технологиях, а в людях. Клиенту проще оставить номер на сайте, чем звонить самому или искать контакты в мессенджерах.

Более того, SMS-уведомления остаются самым надежным способом оповещения. Не нужен интернет. Не требуется установка приложений. Работает везде, даже там, где связь еле дышит.

Что в итоге получим

Сделаем два компонента:

  • минималистичный лендинг с формой заказа звонка;

  • сервер на Go для обработки запросов и отправки SMS через API Exolve.

Звучит просто, но дьявол, как всегда, кроется в деталях.

Начинаем с фронтенда

Создадим простой, но современный лендинг. Никаких тяжелых фреймворков — только HTML5, CSS и чистый JavaScript.

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Заказать звонок</title>
    <style>
        .callback-form {
            display: none;
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 0 10px rgba(0,0,0,0.1);
        }

        .callback-form.active {
            display: block;
        }

        .overlay {
            display: none;
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0,0,0,0.5);
        }

        .overlay.active {
            display: block;
        }
    </style>
</head>
<body>
    <button onclick="showForm()">Заказать звонок</button>

    <div class="overlay" id="overlay"></div>
    <div class="callback-form" id="callbackForm">
        <h2>Заказать обратный звонок</h2>
        <form id="phoneForm">
            <input type="text" id="name" placeholder="Ваше имя" required>
            <input type="tel" id="phone" placeholder="+7 (___) ___-__-__" required>
            <label>
                <input type="checkbox" required>
                Согласен с политикой конфиденциальности
            </label>
            <button type="submit">Отправить</button>
        </form>
    </div>

    <script>
        function showForm() {
            document.getElementById('overlay').classList.add('active');
            document.getElementById('callbackForm').classList.add('active');
        }

        document.getElementById('phoneForm').addEventListener('submit', async (e) => {
            e.preventDefault();
            
            const formData = {
                name: document.getElementById('name').value,
                phone: document.getElementById('phone').value
            };

            try {
                const response = await fetch('/api/callback', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify(formData)
                });

                if (response.ok) {
                    alert('Спасибо! Мы перезвоним вам в ближайшее время.');
                } else {
                    alert('Произошла ошибка. Попробуйте позже.');
                }
            } catch (error) {
                console.error('Error:', error);
                alert('Произошла ошибка. Попробуйте позже.');
            }
        });
    </script>
</body>

Стандартный HTML с JavaScript может многое. Position: fixed с transform — древний как мир способ центрирования модального окна, работающий везде, от кнопочных Nokia до последних айфонов. В этом примере я не использую Bootstrap или Material UI, потому что можно сделать просто и надежно. Асинхронная отправка через fetch избавляет от перезагрузки страницы.

Пишем сервер на Go

Теперь займемся серверной частью. Нам понадобится:

  • обработка входящих запросов;

  • валидация данных;

  • интеграция с API Exolve;

  • защита от спама.

Начнем с основной структуры проекта:

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "os"
    "time"
)

type CallbackRequest struct {
    Name  string `json:"name"`
    Phone string `json:"phone"`
}

type ExolveConfig struct {
    ApiKey string
    From   string
    To     string
}

var (
    config ExolveConfig
    client *http.Client
)

func init() {
    // Загружаем конфигурацию
    config = ExolveConfig{
        ApiKey: os.Getenv("EXOLVE_API_KEY"),
        From:   os.Getenv("SMS_FROM"),
        To:     os.Getenv("SMS_TO"),
    }

    // Инициализируем HTTP-клиент
    client = &http.Client{
        Timeout: time.Second * 10,
    }
}
type ExolveResponse struct {
    CallID string `json:"call_id"`
}

// Создаем структуру для отправки SMS через Exolve API
func sendSMS(phone string, name string) error {
    smsBody := struct {
        Number      string `json:"number"`      // Отправитель 
        Destination string `json:"destination"` // Получатель
        Text        string `json:"text"`        // Текст сообщения
    }{
        Number:      config.From,
        Destination: phone,  
        Text:        fmt.Sprintf("Здравствуйте, %s! Мы получили ваш запрос на обратный звонок и свяжемся с вами в ближайшее время.", name),
    }

    jsonData, err := json.Marshal(smsBody)
    if err != nil {
        return fmt.Errorf("ошибка при формировании SMS: %v", err)
    }

    req, err := http.NewRequest("POST", "https://api.exolve.ru/messaging/v1/SendSMS", bytes.NewBuffer(jsonData))
    if err != nil {
        return fmt.Errorf("ошибка при создании запроса: %v", err)
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.ApiKey))

    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("ошибка при отправке SMS: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("неожиданный статус ответа: %d", resp.StatusCode)
    }

    return nil
}

// Обработчик для API обратного звонка
func handleCallback(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Метод не поддерживается", http.StatusMethodNotAllowed)
        return
    }

    var request CallbackRequest
    if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
        http.Error(w, "Ошибка при разборе запроса", http.StatusBadRequest)
        return
    }

    // Валидация номера телефона
    if !validatePhone(request.Phone) {
        http.Error(w, "Некорректный номер телефона", http.StatusBadRequest)
        return
    }

    // Проверка на спам через Redis или другое хранилище
    if isSpamRequest(request.Phone) {
        http.Error(w, "Слишком много запросов", http.StatusTooManyRequests)
        return
    }

    // Отправляем SMS
    if err := sendSMS(request.Phone, request.Name); err != nil {
        log.Printf("Ошибка при отправке SMS: %v", err)
        http.Error(w, "Внутренняя ошибка сервера", http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{
        "status": "success",
        "message": "Заявка принята",
    })
}

func main() {
    // Настраиваем CORS
    corsMiddleware := cors.New(cors.Options{
        AllowedOrigins: []string{"*"}, // В продакшне заменить на конкретные домены
        AllowedMethods: []string{"GET", "POST", "OPTIONS"},
        AllowedHeaders: []string{"Content-Type", "Authorization"},
    })

    // Настройка маршрутов
    mux := http.NewServeMux()
    mux.HandleFunc("/api/callback", handleCallback)

    // Оборачиваем наш мультиплексор в CORS middleware
    handler := corsMiddleware.Handler(mux)

    // Запуск сервера
    log.Printf("Сервер запущен на порту :8080")
    if err := http.ListenAndServe(":8080", handler); err != nil {
        log.Fatal(err)
    }
}

Ядро системы — встроенный http-пакет Go. Без лишних фреймворков и зависимостей. Так проще масштабировать и удобнее дебажить. Плюс меньше кода — меньше багов.

CallbackRequest-структура намеренно простая: только имя и телефон. Расширить всегда успеем, а вот выкинуть лишнее потом — та еще головная боль.

ExolveConfig держит настройки API в одном месте. Загружаем из переменных окружения — классика 12 factor app. Хардкодить креды в код в 2024 — моветон, да и DevOps нас не поймет.

Безопасность и валидация

Добавим функции валидации и защиты от спама:

func validatePhone(phone string) bool {
    // Очищаем номер от всего, кроме цифр
    re := regexp.MustCompile(`\D`)
    cleanPhone := re.ReplaceAllString(phone, "")
    
    // Проверяем длину и начало номера
    if len(cleanPhone) != 11 {
        return false
    }
    
    if !strings.HasPrefix(cleanPhone, "7") {
        return false
    }
    
    return true
}

// Простая проверка на спам через in-memory-хранилище
// В реальном проекте лучше использовать Redis
var (
    requestLock    sync.RWMutex
    requestCounter = make(map[string]*rateLimiter)
)

type rateLimiter struct {
    count     int
    firstCall time.Time
}

func isSpamRequest(phone string) bool {
    requestLock.Lock()
    defer requestLock.Unlock()

    now := time.Now()
    limiter, exists := requestCounter[phone]
    
    if !exists {
        requestCounter[phone] = &rateLimiter{
            count:     1,
            firstCall: now,
        }
        return false
    }

    // Сбрасываем счетчик каждый час
    if now.Sub(limiter.firstCall) > time.Hour {
        limiter.count = 1
        limiter.firstCall = now
        return false
    }

    limiter.count++
    // Ограничиваем до 3 запросов в час
    return limiter.count > 3
}

ValidatePhone проверяет номер по длине и первой цифре. Никаких хитрых регулярок — они только усложняют поддержку. К тому же валидация номера на бэкенде — это последний рубеж обороны, основную работу должен делать фронт.

Защита от спама через in-memory-хранилище не идеал, но для начала сойдет. Три запроса в час от одного номера — адекватный лимит. Redis сразу не используем — начинаем с простого, усложняем по необходимости.

Улучшаем наш сервис

Но сначала посмотрим, как сделать обработку звонков через Voice API от Exolve. Это позволит нам не только отправлять SMS, но и автоматически совершать звонки.

type VoiceConfig struct {
    ServiceID string // ID нашего голосового сервиса
    Source    string // Номер, с которого будем звонить
}

func makeCallback(phoneNumber string) error {
    callbackBody := struct {
        Source      string `json:"source"`
        Destination string `json:"destination"`
        ServiceID   string `json:"service_id"`
    }{
        Source:      config.Voice.Source,
        Destination: phoneNumber,
        ServiceID:   config.Voice.ServiceID,
    }

    jsonData, err := json.Marshal(callbackBody)
    if err != nil {
        return fmt.Errorf("ошибка при формировании запроса: %v", err)
    }

    req, err := http.NewRequest("POST", "https://api.exolve.ru/call/v1/MakeCallback", bytes.NewBuffer(jsonData))
    if err != nil {
        return fmt.Errorf("ошибка при создании запроса: %v", err)
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.ApiKey))

    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("ошибка при выполнении запроса: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("неожиданный статус ответа: %d", resp.StatusCode)
    }

    return nil
}

MakeCallback — наша связь с Voice API от Exolve. Структура запроса максимально прозрачная: откуда звоним, куда звоним, какой сервис используем. Никакой самодеятельности — только то, что реально нужно.

Логирование и мониторинг

Важная часть любого сервиса — отслеживание его работы. Добавим структурированное логирование:

type LogEntry struct {
    Time      time.Time `json:"time"`
    Level     string    `json:"level"`
    Phone     string    `json:"phone"`
    Name      string    `json:"name"`
    Status    string    `json:"status"`
    Error     string    `json:"error,omitempty"`
    UserAgent string    `json:"user_agent"`
    IP        string    `json:"ip"`
}

func logRequest(r *http.Request, phone, name, status string, err error) {
    entry := LogEntry{
        Time:      time.Now(),
        Level:     "info",
        Phone:     phone,
        Name:      name,
        Status:    status,
        UserAgent: r.UserAgent(),
        IP:        r.RemoteAddr,
    }

    if err != nil {
        entry.Level = "error"
        entry.Error = err.Error()
    }

    jsonEntry, _ := json.Marshal(entry)
    log.Println(string(jsonEntry))
}

LogEntry-структура — наш швейцарский нож для отладки. Время, уровень, телефон, имя, статус — все, что поможет понять, что пошло не так. UserAgent и IP — бонусом для особо пытливых DevOps.

Добавляем метрики

Prometheus стал стандартом де-факто для мониторинга. Добавим базовые метрики:

var (
    requestsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "callback_requests_total",
            Help: "Общее количество запросов на обратный звонок",
        },
        []string{"status"},
    )

    requestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "callback_request_duration_seconds",
            Help:    "Время обработки запроса",
            Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
        },
        []string{"status"},
    )
)

// Оборачиваем наш обработчик для сбора метрик
func metricsMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // Создаем свой ResponseWriter для отслеживания статуса
        srw := &statusResponseWriter{ResponseWriter: w}
        
        next.ServeHTTP(srw, r)
        
        duration := time.Since(start).Seconds()
        status := fmt.Sprintf("%d", srw.status)
        
        requestsTotal.WithLabelValues(status).Inc()
        requestDuration.WithLabelValues(status).Observe(duration)
    }
}

type statusResponseWriter struct {
    http.ResponseWriter
    status int
}

func (w *statusResponseWriter) WriteHeader(status int) {
    w.status = status
    w.ResponseWriter.WriteHeader(status)
}

RequestsTotal и requestDuration — два основных элемента нашего мониторинга. Первый считает запросы, второй измеряет время. Лишние метрики лучше не вводить: этих двух уже достаточно, чтобы следить за здоровьем сервиса.

Кэширование и оптимизация

Добавим Redis для более надежного контроля спама и кэширования:

type Cache struct {
    client *redis.Client
}

func NewCache(addr string) (*Cache, error) {
    client := redis.NewClient(&redis.Options{
        Addr: addr,
    })

    // Проверяем подключение
    if err := client.Ping().Err(); err != nil {
        return nil, fmt.Errorf("ошибка подключения к Redis: %v", err)
    }

    return &Cache{client: client}, nil
}

func (c *Cache) CheckSpam(phone string) (bool, error) {
    key := fmt.Sprintf("spam:%s", phone)
    
    // Получаем количество запросов
    count, err := c.client.Get(key).Int()
    if err == redis.Nil {
        // Ключа нет, создаем новый
        err = c.client.Set(key, 1, time.Hour).Err()
        return false, err
    }
    if err != nil {
        return false, err
    }

    // Увеличиваем счетчик
    count++
    err = c.client.Set(key, count, time.Hour).Err()
    if err != nil {
        return false, err
    }

    return count > 3, nil
}

Cache-структура оборачивает клиент Redis. Проверка спама теперь надежнее: счетчики живут час и не боятся перезапуска сервера. А для больших нагрузок Redis — самое то: быстрый, надежный, проверенный временем.

Деплой и конфигурация

Для контейнеризации используем Docker:

FROM golang:1.21-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .

FROM alpine:latest

RUN apk --no-cache add ca-certificates
WORKDIR /root/

COPY --from=builder /app/main .
COPY config.yaml .

EXPOSE 8080
CMD ["./main"]

Dockerfile разворачиваем в две стадии: сначала собираем, потом упаковываем. Минимум слоев — минимум проблем. Используем как базовый образ легкий и быстрый Alpine Linux.

И docker-compose для локальной разработки:

version: '3'

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - REDIS_URL=redis:6379
      - EXOLVE_API_KEY=${EXOLVE_API_KEY}
    depends_on:
      - redis

  redis:
    image: redis:alpine
    ports:
      - "6379:6379"

  prometheus:
    image: prom/prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

  grafana:
    image: grafana/grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=secret
    depends_on:
      - prometheus

Docker-compose хорошо подходит для нашей задачи. Он объединяет Redis, Prometheus, Grafana и позволяет избежать проблем с настройкой окружения.

Что у нас получилось в итоге

Мы создали простой, но надежный сервис обратного звонка с защитой от спама, мониторингом и логированием. Благодаря интеграции с Exolve API наше решение может не только отправлять SMS, но и автоматически совершать звонки. Однако все-таки стоит помнить, что это лишь учебный прототип и демонстрация того, как можно использовать Exolve в вашей работе. На его основе вам предстоит уже решить, как вы будете делать решение, которое подойдет конкретно для ваших задач и потребностей.

Если у вас возникли вопросы по интеграции с Exolve API или масштабированию сервиса, пишите в комментариях — обязательно отвечу.