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

от автора

Привет, Хабр! Меня зовут Екатерина Саяпина, я 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 или масштабированию сервиса, пишите в комментариях — обязательно отвечу.


ссылка на оригинал статьи https://habr.com/ru/articles/856232/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *