Привет, Хабр! Меня зовут Екатерина Саяпина, я 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/
Добавить комментарий