create and moove into new directories for BegushiyBashkir and
yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarba
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func InitDB(dsn string) (*gorm.DB, error) {
|
||||
// Используем PostgreSQL драйвер
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Получаем underlying sql.DB для настройки пула соединений
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get database instance: %w", err)
|
||||
}
|
||||
|
||||
// Настраиваем пул соединений
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetMaxOpenConns(100)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
// Проверяем соединение
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("database ping failed: %w", err)
|
||||
}
|
||||
|
||||
log.Println("PostgreSQL connection established successfully")
|
||||
|
||||
// Auto migrate models
|
||||
err = db.AutoMigrate(
|
||||
&models.User{},
|
||||
// Добавьте другие модели здесь по мере расширения
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to auto-migrate models: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Database migration completed successfully")
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// pkg/email/email.go
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"api_bb/internal/config"
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"github.com/wneessen/go-mail"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Service представляет сервис для отправки email
|
||||
type Service struct {
|
||||
client *mail.Client
|
||||
config *config.Config
|
||||
logger *zap.Logger
|
||||
tmpl *template.Template
|
||||
fromAddr string
|
||||
isActive bool
|
||||
}
|
||||
|
||||
// NewService создает новый экземпляр email сервиса
|
||||
func NewService(cfg *config.Config) (*Service, error) {
|
||||
log := logger.Get()
|
||||
log.Info("Initializing email service")
|
||||
|
||||
// Проверяем обязательные параметры конфигурации
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
log.Warn("Email service configuration is invalid, service will be disabled", zap.Error(err))
|
||||
return &Service{
|
||||
logger: log,
|
||||
isActive: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Создаем SMTP клиент с правильными настройками
|
||||
client, err := createSMTPClient(cfg)
|
||||
if err != nil {
|
||||
log.Warn("Failed to create SMTP client, email service will be disabled", zap.Error(err))
|
||||
return &Service{
|
||||
logger: log,
|
||||
isActive: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Загружаем шаблоны писем
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
log.Warn("Failed to load email templates, email service will be disabled", zap.Error(err))
|
||||
return &Service{
|
||||
logger: log,
|
||||
isActive: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
service := &Service{
|
||||
client: client,
|
||||
config: cfg,
|
||||
logger: log,
|
||||
tmpl: tmpl,
|
||||
fromAddr: cfg.FromEmail,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
log.Info("Email service initialized successfully",
|
||||
zap.String("host", cfg.SMTPHost),
|
||||
zap.Int("port", cfg.SMTPPort),
|
||||
zap.String("from", cfg.FromEmail))
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// validateConfig проверяет корректность конфигурации email
|
||||
func validateConfig(cfg *config.Config) error {
|
||||
if cfg.SMTPHost == "" {
|
||||
return fmt.Errorf("SMTP host is required")
|
||||
}
|
||||
|
||||
if cfg.SMTPPort <= 0 || cfg.SMTPPort > 65535 {
|
||||
return fmt.Errorf("invalid SMTP port: %d", cfg.SMTPPort)
|
||||
}
|
||||
|
||||
if cfg.SMTPUsername == "" {
|
||||
return fmt.Errorf("SMTP username is required")
|
||||
}
|
||||
|
||||
if cfg.SMTPPassword == "" {
|
||||
return fmt.Errorf("SMTP password is required")
|
||||
}
|
||||
|
||||
if cfg.FromEmail == "" {
|
||||
return fmt.Errorf("from email is required")
|
||||
}
|
||||
|
||||
if cfg.FrontendURL == "" {
|
||||
return fmt.Errorf("frontend URL is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSMTPClient создает SMTP клиент с правильными настройками
|
||||
func createSMTPClient(cfg *config.Config) (*mail.Client, error) {
|
||||
opts := []mail.Option{
|
||||
mail.WithPort(cfg.SMTPPort),
|
||||
mail.WithSMTPAuth(mail.SMTPAuthPlain),
|
||||
mail.WithUsername(cfg.SMTPUsername),
|
||||
mail.WithPassword(cfg.SMTPPassword),
|
||||
}
|
||||
|
||||
// Настраиваем TLS в зависимости от порта
|
||||
switch cfg.SMTPPort {
|
||||
case 587:
|
||||
// STARTTLS для порта 587
|
||||
opts = append(opts, mail.WithTLSPolicy(mail.TLSMandatory))
|
||||
case 465:
|
||||
// SSL/TLS для порта 465
|
||||
opts = append(opts, mail.WithSSL())
|
||||
default:
|
||||
// Opportunistic TLS для других портов
|
||||
opts = append(opts, mail.WithTLSPolicy(mail.TLSOpportunistic))
|
||||
}
|
||||
|
||||
return mail.NewClient(cfg.SMTPHost, opts...)
|
||||
}
|
||||
|
||||
// loadTemplates загружает HTML шаблоны для писем
|
||||
func loadTemplates() (*template.Template, error) {
|
||||
tmpl := template.New("email")
|
||||
|
||||
templates := map[string]string{
|
||||
"verification": verificationTemplate,
|
||||
"password_reset": passwordResetTemplate,
|
||||
"newsletter": newsletterTemplate,
|
||||
}
|
||||
|
||||
for name, content := range templates {
|
||||
var err error
|
||||
tmpl, err = tmpl.New(name).Parse(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse template %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// IsActive возвращает статус сервиса
|
||||
func (s *Service) IsActive() bool {
|
||||
return s.isActive
|
||||
}
|
||||
|
||||
// EmailData содержит данные для шаблонов писем
|
||||
type EmailData struct {
|
||||
UserName string
|
||||
AppName string
|
||||
FrontendURL string
|
||||
Token string
|
||||
Subject string
|
||||
Content string
|
||||
Year int
|
||||
}
|
||||
|
||||
// SendVerificationEmail отправляет email для подтверждения адреса
|
||||
func (s *Service) SendVerificationEmail(to, userName, token string) error {
|
||||
if !s.isActive {
|
||||
s.logger.Warn("Email service is disabled, skipping verification email",
|
||||
zap.String("to", to), zap.String("user", userName))
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info("Sending verification email", zap.String("to", to), zap.String("user", userName))
|
||||
|
||||
data := EmailData{
|
||||
UserName: userName,
|
||||
AppName: "Бегущий Башкир",
|
||||
FrontendURL: s.config.FrontendURL,
|
||||
Token: token,
|
||||
Subject: "Подтверждение email",
|
||||
Year: time.Now().Year(),
|
||||
}
|
||||
|
||||
return s.sendEmail(to, "Подтверждение email - Бегущий Башкир", "verification", data)
|
||||
}
|
||||
|
||||
// SendPasswordResetEmail отправляет email для сброса пароля
|
||||
func (s *Service) SendPasswordResetEmail(to, userName, token string) error {
|
||||
if !s.isActive {
|
||||
s.logger.Warn("Email service is disabled, skipping password reset email",
|
||||
zap.String("to", to), zap.String("user", userName))
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info("Sending password reset email", zap.String("to", to), zap.String("user", userName))
|
||||
|
||||
data := EmailData{
|
||||
UserName: userName,
|
||||
AppName: "Бегущий Башкир",
|
||||
FrontendURL: s.config.FrontendURL,
|
||||
Token: token,
|
||||
Subject: "Восстановление пароля",
|
||||
Year: time.Now().Year(),
|
||||
}
|
||||
|
||||
return s.sendEmail(to, "Восстановление пароля - Бегущий Башкир", "password_reset", data)
|
||||
}
|
||||
|
||||
// SendNewsletterEmail отправляет email рассылку
|
||||
func (s *Service) SendNewsletterEmail(to, userName, subject, content string) error {
|
||||
if !s.isActive {
|
||||
s.logger.Warn("Email service is disabled, skipping newsletter",
|
||||
zap.String("to", to), zap.String("user", userName), zap.String("subject", subject))
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info("Sending newsletter email",
|
||||
zap.String("to", to),
|
||||
zap.String("user", userName),
|
||||
zap.String("subject", subject))
|
||||
|
||||
data := EmailData{
|
||||
UserName: userName,
|
||||
AppName: "Бегущий Башкир",
|
||||
FrontendURL: s.config.FrontendURL,
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
Year: time.Now().Year(),
|
||||
}
|
||||
|
||||
// Для новостей используем специальный шаблон
|
||||
var body bytes.Buffer
|
||||
if err := s.tmpl.ExecuteTemplate(&body, "newsletter", data); err != nil {
|
||||
s.logger.Error("Failed to execute newsletter template", zap.Error(err))
|
||||
return fmt.Errorf("failed to execute newsletter template: %w", err)
|
||||
}
|
||||
|
||||
msg := mail.NewMsg()
|
||||
if err := msg.From(s.fromAddr); err != nil {
|
||||
return fmt.Errorf("failed to set from address: %w", err)
|
||||
}
|
||||
if err := msg.To(to); err != nil {
|
||||
return fmt.Errorf("failed to set to address: %w", err)
|
||||
}
|
||||
|
||||
msg.Subject(subject)
|
||||
msg.SetBodyString(mail.TypeTextHTML, body.String())
|
||||
|
||||
if err := s.client.DialAndSend(msg); err != nil {
|
||||
s.logger.Error("Failed to send newsletter email", zap.Error(err))
|
||||
return fmt.Errorf("failed to send newsletter email: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Newsletter email sent successfully", zap.String("to", to))
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendEmail общий метод для отправки email
|
||||
func (s *Service) sendEmail(to, subject, templateName string, data EmailData) error {
|
||||
var body bytes.Buffer
|
||||
if err := s.tmpl.ExecuteTemplate(&body, templateName, data); err != nil {
|
||||
s.logger.Error("Failed to execute email template",
|
||||
zap.String("template", templateName),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to execute template %s: %w", templateName, err)
|
||||
}
|
||||
|
||||
msg := mail.NewMsg()
|
||||
if err := msg.From(s.fromAddr); err != nil {
|
||||
return fmt.Errorf("failed to set from address: %w", err)
|
||||
}
|
||||
if err := msg.To(to); err != nil {
|
||||
return fmt.Errorf("failed to set to address: %w", err)
|
||||
}
|
||||
|
||||
msg.Subject(subject)
|
||||
msg.SetBodyString(mail.TypeTextHTML, body.String())
|
||||
|
||||
if err := s.client.DialAndSend(msg); err != nil {
|
||||
s.logger.Error("Failed to send email",
|
||||
zap.String("type", templateName),
|
||||
zap.String("to", to),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Email sent successfully",
|
||||
zap.String("type", templateName),
|
||||
zap.String("to", to))
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnection тестирует подключение к SMTP серверу
|
||||
func (s *Service) TestConnection() error {
|
||||
if !s.isActive {
|
||||
return fmt.Errorf("email service is disabled")
|
||||
}
|
||||
|
||||
// Создаем тестовое сообщение
|
||||
msg := mail.NewMsg()
|
||||
if err := msg.From(s.fromAddr); err != nil {
|
||||
return fmt.Errorf("failed to set from address: %w", err)
|
||||
}
|
||||
if err := msg.To(s.fromAddr); err != nil {
|
||||
return fmt.Errorf("failed to set to address: %w", err)
|
||||
}
|
||||
|
||||
msg.Subject("Тестовое письмо - Бегущий Башкир")
|
||||
msg.SetBodyString(mail.TypeTextPlain, "Это тестовое письмо для проверки подключения.")
|
||||
|
||||
// Пытаемся отправить тестовое письмо
|
||||
if err := s.client.DialAndSend(msg); err != nil {
|
||||
return fmt.Errorf("failed to send test email: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("SMTP connection test successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Шаблоны писем остаются без изменений...
|
||||
const verificationTemplate = `
|
||||
{{define "verification"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #2e8b57, #3cb371); color: white; padding: 2rem; text-align: center; border-radius: 10px 10px 0 0; }
|
||||
.content { padding: 2rem; background: #f8f9fa; }
|
||||
.footer { padding: 1rem; text-align: center; color: #666; font-size: 0.9rem; }
|
||||
.cta-button { display: inline-block; padding: 12px 24px; background: #2e8b57; color: white; text-decoration: none; border-radius: 5px; margin: 1rem 0; }
|
||||
.token { background: #e9ecef; padding: 10px; border-radius: 5px; font-family: monospace; margin: 1rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🏃 Бегущий Башкир</h1>
|
||||
<p>Подтверждение email адреса</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Привет, {{.UserName}}!</h2>
|
||||
<p>Благодарим за регистрацию в приложении "Бегущий Башкир". Для завершения регистрации подтвердите ваш email адрес.</p>
|
||||
|
||||
<a href="{{.FrontendURL}}/verify-email?token={{.Token}}" class="cta-button">
|
||||
Подтвердить Email
|
||||
</a>
|
||||
|
||||
<p>Или скопируйте эту ссылку в браузер:</p>
|
||||
<div class="token">{{.FrontendURL}}/verify-email?token={{.Token}}</div>
|
||||
|
||||
<p>Ссылка действительна в течение 24 часов.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{.Year}} Бегущий Башкир. Все права защищены.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
`
|
||||
|
||||
const passwordResetTemplate = `
|
||||
{{define "password_reset"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #dc3545, #e35d6a); color: white; padding: 2rem; text-align: center; border-radius: 10px 10px 0 0; }
|
||||
.content { padding: 2rem; background: #f8f9fa; }
|
||||
.footer { padding: 1rem; text-align: center; color: #666; font-size: 0.9rem; }
|
||||
.cta-button { display: inline-block; padding: 12px 24px; background: #dc3545; color: white; text-decoration: none; border-radius: 5px; margin: 1rem 0; }
|
||||
.token { background: #e9ecef; padding: 10px; border-radius: 5px; font-family: monospace; margin: 1rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🏃 Бегущий Башкир</h1>
|
||||
<p>Восстановление пароля</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Привет, {{.UserName}}!</h2>
|
||||
<p>Мы получили запрос на восстановление пароля для вашего аккаунта.</p>
|
||||
|
||||
<a href="{{.FrontendURL}}/reset-password?token={{.Token}}" class="cta-button">
|
||||
Восстановить пароль
|
||||
</a>
|
||||
|
||||
<p>Или скопируйте эту ссылку в браузер:</p>
|
||||
<div class="token">{{.FrontendURL}}/reset-password?token={{.Token}}</div>
|
||||
|
||||
<p>Если вы не запрашивали восстановление пароля, просто проигнорируйте это письмо.</p>
|
||||
<p>Ссылка действительна в течение 1 часа.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{.Year}} Бегущий Башкир. Все права защищены.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
`
|
||||
|
||||
const newsletterTemplate = `
|
||||
{{define "newsletter"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #2e8b57, #3cb371); color: white; padding: 2rem; text-align: center; border-radius: 10px 10px 0 0; }
|
||||
.content { padding: 2rem; background: #f8f9fa; }
|
||||
.footer { padding: 1rem; text-align: center; color: #666; font-size: 0.9rem; }
|
||||
.newsletter-content { line-height: 1.8; }
|
||||
.cta-button { display: inline-block; padding: 12px 24px; background: #2e8b57; color: white; text-decoration: none; border-radius: 5px; margin: 1rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🏃 Бегущий Башкир</h1>
|
||||
<p>Новости и обновления</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Привет, {{.UserName}}!</h2>
|
||||
<div class="newsletter-content">
|
||||
{{.Content}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{.Year}} Бегущий Башкир. Все права защищены.</p>
|
||||
<p><a href="{{.FrontendURL}}/unsubscribe" style="color: #666;">Отписаться от рассылки</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
`
|
||||
@@ -0,0 +1,35 @@
|
||||
// pkg/logger/helpers.go
|
||||
package logger
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// LogApplicationStart логирует запуск приложения
|
||||
func LogApplicationStart(version, environment, port string) {
|
||||
Get().Info("application starting",
|
||||
zap.String("version", version),
|
||||
zap.String("environment", environment),
|
||||
zap.String("port", port),
|
||||
zap.Time("start_time", time.Now()),
|
||||
)
|
||||
}
|
||||
|
||||
// LogApplicationShutdown логирует graceful shutdown
|
||||
func LogApplicationShutdown(reason string) {
|
||||
Get().Info("application shutting down",
|
||||
zap.String("reason", reason),
|
||||
zap.Time("shutdown_time", time.Now()),
|
||||
)
|
||||
}
|
||||
|
||||
// LogDatabaseStats логирует статистику базы данных
|
||||
func LogDatabaseStats(stats map[string]interface{}) {
|
||||
fields := make([]zap.Field, 0, len(stats))
|
||||
for key, value := range stats {
|
||||
fields = append(fields, zap.Any(key, value))
|
||||
}
|
||||
Get().Info("database statistics", fields...)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// pkg/logger/interface.go
|
||||
package logger
|
||||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
// LoggerInterface определяет контракт для логгера
|
||||
type LoggerInterface interface {
|
||||
Debug(msg string, fields ...zap.Field)
|
||||
Info(msg string, fields ...zap.Field)
|
||||
Warn(msg string, fields ...zap.Field)
|
||||
Error(msg string, fields ...zap.Field)
|
||||
Fatal(msg string, fields ...zap.Field)
|
||||
|
||||
Debugf(template string, args ...interface{})
|
||||
Infof(template string, args ...interface{})
|
||||
Warnf(template string, args ...interface{})
|
||||
Errorf(template string, args ...interface{})
|
||||
Fatalf(template string, args ...interface{})
|
||||
|
||||
With(fields ...zap.Field) LoggerInterface
|
||||
}
|
||||
|
||||
// wrapper обертка для zap.Logger
|
||||
type wrapper struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewWrapper создает новую обертку
|
||||
func NewWrapper(logger *zap.Logger) LoggerInterface {
|
||||
return &wrapper{logger: logger}
|
||||
}
|
||||
|
||||
func (w *wrapper) Debug(msg string, fields ...zap.Field) {
|
||||
w.logger.Debug(msg, fields...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Info(msg string, fields ...zap.Field) {
|
||||
w.logger.Info(msg, fields...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Warn(msg string, fields ...zap.Field) {
|
||||
w.logger.Warn(msg, fields...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Error(msg string, fields ...zap.Field) {
|
||||
w.logger.Error(msg, fields...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Fatal(msg string, fields ...zap.Field) {
|
||||
w.logger.Fatal(msg, fields...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Debugf(template string, args ...interface{}) {
|
||||
w.logger.Sugar().Debugf(template, args...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Infof(template string, args ...interface{}) {
|
||||
w.logger.Sugar().Infof(template, args...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Warnf(template string, args ...interface{}) {
|
||||
w.logger.Sugar().Warnf(template, args...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Errorf(template string, args ...interface{}) {
|
||||
w.logger.Sugar().Errorf(template, args...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Fatalf(template string, args ...interface{}) {
|
||||
w.logger.Sugar().Fatalf(template, args...)
|
||||
}
|
||||
|
||||
func (w *wrapper) With(fields ...zap.Field) LoggerInterface {
|
||||
return &wrapper{logger: w.logger.With(fields...)}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// pkg/logger/logger.go
|
||||
package logger
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
)
|
||||
|
||||
var globalLogger *zap.Logger
|
||||
|
||||
// Init инициализирует глобальный логгер
|
||||
func Init(level string, environment string) error {
|
||||
var config zap.Config
|
||||
|
||||
if environment == "production" {
|
||||
config = zap.NewProductionConfig()
|
||||
} else {
|
||||
config = zap.NewDevelopmentConfig()
|
||||
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
}
|
||||
|
||||
// Устанавливаем уровень логирования
|
||||
switch level {
|
||||
case "debug":
|
||||
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||
case "info":
|
||||
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
|
||||
case "warn":
|
||||
config.Level = zap.NewAtomicLevelAt(zap.WarnLevel)
|
||||
case "error":
|
||||
config.Level = zap.NewAtomicLevelAt(zap.ErrorLevel)
|
||||
default:
|
||||
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
|
||||
}
|
||||
|
||||
logger, err := config.Build()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
globalLogger = logger
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get возвращает глобальный логгер
|
||||
func Get() *zap.Logger {
|
||||
if globalLogger == nil {
|
||||
// Fallback на стандартный логгер если не инициализирован
|
||||
logger, _ := zap.NewProduction()
|
||||
return logger
|
||||
}
|
||||
return globalLogger
|
||||
}
|
||||
|
||||
// Sync синхронизирует буферы логгера
|
||||
func Sync() {
|
||||
if globalLogger != nil {
|
||||
globalLogger.Sync()
|
||||
}
|
||||
}
|
||||
|
||||
// Sugar возвращает SugaredLogger
|
||||
func Sugar() *zap.SugaredLogger {
|
||||
return Get().Sugar()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type RouteLogger struct {
|
||||
logger LoggerInterface
|
||||
}
|
||||
|
||||
func NewRouteLogger(log LoggerInterface) *RouteLogger {
|
||||
return &RouteLogger{
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RouteLogger) LogRoutes(router *chi.Mux) {
|
||||
routes := rl.extractRoutes(router)
|
||||
rl.printFormattedRoutes(routes)
|
||||
}
|
||||
|
||||
func (rl *RouteLogger) extractRoutes(router *chi.Mux) []RouteInfo {
|
||||
var routes []RouteInfo
|
||||
|
||||
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
if route != "" {
|
||||
routes = append(routes, RouteInfo{
|
||||
Method: method,
|
||||
Path: route,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := chi.Walk(router, walkFunc); err != nil {
|
||||
rl.logger.Error("Failed to walk routes", zap.Error(err))
|
||||
}
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
func (rl *RouteLogger) printFormattedRoutes(routes []RouteInfo) {
|
||||
if len(routes) == 0 {
|
||||
rl.logger.Info("No routes found")
|
||||
return
|
||||
}
|
||||
|
||||
// Группируем по пути
|
||||
routesByPath := make(map[string][]string)
|
||||
for _, route := range routes {
|
||||
routesByPath[route.Path] = append(routesByPath[route.Path], route.Method)
|
||||
}
|
||||
|
||||
// Сортируем пути
|
||||
var paths []string
|
||||
for path := range routesByPath {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
|
||||
rl.logger.Info("📋 Registered API Routes:")
|
||||
rl.logger.Info("┌──────────────────────────────────────────────────────────────┐")
|
||||
|
||||
for _, path := range paths {
|
||||
methods := routesByPath[path]
|
||||
sort.Strings(methods)
|
||||
methodsStr := strings.Join(methods, ", ")
|
||||
|
||||
if len(methodsStr) > 12 {
|
||||
methodsStr = methodsStr[:9] + "..."
|
||||
}
|
||||
|
||||
methodField := methodsStr
|
||||
if len(methodField) < 12 {
|
||||
methodField = methodField + strings.Repeat(" ", 12-len(methodField))
|
||||
}
|
||||
|
||||
pathField := path
|
||||
if len(pathField) > 45 {
|
||||
pathField = pathField[:42] + "..."
|
||||
} else {
|
||||
pathField = pathField + strings.Repeat(" ", 45-len(pathField))
|
||||
}
|
||||
|
||||
rl.logger.Info("│ " + methodField + " " + pathField + " │")
|
||||
}
|
||||
|
||||
rl.logger.Info("└──────────────────────────────────────────────────────────────┘")
|
||||
rl.logger.Info("Total routes registered: %d", zap.Int("count", len(routes)))
|
||||
}
|
||||
|
||||
type RouteInfo struct {
|
||||
Method string
|
||||
Path string
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// pkg/middleware/admin_middleware.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/utils"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminMiddleware проверяет, что пользователь имеет роль администратора
|
||||
func AdminMiddleware(next http.Handler) http.Handler {
|
||||
logger := logger.NewWrapper(logger.Get().With(zap.String("middleware", "admin")))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
logger.Info("admin middleware check",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
logger.Warn("admin middleware failed - user not found in context")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем роль пользователя
|
||||
if user.Role != "admin" {
|
||||
logger.Warn("admin middleware failed - insufficient permissions",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("user_role", user.Role),
|
||||
zap.String("required_role", "admin"),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Insufficient permissions: admin role required")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("admin middleware passed",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("user_email", user.Email),
|
||||
)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// middleware/auth.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
UserIDKey contextKey = "userID"
|
||||
UserKey contextKey = "user"
|
||||
)
|
||||
|
||||
func AuthMiddleware(jwtService service.JWTService, userRepo repository.UserRepository) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var tokenString string
|
||||
logger := logger.Get()
|
||||
logger.Debug("authMiddleware Start")
|
||||
|
||||
// Пробуем получить токен из заголовка Authorization
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
logger.Debug("Token found in Authorization header")
|
||||
}
|
||||
|
||||
// Если нет в заголовке, пробуем из куки
|
||||
if tokenString == "" {
|
||||
cookie, err := r.Cookie("auth_token")
|
||||
if err == nil {
|
||||
tokenString = cookie.Value
|
||||
logger.Debug("Token found in auth_token cookie")
|
||||
} else {
|
||||
logger.Debug("No auth_token cookie found", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if tokenString == "" {
|
||||
logger.Debug("No token found in request")
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwtService.ValidateToken(tokenString)
|
||||
if err != nil || !token.Valid {
|
||||
logger.Warn("Invalid token",
|
||||
zap.Error(err),
|
||||
zap.Bool("token_valid", token != nil && token.Valid))
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := jwtService.ExtractUserID(token)
|
||||
if err != nil {
|
||||
logger.Error("Failed to extract user ID from token",
|
||||
zap.Error(err))
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("Extracted user ID from token",
|
||||
zap.Any("user_id", userID))
|
||||
|
||||
user, err := userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
logger.Error("Failed to find user by ID",
|
||||
zap.Any("user_id", userID),
|
||||
zap.Error(err))
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Добавляем пользователя в контекст
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
ctx = context.WithValue(ctx, UserKey, user)
|
||||
|
||||
logger.Debug("User authenticated successfully",
|
||||
zap.Any("user_id", userID),
|
||||
zap.String("username", user.FirstName))
|
||||
|
||||
|
||||
logger.Debug("authMiddleware End")
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth middleware требует аутентификации
|
||||
func RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
logger := logger.Get()
|
||||
userID := r.Context().Value(UserIDKey)
|
||||
logger.Debug("RequireAuth method start")
|
||||
logger.Debug("Extracted user ID from token",
|
||||
zap.Any("user_id", userID))
|
||||
|
||||
if userID == nil {
|
||||
logger.Warn("Authentication required but no user ID in context")
|
||||
http.Error(w, `{"error": "Authentication required"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("User authenticated", zap.Any("user_id", userID))
|
||||
logger.Debug("authMiddleware End")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserFromContext получает пользователя из контекста
|
||||
func GetUserFromContext(ctx context.Context) (*models.User, bool) {
|
||||
logger := logger.Get()
|
||||
user, ok := ctx.Value(UserKey).(*models.User)
|
||||
logger.Debug("GetUserFromContext method")
|
||||
logger.Debug("Extracted user ID from token",
|
||||
zap.Any("user_id", user.ID))
|
||||
|
||||
if !ok {
|
||||
logger.Debug("No user found in context")
|
||||
} else {
|
||||
logger.Debug("User retrieved from context",
|
||||
zap.Any("user_id", user.ID),
|
||||
zap.String("username", user.FirstName))
|
||||
}
|
||||
|
||||
return user, ok
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
// pkg/middleware/cors.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func CORS() func(http.Handler) http.Handler {
|
||||
return cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:3001", "https://begushiybashkir.ru"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Requested-With"},
|
||||
ExposedHeaders: []string{"Link", "Content-Length"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// pkg/middleware/logger.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Logger middleware для логирования HTTP запросов
|
||||
func ZapLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Получаем request ID
|
||||
reqID := middleware.GetReqID(r.Context())
|
||||
|
||||
// Создаем логгер с контекстом запроса
|
||||
requestLogger := logger.Get().With(
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
zap.String("user_agent", r.UserAgent()),
|
||||
zap.String("request_id", reqID),
|
||||
)
|
||||
|
||||
// Обертываем ResponseWriter для получения статуса
|
||||
wrappedWriter := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
// Обрабатываем запрос
|
||||
next.ServeHTTP(wrappedWriter, r)
|
||||
|
||||
// Логируем результат
|
||||
duration := time.Since(start)
|
||||
|
||||
requestLogger.Info("request completed",
|
||||
zap.Int("status", wrappedWriter.Status()),
|
||||
zap.Int("bytes", wrappedWriter.BytesWritten()),
|
||||
zap.Duration("duration", duration),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func CommonMiddleware() []func(http.Handler) http.Handler {
|
||||
return []func(http.Handler) http.Handler{
|
||||
HandleOptions,
|
||||
CORS(),
|
||||
ZapLogger,
|
||||
middleware.Recoverer,
|
||||
middleware.RequestID,
|
||||
cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{
|
||||
"https://xn--80abahjtcfl5d0a8di.xn--p1ai",
|
||||
"https://begushiybashkir.ru",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://localhost:5173"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Requested-With"},
|
||||
ExposedHeaders: []string{
|
||||
"Link",
|
||||
"Content-Length",
|
||||
"Set-Cookie",
|
||||
},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// pkg/middleware/options.go
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// HandleOptions автоматически обрабатывает OPTIONS запросы
|
||||
func HandleOptions(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package utils
|
||||
|
||||
// formatPace форматирует темп в строку "MM:SS"
|
||||
func FormatPace(minutes, seconds int) string {
|
||||
if seconds >= 60 {
|
||||
minutes += seconds / 60
|
||||
seconds = seconds % 60
|
||||
}
|
||||
return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds)
|
||||
}
|
||||
|
||||
// formatTwoDigits форматирует число в двузначную строку
|
||||
func FormatTwoDigits(num int) string {
|
||||
if num < 10 {
|
||||
return "0" + string(rune(num+'0'))
|
||||
}
|
||||
return string(rune(num/10+'0')) + string(rune(num%10+'0'))
|
||||
}
|
||||
|
||||
// formatTime форматирует время в строку "MM:SS"
|
||||
func FormatTime(minutes, seconds int) string {
|
||||
if seconds >= 60 {
|
||||
minutes += seconds / 60
|
||||
seconds = seconds % 60
|
||||
}
|
||||
return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// pkg/utils/response.go (дополнение)
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// RespondWithValidationError отправляет ответ с ошибками валидации
|
||||
func RespondWithValidationError(w http.ResponseWriter, validationError error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"error": "Validation failed",
|
||||
"details": GetValidationErrors(validationError),
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func RespondWithError(w http.ResponseWriter, statusCode int, message string) {
|
||||
RespondWithJSON(w, statusCode, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
// DecodeJSONBody декодирует JSON тело запроса
|
||||
func DecodeJSONBody(w http.ResponseWriter, r *http.Request, dst interface{}) error {
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
return errors.New("Content-Type header is not application/json")
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1048576) // 1MB limit
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
|
||||
err := dec.Decode(dst)
|
||||
if err != nil {
|
||||
var syntaxError *json.SyntaxError
|
||||
var unmarshalTypeError *json.UnmarshalTypeError
|
||||
|
||||
switch {
|
||||
case errors.As(err, &syntaxError):
|
||||
return fmt.Errorf("request body contains badly-formed JSON (at position %d)", syntaxError.Offset)
|
||||
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return errors.New("request body contains badly-formed JSON")
|
||||
|
||||
case errors.As(err, &unmarshalTypeError):
|
||||
return fmt.Errorf("request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset)
|
||||
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
|
||||
return fmt.Errorf("request body contains unknown field %s", fieldName)
|
||||
|
||||
case errors.Is(err, io.EOF):
|
||||
return errors.New("request body must not be empty")
|
||||
|
||||
case err.Error() == "http: request body too large":
|
||||
return errors.New("request body must not be larger than 1MB")
|
||||
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = dec.Decode(&struct{}{})
|
||||
if err != io.EOF {
|
||||
return errors.New("request body must only contain a single JSON object")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserIDFromContext извлекает userID из контекста
|
||||
func GetUserIDFromContext(r *http.Request) (uint, bool) {
|
||||
userID, ok := r.Context().Value("userID").(uint)
|
||||
return userID, ok
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// pkg/utils/validation.go
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ValidationError представляет ошибку валидации
|
||||
type ValidationError struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e ValidationError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
// ValidationResult содержит результат валидации
|
||||
type ValidationResult struct {
|
||||
IsValid bool
|
||||
Errors []ValidationError
|
||||
}
|
||||
|
||||
// TagOptions содержит опции из тега validate
|
||||
type TagOptions struct {
|
||||
Required bool
|
||||
Min *float64
|
||||
Max *float64
|
||||
MinInt *int64
|
||||
MaxInt *int64
|
||||
OneOf []string
|
||||
Email bool
|
||||
MaxLength *int
|
||||
MinLength *int
|
||||
Custom string
|
||||
}
|
||||
|
||||
// ValidateStruct валидирует структуру на основе тегов validate
|
||||
func ValidateStruct(s interface{}) error {
|
||||
val := reflect.ValueOf(s)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
if val.Kind() != reflect.Struct {
|
||||
return fmt.Errorf("ValidateStruct expects a struct, got %T", s)
|
||||
}
|
||||
|
||||
var errors []ValidationError
|
||||
typ := val.Type()
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Field(i)
|
||||
fieldType := typ.Field(i)
|
||||
|
||||
// Пропускаем неэкспортируемые поля
|
||||
if !field.CanInterface() {
|
||||
continue
|
||||
}
|
||||
|
||||
tag := fieldType.Tag.Get("validate")
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
options := parseTagOptions(tag)
|
||||
fieldName := getFieldName(fieldType)
|
||||
|
||||
// Валидация поля
|
||||
if err := validateField(field, fieldName, options); err != nil {
|
||||
errors = append(errors, err...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return &ValidationResult{
|
||||
IsValid: false,
|
||||
Errors: errors,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTagOptions парсит тег validate и возвращает опции
|
||||
func parseTagOptions(tag string) TagOptions {
|
||||
options := TagOptions{}
|
||||
parts := strings.Split(tag, ",")
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
|
||||
switch {
|
||||
case part == "required":
|
||||
options.Required = true
|
||||
case part == "email":
|
||||
options.Email = true
|
||||
case strings.HasPrefix(part, "min="):
|
||||
if val, err := strconv.ParseFloat(part[4:], 64); err == nil {
|
||||
options.Min = &val
|
||||
}
|
||||
case strings.HasPrefix(part, "max="):
|
||||
if val, err := strconv.ParseFloat(part[4:], 64); err == nil {
|
||||
options.Max = &val
|
||||
}
|
||||
case strings.HasPrefix(part, "minint="):
|
||||
if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil {
|
||||
options.MinInt = &val
|
||||
}
|
||||
case strings.HasPrefix(part, "maxint="):
|
||||
if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil {
|
||||
options.MaxInt = &val
|
||||
}
|
||||
case strings.HasPrefix(part, "oneof="):
|
||||
options.OneOf = strings.Split(part[6:], " ")
|
||||
case strings.HasPrefix(part, "maxlen="):
|
||||
if val, err := strconv.Atoi(part[7:]); err == nil {
|
||||
options.MaxLength = &val
|
||||
}
|
||||
case strings.HasPrefix(part, "minlen="):
|
||||
if val, err := strconv.Atoi(part[7:]); err == nil {
|
||||
options.MinLength = &val
|
||||
}
|
||||
case strings.HasPrefix(part, "custom="):
|
||||
options.Custom = part[7:]
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// getFieldName возвращает имя поля для сообщений об ошибках
|
||||
func getFieldName(field reflect.StructField) string {
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag != "" {
|
||||
parts := strings.Split(jsonTag, ",")
|
||||
if parts[0] != "" {
|
||||
return parts[0]
|
||||
}
|
||||
}
|
||||
return field.Name
|
||||
}
|
||||
|
||||
// validateField валидирует отдельное поле
|
||||
func validateField(field reflect.Value, fieldName string, options TagOptions) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
// Проверка required
|
||||
if options.Required {
|
||||
if isEmptyValue(field) {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "field is required",
|
||||
})
|
||||
return errors // Если поле обязательно и пустое, дальше не проверяем
|
||||
}
|
||||
}
|
||||
|
||||
// Если поле пустое и не обязательное, дальше не проверяем
|
||||
if isEmptyValue(field) {
|
||||
return errors
|
||||
}
|
||||
|
||||
// Валидация в зависимости от типа поля
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
errors = append(errors, validateString(field.String(), fieldName, options)...)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
errors = append(errors, validateInt(field.Int(), fieldName, options)...)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
errors = append(errors, validateFloat(field.Float(), fieldName, options)...)
|
||||
case reflect.Struct:
|
||||
// Для time.Time и других структур
|
||||
if field.Type().String() == "time.Time" {
|
||||
errors = append(errors, validateTime(field.Interface().(time.Time), fieldName, options)...)
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// validateString валидирует строковые поля
|
||||
func validateString(value, fieldName string, options TagOptions) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
// Проверка email
|
||||
if options.Email {
|
||||
if !isValidEmail(value) {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "invalid email format",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка длины строки
|
||||
if options.MinLength != nil && len(value) < *options.MinLength {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("minimum length is %d characters", *options.MinLength),
|
||||
})
|
||||
}
|
||||
|
||||
if options.MaxLength != nil && len(value) > *options.MaxLength {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("maximum length is %d characters", *options.MaxLength),
|
||||
})
|
||||
}
|
||||
|
||||
// Проверка oneof
|
||||
if len(options.OneOf) > 0 {
|
||||
valid := false
|
||||
for _, allowed := range options.OneOf {
|
||||
if value == allowed {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("must be one of: %s", strings.Join(options.OneOf, ", ")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// validateInt валидирует целочисленные поля
|
||||
func validateInt(value int64, fieldName string, options TagOptions) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
if options.MinInt != nil && value < *options.MinInt {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("minimum value is %d", *options.MinInt),
|
||||
})
|
||||
}
|
||||
|
||||
if options.MaxInt != nil && value > *options.MaxInt {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("maximum value is %d", *options.MaxInt),
|
||||
})
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// validateFloat валидирует поля с плавающей точкой
|
||||
func validateFloat(value float64, fieldName string, options TagOptions) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
if options.Min != nil && value < *options.Min {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("minimum value is %.2f", *options.Min),
|
||||
})
|
||||
}
|
||||
|
||||
if options.Max != nil && value > *options.Max {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("maximum value is %.2f", *options.Max),
|
||||
})
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// validateTime валидирует временные поля
|
||||
func validateTime(value time.Time, fieldName string, options TagOptions) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
// Проверка, что дата не нулевая
|
||||
if value.IsZero() && options.Required {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "date is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Проверка, что дата не в будущем (пример кастомной валидации)
|
||||
if options.Custom == "not_future" && value.After(time.Now()) {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "date cannot be in the future",
|
||||
})
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// isEmptyValue проверяет, является ли значение пустым
|
||||
func isEmptyValue(v reflect.Value) bool {
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
return v.String() == ""
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return v.Int() == 0
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return v.Float() == 0
|
||||
case reflect.Bool:
|
||||
return !v.Bool()
|
||||
case reflect.Struct:
|
||||
if v.Type().String() == "time.Time" {
|
||||
return v.Interface().(time.Time).IsZero()
|
||||
}
|
||||
case reflect.Ptr, reflect.Interface:
|
||||
return v.IsNil()
|
||||
case reflect.Slice, reflect.Map, reflect.Array:
|
||||
return v.Len() == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidEmail проверяет валидность email
|
||||
func isValidEmail(email string) bool {
|
||||
emailRegex := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
|
||||
matched, _ := regexp.MatchString(emailRegex, email)
|
||||
return matched
|
||||
}
|
||||
|
||||
// Error возвращает строковое представление ошибок валидации
|
||||
func (vr *ValidationResult) Error() string {
|
||||
var errorMessages []string
|
||||
for _, err := range vr.Errors {
|
||||
errorMessages = append(errorMessages, err.Error())
|
||||
}
|
||||
return strings.Join(errorMessages, "; ")
|
||||
}
|
||||
|
||||
// GetValidationErrors возвращает ошибки валидации в структурированном виде
|
||||
func GetValidationErrors(err error) []ValidationError {
|
||||
if vr, ok := err.(*ValidationResult); ok {
|
||||
return vr.Errors
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogValidationErrors логирует ошибки валидации
|
||||
func LogValidationErrors(logger *zap.Logger, err error, context string) {
|
||||
if vr, ok := err.(*ValidationResult); ok {
|
||||
for _, validationErr := range vr.Errors {
|
||||
logger.Warn("validation error",
|
||||
zap.String("context", context),
|
||||
zap.String("field", validationErr.Field),
|
||||
zap.String("error", validationErr.Message),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParseUintFromQuery парсит uint из query параметра
|
||||
func ParseUintFromQuery(queryParam string, defaultValue uint) (uint, error) {
|
||||
if queryParam == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
value, err := strconv.ParseUint(queryParam, 10, 32)
|
||||
if err != nil {
|
||||
return defaultValue, err
|
||||
}
|
||||
|
||||
return uint(value), nil
|
||||
}
|
||||
|
||||
// ParseIntFromQuery парсит int из query параметра
|
||||
func ParseIntFromQuery(queryParam string, defaultValue int) (int, error) {
|
||||
if queryParam == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(queryParam)
|
||||
if err != nil {
|
||||
return defaultValue, err
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// ParseBoolFromQuery парсит bool из query параметра
|
||||
func ParseBoolFromQuery(queryParam string, defaultValue bool) bool {
|
||||
if queryParam == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return strings.ToLower(queryParam) == "true" || queryParam == "1"
|
||||
}
|
||||
Reference in New Issue
Block a user