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

4751a0f5174f9ff2ea5f9d1c7128e0ca.png

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

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

© Habrahabr.ru