Как построить свою систему SMS-голосования

от автора

Привет, Хабр! Недавно мне пришла задача: провести голосование среди пользователей, но без сложных и дорогостоящих решений. Когда я пришёл к выбору системы SMS-голосования, осознал, что многие решения на рынке либо слишком сложны для интеграции, либо слишком дороги для решения простых задач.

Я хотел создать что-то, что могло бы работать везде, где есть мобильная сеть. Вооружившись Golang, подключив Exolve SMS API и настроив Supabase, я приступил к работе.

Для начала нужно установить сам Go и подключить следующие библиотеки:

  • Supabase: прекрасный инструмент для работы с базами данных, который отлично интегрируется с Golang через GORM. В нашем случае он будет хранить данные о голосах.

  • Exolve SMS API: основной инструмент для работы с SMS. Ознакомиться с API можно здесь.

  • Gin: легковесный и быстрый фреймворк для создания веб-приложений. 

  • GORM: одна из лучших ORM для Golang, позволяющая легко работать с базами данных. 

Инициализация проекта

Создадим новый проект и инициализируем его с помощью команды go mod init:

$ mkdir sms-voting $ cd sms-voting $ go mod init sms-voting

Для хранения данных о голосах будем использовать Supabase.

Установим необходимые библиотеки:

$ go get -u github.com/gin-gonic/gin $ go get -u gorm.io/gorm $ go get -u gorm.io/driver/postgres

Регистрируемся на самом Supabase, создадим новый проект и добавим таблицу для голосов:

CREATE TABLE votes (   id SERIAL PRIMARY KEY,   candidate_name VARCHAR(255) NOT NULL,   vote_count INT DEFAULT 0,   created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP );

Это позволит хранить информацию о каждом голосе и отслеживать количество голосов за каждого кандидата.

Структура проекта

Структура будет выглядеть следующим образом:

sms-voting/ ├── config/ │   └── config.go ├── db/ │   └── db.go ├── sms/ │   └── sms.go ├── handlers/ │   └── sms_handler.go ├── models/ │   └── vote.go ├── main.go └── go.mod

Конфигурация проекта

Создадим файл config/config.go для хранения конфигурационных параметров:

// config/config.go package config  import (     "log"     "os" )  type Config struct {     DBHost        string     DBUser        string     DBPassword    string     DBName        string     ExolveAPIKey  string     SenderNumber  string     ServerPort    string }  func LoadConfig() *Config {     config := &Config{         DBHost:       getEnv("DB_HOST", "localhost"),         DBUser:       getEnv("DB_USER", "postgres"),         DBPassword:   getEnv("DB_PASSWORD", "password"),         DBName:       getEnv("DB_NAME", "votes_db"),         ExolveAPIKey: getEnv("EXOLVE_API_KEY", ""),         SenderNumber: getEnv("SENDER_NUMBER", ""),         ServerPort:   getEnv("SERVER_PORT", "8080"),     }      if config.ExolveAPIKey == "" || config.SenderNumber == "" {         log.Fatal("EXOLVE_API_KEY and SENDER_NUMBER must be set")     }      return config }  func getEnv(key, fallback string) string {     if value, exists := os.LookupEnv(key); exists {         return value     }     return fallback }

Не забываем установить переменные окружения перед запуском приложения:

export DB_HOST=your_db_host export DB_USER=your_db_user export DB_PASSWORD=your_db_password export DB_NAME=your_db_name export EXOLVE_API_KEY=your_exolve_api_key export SENDER_NUMBER=your_sender_number export SERVER_PORT=8080

Модель данных

Создадим файл models/vote.go для описания модели голосования:

// models/vote.go package models  import (     "time"      "gorm.io/gorm" )  type Vote struct {     ID            uint      `gorm:"primaryKey" json:"id"`     CandidateName string    `gorm:"not null" json:"candidate_name"`     VoteCount     int       `gorm:"default:0" json:"vote_count"`     CreatedAt     time.Time `gorm:"autoCreateTime" json:"created_at"` }

Работа с БД

Создадим файл db/db.go для настройки подключения к базе данных:

// db/db.go package db  import (     "fmt"     "log"      "gorm.io/driver/postgres"     "gorm.io/gorm"      "sms-voting/config"     "sms-voting/models" )  type Database struct {     Conn *gorm.DB }  func NewDatabase(cfg *config.Config) *Database {     dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=5432 sslmode=require",         cfg.DBHost,         cfg.DBUser,         cfg.DBPassword,         cfg.DBName)      db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})     if err != nil {         log.Fatal("failed to connect database:", err)     }      // Миграция схемы     if err := db.AutoMigrate(&models.Vote{}); err != nil {         log.Fatal("failed to migrate database:", err)     }      return &Database{Conn: db} }

Интерфейсы

Для того, чтобы все было гибко и удобно, при том же тестировании, введем интерфейсы для работы с базой данных и отправки SMS.

Интерфейс для базы данных

Создадим файл models/vote_repository.go:

// models/vote_repository.go package models  import (     "errors"      "gorm.io/gorm" )  type VoteRepository interface {     GetOrCreateVote(candidateName string) (*Vote, error)     IncrementVote(vote *Vote) error     GetAllVotes() ([]Vote, error) }  type voteRepository struct {     db *gorm.DB }  func NewVoteRepository(db *gorm.DB) VoteRepository {     return &voteRepository{db} }  func (r *voteRepository) GetOrCreateVote(candidateName string) (*Vote, error) {     var vote Vote     if err := r.db.Where("candidate_name = ?", candidateName).First(&vote).Error; err != nil {         if errors.Is(err, gorm.ErrRecordNotFound) {             vote = Vote{CandidateName: candidateName}             if err := r.db.Create(&vote).Error; err != nil {                 return nil, err             }         } else {             return nil, err         }     }     return &vote, nil }  func (r *voteRepository) IncrementVote(vote *Vote) error {     vote.VoteCount += 1     return r.db.Save(vote).Error }  func (r *voteRepository) GetAllVotes() ([]Vote, error) {     var votes []Vote     if err := r.db.Find(&votes).Error; err != nil {         return nil, err     }     return votes, nil }

