rename long name to short name
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