- Go API server with PostgreSQL + Redis - AI floor replacement (OpenRouter Gemini) - Product database (10 brands, 3539 products) - Recommendation engine, calculator, articles - Redis async queue + worker pool - Hot product caching, brand view tracking - JWT auth, favorites, projects - Docker deployment ready Co-Authored-By: Claude <noreply@anthropic.com>
176 lines
4.9 KiB
Go
176 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"time"
|
|
"encoding/json"
|
|
"math/rand"
|
|
|
|
"floorvisualizer/internal/handler"
|
|
"floorvisualizer/internal/logger"
|
|
"floorvisualizer/internal/model"
|
|
"floorvisualizer/internal/middleware"
|
|
"floorvisualizer/internal/openrouter"
|
|
"floorvisualizer/internal/queue"
|
|
"floorvisualizer/internal/repository"
|
|
"floorvisualizer/internal/service"
|
|
)
|
|
|
|
const defaultAPIKey = "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"
|
|
const defaultJWTSecret = "floorviz-dev-secret-change-in-production"
|
|
var proxyURL = "http://127.0.0.1:7897"
|
|
|
|
func init() {
|
|
if v := os.Getenv("PROXY_URL"); v != "" {
|
|
proxyURL = v
|
|
}
|
|
if v := os.Getenv("DISABLE_PROXY"); v == "true" || v == "1" {
|
|
proxyURL = ""
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
logDir := "logs"
|
|
if v := os.Getenv("LOG_DIR"); v != "" {
|
|
logDir = v
|
|
}
|
|
logLevel := "INFO"
|
|
if v := os.Getenv("LOG_LEVEL"); v != "" {
|
|
logLevel = v
|
|
}
|
|
logger.Init(logDir, logLevel)
|
|
defer logger.Close()
|
|
|
|
dsn := repository.DefaultDSN
|
|
if v := os.Getenv("DATABASE_URL"); v != "" {
|
|
dsn = v
|
|
}
|
|
db, err := repository.Connect(dsn)
|
|
if err != nil {
|
|
logger.Warn("Database not available: %v", err)
|
|
} else {
|
|
defer db.Close()
|
|
logger.Info("Database connected (tables managed via schema.sql)")
|
|
if err := repository.InitGORM(db); err != nil {
|
|
logger.Warn("GORM init failed, falling back to raw SQL: %v", err)
|
|
} else {
|
|
logger.Info("GORM initialized")
|
|
}
|
|
}
|
|
|
|
jwtSecret := defaultJWTSecret
|
|
if s := os.Getenv("JWT_SECRET"); s != "" {
|
|
jwtSecret = s
|
|
}
|
|
authSvc := service.NewAuthService(jwtSecret)
|
|
|
|
apiKey := defaultAPIKey
|
|
if k := os.Getenv("OPENROUTER_API_KEY"); k != "" {
|
|
apiKey = k
|
|
}
|
|
client := openrouter.NewClient(apiKey)
|
|
if proxyURL != "" {
|
|
client = openrouter.NewClient(apiKey, openrouter.WithHTTPClient(handler.NewProxyClient(proxyURL)))
|
|
}
|
|
|
|
// ── Redis ──────────────────────────────────────────────
|
|
redisAddr := "localhost:6379"
|
|
if v := os.Getenv("REDIS_ADDR"); v != "" {
|
|
redisAddr = v
|
|
}
|
|
rdb, err := queue.NewRedisClient(redisAddr, "")
|
|
if err != nil {
|
|
logger.Warn("Redis not available (%v), worker queue disabled", err)
|
|
} else {
|
|
defer rdb.Close()
|
|
logger.Info("Redis connected")
|
|
|
|
// Wire up the queue: generate handler enqueues to Redis
|
|
handler.SetQueue(func(ctx context.Context, jobID string, payload map[string]string) error {
|
|
if err := queue.StoreJobPayload(ctx, rdb, jobID, payload); err != nil {
|
|
return err
|
|
}
|
|
return queue.EnqueueJob(ctx, rdb, jobID)
|
|
})
|
|
|
|
// Wire up status query
|
|
handler.SetStatusQuery(func(ctx context.Context, jobID string) (*handler.JobStatusResp, error) {
|
|
js, err := queue.GetJobStatus(ctx, rdb, jobID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &handler.JobStatusResp{
|
|
JobID: js.JobID, Status: js.Status, Step: js.Step,
|
|
Message: js.Message, Result: js.Result, Error: js.Error,
|
|
}, nil
|
|
})
|
|
|
|
// Start worker pool (default 5, override with WORKER_COUNT env)
|
|
workerCount := 5
|
|
if v := os.Getenv("WORKER_COUNT"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 20 {
|
|
workerCount = n
|
|
}
|
|
}
|
|
workerCtx, cancelWorkers := context.WithCancel(context.Background())
|
|
defer cancelWorkers()
|
|
queue.StartWorkers(workerCtx, workerCount, rdb, client, db)
|
|
logger.Info("Worker pool started: %d workers", workerCount)
|
|
|
|
// Graceful shutdown
|
|
go func() {
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, os.Interrupt)
|
|
<-sig
|
|
logger.Info("Shutting down workers...")
|
|
cancelWorkers()
|
|
}()
|
|
}
|
|
|
|
|
|
// ── Hot product cache ────────────────────────────────
|
|
handler.SetProductsCache(
|
|
// Getter: check Redis cache for hot products
|
|
func(ctx context.Context, sku string) (*model.Product, bool) {
|
|
val, err := rdb.Get(ctx, "product:"+sku).Result()
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
var p model.Product
|
|
if json.Unmarshal([]byte(val), &p) == nil {
|
|
return &p, true
|
|
}
|
|
return nil, false
|
|
},
|
|
// Setter: only cache if this SKU is in the hot list (top 200)
|
|
func(ctx context.Context, sku string, product *model.Product) error {
|
|
rank, err := rdb.ZRevRank(ctx, "product:views", sku).Result()
|
|
if err != nil || rank >= 200 {
|
|
return nil // not hot enough, skip caching
|
|
}
|
|
data, _ := json.Marshal(product)
|
|
ttl := 1*time.Hour + time.Duration(rand.Intn(600))*time.Second // ±10min jitter
|
|
return rdb.Set(ctx, "product:"+sku, data, ttl).Err()
|
|
},
|
|
// View tracker: increment view count in sorted set
|
|
func(ctx context.Context, sku string) {
|
|
rdb.ZIncrBy(ctx, "product:views", 1, sku)
|
|
},
|
|
)
|
|
|
|
mux := http.NewServeMux()
|
|
handler.RegisterAll(mux, client, db, authSvc)
|
|
|
|
port := "8099"
|
|
if p := os.Getenv("PORT"); p != "" {
|
|
port = p
|
|
}
|
|
logger.Info("FloorVisualizer -> http://localhost:%s", port)
|
|
log.Fatal(http.ListenAndServe(":"+port, middleware.Logging(mux)))
|
|
}
|