Интерфейс для отправки SMS

// sms/sms_service.go package sms  import (     "bytes"     "encoding/json"     "fmt"     "io/ioutil"     "net/http" )  type SMSService interface {     SendSMS(to string, message string) error }  type exolveSMSService struct {     apiKey       string     senderNumber string     apiURL       string }  func NewExolveSMSService(apiKey, senderNumber string) SMSService {     return &exolveSMSService{         apiKey:       apiKey,         senderNumber: senderNumber,         apiURL:       "https://api.exolve.ru/sms/send",     } }  type SMSSendRequest struct {     To      string `json:"to"`     From    string `json:"from"`     Message string `json:"message"` }  func (s *exolveSMSService) SendSMS(to string, message string) error {     smsSendReq := SMSSendRequest{         To:      to,         From:    s.senderNumber,         Message: message,     }      body, err := json.Marshal(smsSendReq)     if err != nil {         return fmt.Errorf("error marshalling SMS request: %w", err)     }      req, err := http.NewRequest("POST", s.apiURL, bytes.NewBuffer(body))     if err != nil {         return fmt.Errorf("error creating SMS request: %w", err)     }     req.Header.Set("Authorization", "Bearer "+s.apiKey)     req.Header.Set("Content-Type", "application/json")      client := &http.Client{}     resp, err := client.Do(req)     if err != nil {         return fmt.Errorf("error sending SMS: %w", err)     }     defer resp.Body.Close()      if resp.StatusCode != http.StatusOK {         respBody, _ := ioutil.ReadAll(resp.Body)         return fmt.Errorf("failed to send SMS, status code: %d, response: %s", resp.StatusCode, string(respBody))     }      return nil }

Обработчики HTTP-запросов

Создадим файл handlers/sms_handler.go для обработки входящих SMS:

// handlers/sms_handler.go package handlers  import (     "fmt"     "log"     "net/http"      "github.com/gin-gonic/gin"      "sms-voting/models"     "sms-voting/sms" )  type SMSRequest struct {     From string `json:"from"`     Body string `json:"body"` }  type SMSHandler struct {     VoteRepo   models.VoteRepository     SMSService sms.SMSService }  func NewSMSHandler(voteRepo models.VoteRepository, smsService sms.SMSService) *SMSHandler {     return &SMSHandler{         VoteRepo:   voteRepo,         SMSService: smsService,     } }  func (h *SMSHandler) HandleSMS(c *gin.Context) {     var smsReq SMSRequest     if err := c.ShouldBindJSON(&smsReq); err != nil {         c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})         return     }      candidateName := smsReq.Body     vote, err := h.VoteRepo.GetOrCreateVote(candidateName)     if err != nil {         log.Printf("Error fetching/creating vote for candidate %s: %v", candidateName, err)         c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})         return     }      if err := h.VoteRepo.IncrementVote(vote); err != nil {         log.Printf("Error incrementing vote for candidate %s: %v", candidateName, err)         c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})         return     }      responseMessage := fmt.Sprintf("Ваш голос за %s принят. Спасибо за участие!", vote.CandidateName)     if err := h.SMSService.SendSMS(smsReq.From, responseMessage); err != nil {         log.Printf("Error sending SMS to %s: %v", smsReq.From, err)         // Можно решить, возвращать ли ошибку пользователю или нет     }      c.JSON(http.StatusOK, gin.H{"message": responseMessage}) }

Основной файл приложения

Создадим файл main.go, который будет запускать сервер:

// main.go package main  import (     "log"      "github.com/gin-gonic/gin"      "sms-voting/config"     "sms-voting/db"     "sms-voting/handlers"     "sms-voting/models"     "sms-voting/sms" )  func main() {     // Загрузка конфигурации     cfg := config.LoadConfig()      // Инициализация базы данных     database := db.NewDatabase(cfg)     voteRepo := models.NewVoteRepository(database.Conn)      // Инициализация SMS-сервиса     smsService := sms.NewExolveSMSService(cfg.ExolveAPIKey, cfg.SenderNumber)      // Инициализация обработчиков     smsHandler := handlers.NewSMSHandler(voteRepo, smsService)      // Настройка роутера Gin     router := gin.Default()     router.POST("/sms", smsHandler.HandleSMS)      // Запуск сервера     log.Printf("Сервер запущен на порту %s", cfg.ServerPort)     if err := router.Run(":" + cfg.ServerPort); err != nil {         log.Fatalf("Не удалось запустить сервер: %v", err)     } }

Теперь можно запустить сервер:

$ go run main.go

Пример тела запроса для тестирования:

{     "from": "+79678880033",     "body": "Кандидат" }

После обработки запроса система увеличит счетчик голосов за указанного кандидата и отправит подтверждающее SMS пользователю.

Что дальше

В эту систему можно добавить дополнительные функции: отчеты, управление кампаниями, интеграцию с другими сервисами или то же масштабирование. Exolve SMS API также имеет множество возможностей для улучшения сценариев.

Делитесь своими идеями и улучшениями в комментариях. До новых встреч!


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