Files
QuoteForge/cmd/server/main.go
Mikhail Chusavitin 190a9aa0a3 Add initial backend implementation
- Go module with Gin, GORM, JWT, excelize dependencies
- Configuration loading from YAML with all settings
- GORM models for users, categories, components, configurations, alerts
- Repository layer for all entities
- Services: auth (JWT), pricing (median/average/weighted), components,
  quotes, configurations, export (CSV/XLSX), alerts
- Middleware: JWT auth, role-based access, CORS
- HTTP handlers for all API endpoints
- Main server with dependency injection and graceful shutdown

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:49:56 +03:00

290 lines
7.9 KiB
Go

package main
import (
"context"
"flag"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/mchus/quoteforge/internal/config"
"github.com/mchus/quoteforge/internal/handlers"
"github.com/mchus/quoteforge/internal/middleware"
"github.com/mchus/quoteforge/internal/models"
"github.com/mchus/quoteforge/internal/repository"
"github.com/mchus/quoteforge/internal/services"
"github.com/mchus/quoteforge/internal/services/alerts"
"github.com/mchus/quoteforge/internal/services/pricing"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
migrate := flag.Bool("migrate", false, "run database migrations")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
setupLogger(cfg.Logging)
slog.Info("starting QuoteForge server",
"host", cfg.Server.Host,
"port", cfg.Server.Port,
"mode", cfg.Server.Mode,
)
db, err := setupDatabase(cfg.Database)
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
if *migrate {
slog.Info("running database migrations...")
if err := models.Migrate(db); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
if err := models.SeedCategories(db); err != nil {
slog.Error("seeding categories failed", "error", err)
os.Exit(1)
}
slog.Info("migrations completed")
}
gin.SetMode(cfg.Server.Mode)
router := setupRouter(db, cfg)
srv := &http.Server{
Addr: cfg.Address(),
Handler: router,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
go func() {
slog.Info("server listening", "address", cfg.Address())
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("server forced to shutdown", "error", err)
}
slog.Info("server stopped")
}
func setupLogger(cfg config.LoggingConfig) {
var level slog.Level
switch cfg.Level {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: level}
var handler slog.Handler
if cfg.Format == "json" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
slog.SetDefault(slog.New(handler))
}
func setupDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
gormLogger := logger.Default.LogMode(logger.Silent)
db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{
Logger: gormLogger,
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
return db, nil
}
func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
// Repositories
userRepo := repository.NewUserRepository(db)
componentRepo := repository.NewComponentRepository(db)
categoryRepo := repository.NewCategoryRepository(db)
priceRepo := repository.NewPriceRepository(db)
configRepo := repository.NewConfigurationRepository(db)
alertRepo := repository.NewAlertRepository(db)
statsRepo := repository.NewStatsRepository(db)
// Services
authService := services.NewAuthService(userRepo, cfg.Auth)
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
configService := services.NewConfigurationService(configRepo, componentRepo, quoteService)
exportService := services.NewExportService(cfg.Export)
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
// Handlers
authHandler := handlers.NewAuthHandler(authService, userRepo)
componentHandler := handlers.NewComponentHandler(componentService)
quoteHandler := handlers.NewQuoteHandler(quoteService)
configHandler := handlers.NewConfigurationHandler(configService, exportService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(pricingService, alertService, componentRepo, statsRepo)
// Router
router := gin.New()
router.Use(gin.Recovery())
router.Use(requestLogger())
router.Use(middleware.CORS())
// Health check
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
// API routes
api := router.Group("/api")
{
api.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "pong"})
})
// Auth (public)
auth := api.Group("/auth")
{
auth.POST("/login", authHandler.Login)
auth.POST("/refresh", authHandler.Refresh)
auth.POST("/logout", authHandler.Logout)
auth.GET("/me", middleware.Auth(authService), authHandler.Me)
}
// Components (public read, for quote builder)
components := api.Group("/components")
{
components.GET("", componentHandler.List)
components.GET("/:lot_name", componentHandler.Get)
}
// Categories (public)
api.GET("/categories", componentHandler.GetCategories)
api.GET("/vendors", componentHandler.GetVendors)
// Quote (public, for anonymous quote building)
quote := api.Group("/quote")
{
quote.POST("/validate", quoteHandler.Validate)
quote.POST("/calculate", quoteHandler.Calculate)
}
// Export (public, for anonymous exports)
export := api.Group("/export")
{
export.POST("/csv", exportHandler.ExportCSV)
export.POST("/xlsx", exportHandler.ExportXLSX)
}
// Configurations (requires auth)
configs := api.Group("/configs")
configs.Use(middleware.Auth(authService))
configs.Use(middleware.RequireEditor())
{
configs.GET("", configHandler.List)
configs.POST("", configHandler.Create)
configs.GET("/:uuid", configHandler.Get)
configs.PUT("/:uuid", configHandler.Update)
configs.DELETE("/:uuid", configHandler.Delete)
configs.GET("/:uuid/export", configHandler.ExportJSON)
configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV)
configs.GET("/:uuid/xlsx", exportHandler.ExportConfigXLSX)
configs.POST("/import", configHandler.ImportJSON)
}
}
// Admin routes
admin := router.Group("/admin")
admin.Use(middleware.Auth(authService))
{
// Pricing admin
pricingAdmin := admin.Group("/pricing")
pricingAdmin.Use(middleware.RequirePricingAdmin())
{
pricingAdmin.GET("/stats", pricingHandler.GetStats)
pricingAdmin.GET("/components", pricingHandler.ListComponents)
pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing)
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)
pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert)
pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert)
}
}
return router
}
func requestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
slog.Info("request",
"method", c.Request.Method,
"path", path,
"query", query,
"status", status,
"latency", latency,
"ip", c.ClientIP(),
)
}
}