diff --git a/.env.example b/.env.example index 088a927..34699a1 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ DB_USER=postgres DB_PASSWORD=postgres DB_NAME=dating_app DB_SSLMODE=disable +# connection pool؛ زیر بار بالا این مقادیر رو با ظرفیت واقعی سرور Postgres تنظیم کن +DB_MAX_OPEN_CONNS=25 +DB_MAX_IDLE_CONNS=10 +DB_CONN_MAX_LIFETIME_MINUTES=30 +DB_CONN_MAX_IDLE_TIME_MINUTES=5 JWT_ACCESS_SECRET=test-access-secret JWT_REFRESH_SECRET=test-refresh-secret diff --git a/.gitignore b/.gitignore index 7ed7034..c69238d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ # فایل‌های آپلودشده‌ی کاربران (نباید توی گیت باشن) /uploads/ +# بک‌آپ‌های دیتابیس (نباید توی گیت باشن؛ حجیم و شامل داده‌ی کاربرانه) +/backups/ + # IDE .idea/ .vscode/ diff --git a/cmd/api/main.go b/cmd/api/main.go index 5d11aad..06abdd5 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,13 +1,22 @@ package main import ( + "context" + "errors" "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" "github.com/gin-gonic/gin" "dating-app/internal/config" "dating-app/internal/database" "dating-app/internal/handler" + "dating-app/internal/middleware" + "dating-app/internal/monitoring" "dating-app/internal/repository" "dating-app/internal/router" "dating-app/internal/service" @@ -65,6 +74,10 @@ func main() { profileService := service.NewProfileService(photoRepo, userRepo, photoStorage) discoveryService := service.NewDiscoveryService(userRepo, photoRepo, swipeRepo, matchRepo, optionRepo, questionRepo) chatService := service.NewChatService(matchRepo, messageRepo, userRepo, photoRepo) + backupService := service.NewBackupService(cfg, "./backups") + + // -------- مانیتورینگ -------- + metricsStore := monitoring.NewStore() // -------- Handlers -------- handlers := router.Handlers{ @@ -74,16 +87,54 @@ func main() { Admin: handler.NewAdminHandler(adminService, optionRepo, questionRepo, photoRepo, onboardingService), Discovery: handler.NewDiscoveryHandler(discoveryService), Chat: handler.NewChatHandler(chatService), + Monitoring: handler.NewMonitoringHandler(metricsStore), + Backup: handler.NewBackupHandler(backupService), } if cfg.AppEnv == "production" { gin.SetMode(gin.ReleaseMode) } - engine := gin.Default() + // به‌جای gin.Default() (که فقط Logger+Recovery پیش‌فرض رو می‌ده)، دستی + // می‌سازیم تا هم Metrics رو اضافه کنیم و هم Recovery خودمون که envelope + // استاندارد JSON برمی‌گردونه. ترتیب مهمه: Metrics باید بیرونی‌ترین لایه + // باشه تا حتی درخواست‌هایی که پنیک می‌کنن (و توسط Recovery مهار می‌شن) هم + // توی آمار لحاظ بشن. + engine := gin.New() + engine.Use(middleware.Metrics(metricsStore)) + engine.Use(middleware.Recovery()) + engine.Use(gin.Logger()) router.Setup(engine, handlers, jwtManager, userRepo) - log.Printf("سرور روی پورت %s در حال اجراست (env=%s)", cfg.AppPort, cfg.AppEnv) - if err := engine.Run(":" + cfg.AppPort); err != nil { - log.Fatalf("اجرای سرور ناموفق بود: %v", err) + // یک http.Server دستی به‌جای engine.Run() ساده، به دو دلیل: + // ۱. تایم‌اوت‌های صریح روی خوندن/نوشتن، تا یک کلاینت کند یا مخرب (slowloris) + // نتونه goroutine/فایل‌دسکریپتورهای سرور رو زیر بار بالا قفل نگه داره. + // ۲. Shutdown مهلت‌دار، تا موقع دیپلوی/ری‌استارت، درخواست‌های در حال پردازش + // ناتمام قطع نشن (به‌جای کشتن فوری پروسه). + srv := &http.Server{ + Addr: ":" + cfg.AppPort, + Handler: engine, + ReadTimeout: 30 * time.Second, + ReadHeaderTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 90 * time.Second, } + + go func() { + log.Printf("سرور روی پورت %s در حال اجراست (env=%s)", cfg.AppPort, cfg.AppEnv) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("اجرای سرور ناموفق بود: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("سیگنال خاموشی دریافت شد؛ منتظر تمام‌شدن درخواست‌های در حال پردازش...") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("خاموش کردن سرور با مشکل مواجه شد: %v", err) + } + log.Println("سرور با موفقیت خاموش شد") } diff --git a/internal/cache/ttl_cache.go b/internal/cache/ttl_cache.go new file mode 100644 index 0000000..48f88f0 --- /dev/null +++ b/internal/cache/ttl_cache.go @@ -0,0 +1,55 @@ +// Package cache یک کش درون‌حافظه‌ای ساده با انقضای زمانی (TTL) ارائه می‌ده. +// برای داده‌هایی مناسبه که زیاد خونده می‌شن ولی به‌ندرت تغییر می‌کنن (مثل +// گزینه‌های دسته‌بندی‌ها و سوالات پروفایل که فقط از پنل ادمین تغییر می‌کنن). +package cache + +import ( + "sync" + "time" +) + +type entry[V any] struct { + value V + expiresAt time.Time +} + +type TTLCache[K comparable, V any] struct { + mu sync.RWMutex + ttl time.Duration + m map[K]entry[V] +} + +func New[K comparable, V any](ttl time.Duration) *TTLCache[K, V] { + return &TTLCache[K, V]{ttl: ttl, m: make(map[K]entry[V])} +} + +func (c *TTLCache[K, V]) Get(key K) (V, bool) { + c.mu.RLock() + e, ok := c.m[key] + c.mu.RUnlock() + + var zero V + if !ok || time.Now().After(e.expiresAt) { + return zero, false + } + return e.value, true +} + +func (c *TTLCache[K, V]) Set(key K, value V) { + c.mu.Lock() + defer c.mu.Unlock() + c.m[key] = entry[V]{value: value, expiresAt: time.Now().Add(c.ttl)} +} + +func (c *TTLCache[K, V]) Delete(key K) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.m, key) +} + +// Clear کل کش رو خالی می‌کنه؛ برای موقعی که نمی‌شه/نمی‌خوایم کلید دقیق رو حساب کنیم. +func (c *TTLCache[K, V]) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.m = make(map[K]entry[V]) +} diff --git a/internal/cache/ttl_cache_test.go b/internal/cache/ttl_cache_test.go new file mode 100644 index 0000000..958c45d --- /dev/null +++ b/internal/cache/ttl_cache_test.go @@ -0,0 +1,62 @@ +package cache + +import ( + "testing" + "time" +) + +func TestTTLCache_SetGet(t *testing.T) { + c := New[string, int](time.Minute) + c.Set("a", 42) + + v, ok := c.Get("a") + if !ok || v != 42 { + t.Fatalf("expected (42, true), got (%d, %v)", v, ok) + } +} + +func TestTTLCache_MissingKey(t *testing.T) { + c := New[string, int](time.Minute) + if _, ok := c.Get("missing"); ok { + t.Fatal("expected miss for key that was never set") + } +} + +func TestTTLCache_Expiry(t *testing.T) { + c := New[string, int](10 * time.Millisecond) + c.Set("a", 1) + + if _, ok := c.Get("a"); !ok { + t.Fatal("expected hit immediately after Set") + } + + time.Sleep(30 * time.Millisecond) + + if _, ok := c.Get("a"); ok { + t.Fatal("expected miss after TTL expired") + } +} + +func TestTTLCache_Delete(t *testing.T) { + c := New[string, int](time.Minute) + c.Set("a", 1) + c.Delete("a") + + if _, ok := c.Get("a"); ok { + t.Fatal("expected miss after Delete") + } +} + +func TestTTLCache_Clear(t *testing.T) { + c := New[string, int](time.Minute) + c.Set("a", 1) + c.Set("b", 2) + c.Clear() + + if _, ok := c.Get("a"); ok { + t.Fatal("expected miss for 'a' after Clear") + } + if _, ok := c.Get("b"); ok { + t.Fatal("expected miss for 'b' after Clear") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8874991..2d3d5bb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,13 @@ type Config struct { DBName string DBSSLMode string + // تنظیمات connection pool؛ زیر بار بالا این‌ها از باز شدن بی‌رویه‌ی اتصال + // (که خود Postgres رو زمین می‌زنه) و از نشتی اتصالات راکد جلوگیری می‌کنن. + DBMaxOpenConns int + DBMaxIdleConns int + DBConnMaxLifetimeMins int + DBConnMaxIdleTimeMins int + JWTAccessSecret string JWTRefreshSecret string JWTAccessTTL time.Duration @@ -49,6 +56,11 @@ func Load() *Config { DBName: getEnv("DB_NAME", "dating_app"), DBSSLMode: getEnv("DB_SSLMODE", "disable"), + DBMaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 25), + DBMaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 10), + DBConnMaxLifetimeMins: getEnvInt("DB_CONN_MAX_LIFETIME_MINUTES", 30), + DBConnMaxIdleTimeMins: getEnvInt("DB_CONN_MAX_IDLE_TIME_MINUTES", 5), + JWTAccessSecret: getEnv("JWT_ACCESS_SECRET", "change-me-access-secret"), JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", "change-me-refresh-secret"), JWTAccessTTL: time.Duration(getEnvInt("JWT_ACCESS_TTL_MINUTES", 60*24)) * time.Minute, diff --git a/internal/database/database.go b/internal/database/database.go index 79354c7..1866f54 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -2,6 +2,7 @@ package database import ( "log" + "time" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -24,6 +25,20 @@ func Connect(cfg *config.Config) (*gorm.DB, error) { if err != nil { return nil, err } + + // تنظیم connection pool؛ بدون این، پیش‌فرض Go بی‌نهایت اتصال باز می‌کنه که + // زیر بار بالا می‌تونه به‌راحتی خود Postgres رو با تعداد زیاد اتصال همزمان + // از پا در بیاره. MaxIdleConns هم اتصالات راکد رو محدود می‌کنه تا حافظه‌ی + // دیتابیس هدر نره. + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxOpenConns(cfg.DBMaxOpenConns) + sqlDB.SetMaxIdleConns(cfg.DBMaxIdleConns) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.DBConnMaxLifetimeMins) * time.Minute) + sqlDB.SetConnMaxIdleTime(time.Duration(cfg.DBConnMaxIdleTimeMins) * time.Minute) + return db, nil } diff --git a/internal/handler/backup_handler.go b/internal/handler/backup_handler.go new file mode 100644 index 0000000..01a2366 --- /dev/null +++ b/internal/handler/backup_handler.go @@ -0,0 +1,49 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "dating-app/internal/service" + "dating-app/internal/utils" +) + +type BackupHandler struct { + backupService *service.BackupService +} + +func NewBackupHandler(backupService *service.BackupService) *BackupHandler { + return &BackupHandler{backupService: backupService} +} + +// List GET /admin/backups +func (h *BackupHandler) List(c *gin.Context) { + backups, err := h.backupService.List() + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست بک‌آپ‌ها", nil) + return + } + utils.Success(c, http.StatusOK, "", backups) +} + +// Create POST /admin/backups +func (h *BackupHandler) Create(c *gin.Context) { + info, err := h.backupService.Create() + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "گرفتن بک‌آپ ناموفق بود: "+err.Error(), nil) + return + } + utils.Success(c, http.StatusCreated, "بک‌آپ با موفقیت ساخته شد", info) +} + +// Download GET /admin/backups/:filename +func (h *BackupHandler) Download(c *gin.Context) { + filename := c.Param("filename") + path, err := h.backupService.ResolvePath(filename) + if err != nil { + utils.Fail(c, http.StatusNotFound, "فایل بک‌آپ یافت نشد", nil) + return + } + c.FileAttachment(path, filename) +} diff --git a/internal/handler/chat_handler.go b/internal/handler/chat_handler.go index d8c40f2..f6c9a16 100644 --- a/internal/handler/chat_handler.go +++ b/internal/handler/chat_handler.go @@ -81,6 +81,10 @@ func (h *ChatHandler) handleError(c *gin.Context, err error) { switch { case errors.Is(err, service.ErrNotMatchMember): utils.Fail(c, http.StatusForbidden, err.Error(), nil) + case errors.Is(err, utils.ErrInvalidMessageEncoding), + errors.Is(err, utils.ErrMessageEmpty), + errors.Is(err, utils.ErrMessageTooLong): + utils.Fail(c, http.StatusBadRequest, err.Error(), nil) default: utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil) } diff --git a/internal/handler/monitoring_handler.go b/internal/handler/monitoring_handler.go new file mode 100644 index 0000000..7643208 --- /dev/null +++ b/internal/handler/monitoring_handler.go @@ -0,0 +1,35 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "dating-app/internal/monitoring" + "dating-app/internal/utils" +) + +type MonitoringHandler struct { + store *monitoring.Store +} + +func NewMonitoringHandler(store *monitoring.Store) *MonitoringHandler { + return &MonitoringHandler{store: store} +} + +// Summary GET /admin/monitoring/summary +func (h *MonitoringHandler) Summary(c *gin.Context) { + utils.Success(c, http.StatusOK, "", h.store.Summary()) +} + +// RecentRequests GET /admin/monitoring/requests?limit=100 +func (h *MonitoringHandler) RecentRequests(c *gin.Context) { + limit := 100 + if raw := c.Query("limit"); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 1000 { + limit = n + } + } + utils.Success(c, http.StatusOK, "", h.store.Recent(limit)) +} diff --git a/internal/middleware/gzip_middleware.go b/internal/middleware/gzip_middleware.go new file mode 100644 index 0000000..c04a199 --- /dev/null +++ b/internal/middleware/gzip_middleware.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "compress/gzip" + "io" + "strings" + + "github.com/gin-gonic/gin" +) + +type gzipWriter struct { + gin.ResponseWriter + writer io.Writer +} + +func (g *gzipWriter) Write(data []byte) (int, error) { + return g.writer.Write(data) +} + +func (g *gzipWriter) WriteString(s string) (int, error) { + return g.writer.Write([]byte(s)) +} + +// Gzip پاسخ‌های JSON رو (وقتی کلاینت gzip رو توی Accept-Encoding پشتیبانی کنه) +// فشرده می‌کنه؛ بدون هیچ وابستگی بیرونی، فقط با compress/gzip استاندارد گو. +// برای مسیرهایی مثل فید دیسکاوری که پاسخ JSON نسبتاً بزرگی برمی‌گردونن، حجم +// انتقال رو محسوس کم می‌کنه. +func Gzip() gin.HandlerFunc { + return func(c *gin.Context) { + if !strings.Contains(c.GetHeader("Accept-Encoding"), "gzip") { + c.Next() + return + } + // فایل‌های عکسِ آپلودشده از قبل فشرده‌ان (jpeg/png/webp)؛ دوباره gzip + // کردنشون فقط CPU هدر می‌ده بدون فایده‌ی محسوس. + if strings.HasPrefix(c.Request.URL.Path, "/uploads") { + c.Next() + return + } + + gz := gzip.NewWriter(c.Writer) + defer gz.Close() + + c.Header("Content-Encoding", "gzip") + c.Header("Vary", "Accept-Encoding") + c.Writer = &gzipWriter{ResponseWriter: c.Writer, writer: gz} + c.Next() + } +} diff --git a/internal/middleware/metrics_middleware.go b/internal/middleware/metrics_middleware.go new file mode 100644 index 0000000..6ebc832 --- /dev/null +++ b/internal/middleware/metrics_middleware.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + + "dating-app/internal/models" + "dating-app/internal/monitoring" +) + +// Metrics هر درخواست رو (متد، مسیر، کد پاسخ، مدت زمان) توی Store ثبت می‌کنه. +// باید قبل از Recovery ثبت بشه (بیرونی‌تر) تا حتی درخواست‌هایی که پنیک می‌کنن +// و توسط Recovery مهار می‌شن هم توی آمار لحاظ بشن. +func Metrics(store *monitoring.Store) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + + defer func() { + path := c.FullPath() + if path == "" { + path = c.Request.URL.Path + } + + rec := monitoring.RequestRecord{ + Time: start, + Method: c.Request.Method, + Path: path, + StatusCode: c.Writer.Status(), + DurationMs: float64(time.Since(start).Microseconds()) / 1000.0, + ClientIP: c.ClientIP(), + } + if userVal, exists := c.Get(ContextUserKey); exists { + if user, ok := userVal.(*models.User); ok { + id := user.ID + rec.UserID = &id + } + } + store.Record(rec) + }() + + c.Next() + } +} diff --git a/internal/middleware/rate_limit_middleware.go b/internal/middleware/rate_limit_middleware.go new file mode 100644 index 0000000..315452f --- /dev/null +++ b/internal/middleware/rate_limit_middleware.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + + "dating-app/internal/utils" +) + +type bucket struct { + tokens float64 + lastSeenAt time.Time +} + +// RateLimiter یک rate limiter ساده‌ی token-bucket به‌ازای هر IP؛ برای جلوگیری +// از سوءاستفاده از اندپوینت‌های حساس مثل درخواست OTP (که هزینه‌ی پیامک داره +// و هدف مناسبی برای اسپم/brute-force هست). maxTokens یعنی سقف درخواست +// پشت‌سرهم مجاز، refillPerSecond یعنی سرعت شارژ دوباره‌ی سطل. +func RateLimiter(maxTokens, refillPerSecond float64) gin.HandlerFunc { + var mu sync.Mutex + buckets := make(map[string]*bucket) + + // سطل‌های راکد (IP هایی که مدتیه درخواست نزدن) رو دوره‌ای پاک می‌کنه تا + // حافظه با تعداد IP های دیده‌شده بی‌نهایت رشد نکنه. + go func() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for range ticker.C { + cutoff := time.Now().Add(-10 * time.Minute) + mu.Lock() + for k, b := range buckets { + if b.lastSeenAt.Before(cutoff) { + delete(buckets, k) + } + } + mu.Unlock() + } + }() + + return func(c *gin.Context) { + key := c.ClientIP() + now := time.Now() + + mu.Lock() + b, ok := buckets[key] + if !ok { + b = &bucket{tokens: maxTokens, lastSeenAt: now} + buckets[key] = b + } + elapsed := now.Sub(b.lastSeenAt).Seconds() + b.tokens = min(maxTokens, b.tokens+elapsed*refillPerSecond) + b.lastSeenAt = now + + allowed := b.tokens >= 1 + if allowed { + b.tokens-- + } + mu.Unlock() + + if !allowed { + utils.Fail(c, http.StatusTooManyRequests, "درخواست‌های زیادی فرستادی؛ کمی صبر کن و دوباره امتحان کن", nil) + c.Abort() + return + } + c.Next() + } +} diff --git a/internal/middleware/rate_limit_middleware_test.go b/internal/middleware/rate_limit_middleware_test.go new file mode 100644 index 0000000..741213c --- /dev/null +++ b/internal/middleware/rate_limit_middleware_test.go @@ -0,0 +1,74 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func newTestEngine(mw gin.HandlerFunc) *gin.Engine { + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.GET("/x", mw, func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + return engine +} + +func TestRateLimiter_AllowsWithinLimit(t *testing.T) { + engine := newTestEngine(RateLimiter(3, 1)) + + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.RemoteAddr = "1.2.3.4:1111" + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("request %d: expected 200, got %d", i+1, w.Code) + } + } +} + +func TestRateLimiter_BlocksOverLimit(t *testing.T) { + engine := newTestEngine(RateLimiter(2, 0)) // بدون شارژ دوباره، فقط ۲ تا مجازه + + for i := 0; i < 2; i++ { + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.RemoteAddr = "5.6.7.8:2222" + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("request %d: expected 200, got %d", i+1, w.Code) + } + } + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.RemoteAddr = "5.6.7.8:2222" + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("expected 429 on 3rd request, got %d", w.Code) + } +} + +func TestRateLimiter_SeparateBucketsPerIP(t *testing.T) { + engine := newTestEngine(RateLimiter(1, 0)) + + req1 := httptest.NewRequest(http.MethodGet, "/x", nil) + req1.RemoteAddr = "9.9.9.9:1" + w1 := httptest.NewRecorder() + engine.ServeHTTP(w1, req1) + if w1.Code != http.StatusOK { + t.Fatalf("expected 200 for first IP, got %d", w1.Code) + } + + req2 := httptest.NewRequest(http.MethodGet, "/x", nil) + req2.RemoteAddr = "8.8.8.8:1" + w2 := httptest.NewRecorder() + engine.ServeHTTP(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("expected 200 for a different IP (separate bucket), got %d", w2.Code) + } +} diff --git a/internal/middleware/recovery_middleware.go b/internal/middleware/recovery_middleware.go new file mode 100644 index 0000000..72de913 --- /dev/null +++ b/internal/middleware/recovery_middleware.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "log" + "net/http" + "runtime/debug" + + "github.com/gin-gonic/gin" + + "dating-app/internal/utils" +) + +// Recovery از کرش کردن کل پروسه به‌خاطر panic غیرمنتظره‌ی یک هندلر +// (nil pointer، index out of range و...) جلوگیری می‌کنه: استک‌تریس رو لاگ +// می‌کنه و به‌جای متن ساده‌ی پیش‌فرض Gin، همون envelope استاندارد JSON +// پروژه (utils.Fail) رو برمی‌گردونه تا کلاینت‌ها فرمت یکسانی ببینن. +func Recovery() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if r := recover(); r != nil { + log.Printf("[PANIC RECOVERED] %v\n%s", r, debug.Stack()) + if !c.Writer.Written() { + utils.Fail(c, http.StatusInternalServerError, "خطای غیرمنتظره‌ای رخ داد؛ لطفاً دوباره امتحان کن", nil) + } + c.Abort() + } + }() + c.Next() + } +} diff --git a/internal/monitoring/store.go b/internal/monitoring/store.go new file mode 100644 index 0000000..7ac5a22 --- /dev/null +++ b/internal/monitoring/store.go @@ -0,0 +1,134 @@ +// Package monitoring یک ذخیره‌ساز سبک و درون‌حافظه‌ای برای دیدبانی درخواست‌هاست: +// آخرین درخواست‌ها (برای دیباگ لحظه‌ای) + آمار تجمیعی (بار، زمان پاسخ، نرخ خطا). +// عمداً بدون وابستگی بیرونی (بدون Prometheus/Redis) چون در این مقیاس یک حلقه‌ی +// درون‌حافظه‌ای thread-safe کافیه؛ اگه بعداً مقیاس بزرگ‌تر شد، همین Store رو می‌شه +// پشت یک exporter واقعی گذاشت. +package monitoring + +import ( + "sort" + "sync" + "time" +) + +const bufferSize = 1000 + +type RequestRecord struct { + Time time.Time `json:"time"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int `json:"status_code"` + DurationMs float64 `json:"duration_ms"` + ClientIP string `json:"client_ip"` + UserID *uint `json:"user_id,omitempty"` +} + +type Summary struct { + TotalRequests int64 `json:"total_requests"` + TotalErrors int64 `json:"total_errors"` + ErrorRatePct float64 `json:"error_rate_pct"` + AvgLatencyMs float64 `json:"avg_latency_ms"` + P95LatencyMs float64 `json:"p95_latency_ms"` + RequestsLastMin int `json:"requests_last_min"` + StartedAt time.Time `json:"started_at"` + UptimeSeconds float64 `json:"uptime_seconds"` +} + +// Store یک حلقه‌ی ثابت‌اندازه (ring buffer) از آخرین درخواست‌ها به‌همراه +// شمارنده‌های تجمیعیه. نوشتن/خواندن با RWMutex محافظت می‌شه؛ هزینه‌ی هر +// درخواست فقط یک قفل کوتاه و بدون I/O روی دیسکه (سریع، مناسب مسیر داغ). +type Store struct { + mu sync.RWMutex + buf []RequestRecord + next int + count int + total int64 + totalErr int64 + sumLatency float64 + startedAt time.Time +} + +func NewStore() *Store { + return &Store{ + buf: make([]RequestRecord, bufferSize), + startedAt: time.Now(), + } +} + +func (s *Store) Record(rec RequestRecord) { + s.mu.Lock() + defer s.mu.Unlock() + + s.buf[s.next] = rec + s.next = (s.next + 1) % bufferSize + if s.count < bufferSize { + s.count++ + } + s.total++ + s.sumLatency += rec.DurationMs + if rec.StatusCode >= 400 { + s.totalErr++ + } +} + +// Recent آخرین n درخواست رو برمی‌گردونه (جدیدترین اول). +func (s *Store) Recent(n int) []RequestRecord { + s.mu.RLock() + defer s.mu.RUnlock() + + if n > s.count { + n = s.count + } + out := make([]RequestRecord, 0, n) + idx := s.next - 1 + for i := 0; i < n; i++ { + if idx < 0 { + idx = bufferSize - 1 + } + out = append(out, s.buf[idx]) + idx-- + } + return out +} + +func (s *Store) Summary() Summary { + s.mu.RLock() + defer s.mu.RUnlock() + + var errRate, avgLatency float64 + if s.total > 0 { + errRate = float64(s.totalErr) / float64(s.total) * 100 + avgLatency = s.sumLatency / float64(s.total) + } + + cutoff := time.Now().Add(-1 * time.Minute) + lastMin := 0 + latencies := make([]float64, 0, s.count) + for i := 0; i < s.count; i++ { + r := s.buf[i] + if r.Time.After(cutoff) { + lastMin++ + } + latencies = append(latencies, r.DurationMs) + } + sort.Float64s(latencies) + p95 := 0.0 + if len(latencies) > 0 { + idx := int(float64(len(latencies)) * 0.95) + if idx >= len(latencies) { + idx = len(latencies) - 1 + } + p95 = latencies[idx] + } + + return Summary{ + TotalRequests: s.total, + TotalErrors: s.totalErr, + ErrorRatePct: errRate, + AvgLatencyMs: avgLatency, + P95LatencyMs: p95, + RequestsLastMin: lastMin, + StartedAt: s.startedAt, + UptimeSeconds: time.Since(s.startedAt).Seconds(), + } +} diff --git a/internal/monitoring/store_test.go b/internal/monitoring/store_test.go new file mode 100644 index 0000000..0335f32 --- /dev/null +++ b/internal/monitoring/store_test.go @@ -0,0 +1,74 @@ +package monitoring + +import ( + "testing" + "time" +) + +func TestStore_RecordAndSummary(t *testing.T) { + s := NewStore() + + s.Record(RequestRecord{Time: time.Now(), Method: "GET", Path: "/x", StatusCode: 200, DurationMs: 10}) + s.Record(RequestRecord{Time: time.Now(), Method: "GET", Path: "/x", StatusCode: 500, DurationMs: 20}) + + sum := s.Summary() + if sum.TotalRequests != 2 { + t.Fatalf("expected 2 total requests, got %d", sum.TotalRequests) + } + if sum.TotalErrors != 1 { + t.Fatalf("expected 1 error (status>=400), got %d", sum.TotalErrors) + } + if sum.ErrorRatePct != 50 { + t.Fatalf("expected 50%% error rate, got %v", sum.ErrorRatePct) + } + if sum.AvgLatencyMs != 15 { + t.Fatalf("expected avg latency 15ms, got %v", sum.AvgLatencyMs) + } +} + +func TestStore_RecentOrder(t *testing.T) { + s := NewStore() + s.Record(RequestRecord{Time: time.Now(), Path: "/1", StatusCode: 200}) + s.Record(RequestRecord{Time: time.Now(), Path: "/2", StatusCode: 200}) + s.Record(RequestRecord{Time: time.Now(), Path: "/3", StatusCode: 200}) + + recent := s.Recent(10) + if len(recent) != 3 { + t.Fatalf("expected 3 records, got %d", len(recent)) + } + // جدیدترین باید اول باشه + if recent[0].Path != "/3" || recent[1].Path != "/2" || recent[2].Path != "/1" { + t.Fatalf("expected newest-first order, got %+v", recent) + } +} + +func TestStore_RecentLimit(t *testing.T) { + s := NewStore() + for i := 0; i < 5; i++ { + s.Record(RequestRecord{Time: time.Now(), Path: "/x", StatusCode: 200}) + } + if got := len(s.Recent(2)); got != 2 { + t.Fatalf("expected limit of 2, got %d", got) + } + if got := len(s.Recent(100)); got != 5 { + t.Fatalf("expected all 5 when limit exceeds count, got %d", got) + } +} + +func TestStore_RingBufferWraparound(t *testing.T) { + s := NewStore() + // بیشتر از ظرفیت بافر ثبت می‌کنیم تا رفتار wraparound رو تست کنیم + for i := 0; i < bufferSize+50; i++ { + s.Record(RequestRecord{Time: time.Now(), Path: "/x", StatusCode: 200, DurationMs: 1}) + } + + sum := s.Summary() + if sum.TotalRequests != int64(bufferSize+50) { + t.Fatalf("expected total requests to keep counting past buffer size, got %d", sum.TotalRequests) + } + + recent := s.Recent(bufferSize + 100) + if len(recent) != bufferSize { + t.Fatalf("expected Recent to be capped at bufferSize (%d), got %d", bufferSize, len(recent)) + } +} diff --git a/internal/repository/message_repository.go b/internal/repository/message_repository.go index 6e523a3..06b62c6 100644 --- a/internal/repository/message_repository.go +++ b/internal/repository/message_repository.go @@ -33,3 +33,29 @@ func (r *MessageRepository) LastMessage(matchID uint) (*models.Message, error) { } return &msg, nil } + +// LastMessagesByMatchIDs آخرین پیام هر مچ رو، برای چند مچ با هم در یک کوئری +// برمی‌گردونه (به‌جای یک کوئری جدا به‌ازای هر مچ توی لیست گفتگوها). از +// DISTINCT ON پستگرس استفاده می‌کنه که دقیقاً برای همین کار («آخرین ردیف هر +// گروه») ساخته شده. +func (r *MessageRepository) LastMessagesByMatchIDs(matchIDs []uint) (map[uint]models.Message, error) { + result := make(map[uint]models.Message, len(matchIDs)) + if len(matchIDs) == 0 { + return result, nil + } + + var messages []models.Message + err := r.db. + Distinct("ON (match_id) *"). + Where("match_id IN ?", matchIDs). + Order("match_id, created_at desc"). + Find(&messages).Error + if err != nil { + return nil, err + } + + for _, m := range messages { + result[m.MatchID] = m + } + return result, nil +} diff --git a/internal/repository/option_repository.go b/internal/repository/option_repository.go index dcef60b..f864dee 100644 --- a/internal/repository/option_repository.go +++ b/internal/repository/option_repository.go @@ -2,23 +2,41 @@ package repository import ( "errors" + "time" "gorm.io/gorm" + "dating-app/internal/cache" "dating-app/internal/models" ) var ErrCategoryNotFound = errors.New("دسته‌ی موردنظر یافت نشد") +// activeOptionsCacheTTL: گزینه‌های فعال هر دسته و خودِ رکورد دسته فقط از پنل +// ادمین تغییر می‌کنن (که خیلی به‌ندرت اتفاق می‌افته) ولی توی هر مرحله‌ی +// آنبوردینگ/دیسکاوری هر کاربر خونده می‌شن؛ کش کردنشون فشار روی دیتابیس رو +// زیاد کم می‌کنه. +const activeOptionsCacheTTL = 5 * time.Minute + type OptionRepository struct { - db *gorm.DB + db *gorm.DB + activeOptionsByKey *cache.TTLCache[models.CategoryKey, []models.Option] + categoryByKey *cache.TTLCache[models.CategoryKey, models.Category] } func NewOptionRepository(db *gorm.DB) *OptionRepository { - return &OptionRepository{db: db} + return &OptionRepository{ + db: db, + activeOptionsByKey: cache.New[models.CategoryKey, []models.Option](activeOptionsCacheTTL), + categoryByKey: cache.New[models.CategoryKey, models.Category](activeOptionsCacheTTL), + } } func (r *OptionRepository) GetCategoryByKey(key models.CategoryKey) (*models.Category, error) { + if cached, ok := r.categoryByKey.Get(key); ok { + return &cached, nil + } + var cat models.Category err := r.db.Where("key = ?", key).First(&cat).Error if err == gorm.ErrRecordNotFound { @@ -27,14 +45,25 @@ func (r *OptionRepository) GetCategoryByKey(key models.CategoryKey) (*models.Cat if err != nil { return nil, err } + + r.categoryByKey.Set(key, cat) return &cat, nil } func (r *OptionRepository) UpdateCategory(cat *models.Category) error { - return r.db.Save(cat).Error + if err := r.db.Save(cat).Error; err != nil { + return err + } + r.activeOptionsByKey.Delete(cat.Key) + r.categoryByKey.Delete(cat.Key) + return nil } func (r *OptionRepository) ListActiveOptionsByCategoryKey(key models.CategoryKey) ([]models.Option, error) { + if cached, ok := r.activeOptionsByKey.Get(key); ok { + return cached, nil + } + cat, err := r.GetCategoryByKey(key) if err != nil { return nil, err @@ -43,7 +72,12 @@ func (r *OptionRepository) ListActiveOptionsByCategoryKey(key models.CategoryKey err = r.db.Where("category_id = ? AND is_active = ?", cat.ID, true). Order("sort_order asc, id asc"). Find(&options).Error - return options, err + if err != nil { + return nil, err + } + + r.activeOptionsByKey.Set(key, options) + return options, nil } // ListOptionsByCategoryKeyAdmin تمام گزینه‌ها (فعال و غیرفعال) رو برمی‌گردونه - برای پنل ادمین @@ -60,7 +94,11 @@ func (r *OptionRepository) ListOptionsByCategoryKeyAdmin(key models.CategoryKey) } func (r *OptionRepository) CreateOption(opt *models.Option) error { - return r.db.Create(opt).Error + if err := r.db.Create(opt).Error; err != nil { + return err + } + r.activeOptionsByKey.Clear() + return nil } func (r *OptionRepository) FindOptionByID(id uint) (*models.Option, error) { @@ -73,11 +111,19 @@ func (r *OptionRepository) FindOptionByID(id uint) (*models.Option, error) { } func (r *OptionRepository) UpdateOption(opt *models.Option) error { - return r.db.Save(opt).Error + if err := r.db.Save(opt).Error; err != nil { + return err + } + r.activeOptionsByKey.Clear() + return nil } func (r *OptionRepository) DeleteOption(id uint) error { - return r.db.Delete(&models.Option{}, id).Error + if err := r.db.Delete(&models.Option{}, id).Error; err != nil { + return err + } + r.activeOptionsByKey.Clear() + return nil } // FindActiveOptionsByIDsInCategory فقط گزینه‌های فعالی که در این دسته هستند و id شون @@ -128,3 +174,52 @@ func (r *OptionRepository) CountUserSelections(userID, categoryID uint) (int64, Count(&count).Error return count, err } + +// GetSelectionsForUsersInCategory انتخاب‌های چند کاربر رو در یک دسته‌ی مشخص، +// همه با هم در یک کوئری برمی‌گردونه (به‌جای یک کوئری جدا برای هر کاربر). +// برای فید دیسکاوری استفاده می‌شه که تا ۶۰ کاندیدا با هم پردازش می‌شن. +func (r *OptionRepository) GetSelectionsForUsersInCategory(userIDs []uint, categoryID uint) (map[uint][]string, error) { + result := make(map[uint][]string, len(userIDs)) + if len(userIDs) == 0 { + return result, nil + } + + var selections []models.UserOptionSelection + err := r.db.Preload("Option"). + Where("user_id IN ? AND category_id = ?", userIDs, categoryID). + Find(&selections).Error + if err != nil { + return nil, err + } + + for _, sel := range selections { + if sel.Option != nil { + result[sel.UserID] = append(result[sel.UserID], sel.Option.Title) + } + } + return result, nil +} + +// GetUserSelectionsForCategories انتخاب‌های یک کاربر رو در چند دسته با هم، در +// یک کوئری برمی‌گردونه (به‌جای یک کوئری جدا به‌ازای هر دسته). برای صفحه‌ی +// جزئیات پروفایل استفاده می‌شه که هم‌زمان چند دسته (تحصیلات، شغل، علاقه‌مندی و...) +// رو نشون می‌ده. +func (r *OptionRepository) GetUserSelectionsForCategories(userID uint, categoryIDs []uint) (map[uint][]models.UserOptionSelection, error) { + result := make(map[uint][]models.UserOptionSelection) + if len(categoryIDs) == 0 { + return result, nil + } + + var selections []models.UserOptionSelection + err := r.db.Preload("Option"). + Where("user_id = ? AND category_id IN ?", userID, categoryIDs). + Find(&selections).Error + if err != nil { + return nil, err + } + + for _, sel := range selections { + result[sel.CategoryID] = append(result[sel.CategoryID], sel) + } + return result, nil +} diff --git a/internal/repository/question_repository.go b/internal/repository/question_repository.go index 15761d7..c9a3524 100644 --- a/internal/repository/question_repository.go +++ b/internal/repository/question_repository.go @@ -1,23 +1,40 @@ package repository import ( + "time" + "gorm.io/gorm" + "dating-app/internal/cache" "dating-app/internal/models" ) +const activeQuestionsCacheTTL = 5 * time.Minute +const activeQuestionsCacheKey = "active" + type QuestionRepository struct { - db *gorm.DB + db *gorm.DB + activeCache *cache.TTLCache[string, []models.ProfileQuestion] } func NewQuestionRepository(db *gorm.DB) *QuestionRepository { - return &QuestionRepository{db: db} + return &QuestionRepository{ + db: db, + activeCache: cache.New[string, []models.ProfileQuestion](activeQuestionsCacheTTL), + } } func (r *QuestionRepository) ListActive() ([]models.ProfileQuestion, error) { + if cached, ok := r.activeCache.Get(activeQuestionsCacheKey); ok { + return cached, nil + } var qs []models.ProfileQuestion err := r.db.Where("is_active = ?", true).Order("sort_order asc, id asc").Find(&qs).Error - return qs, err + if err != nil { + return nil, err + } + r.activeCache.Set(activeQuestionsCacheKey, qs) + return qs, nil } func (r *QuestionRepository) ListAllAdmin() ([]models.ProfileQuestion, error) { @@ -42,15 +59,27 @@ func (r *QuestionRepository) FindActiveByIDs(ids []uint) ([]models.ProfileQuesti } func (r *QuestionRepository) Create(q *models.ProfileQuestion) error { - return r.db.Create(q).Error + if err := r.db.Create(q).Error; err != nil { + return err + } + r.activeCache.Clear() + return nil } func (r *QuestionRepository) Update(q *models.ProfileQuestion) error { - return r.db.Save(q).Error + if err := r.db.Save(q).Error; err != nil { + return err + } + r.activeCache.Clear() + return nil } func (r *QuestionRepository) Delete(id uint) error { - return r.db.Delete(&models.ProfileQuestion{}, id).Error + if err := r.db.Delete(&models.ProfileQuestion{}, id).Error; err != nil { + return err + } + r.activeCache.Clear() + return nil } // ReplaceUserAnswers پاسخ‌های قبلی کاربر رو با پاسخ‌های جدید جایگزین می‌کنه (تراکنشی) diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index e77aef5..1796ee2 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -34,6 +34,18 @@ func (r *UserRepository) FindByID(id uint) (*models.User, error) { return &user, nil } +// FindByIDs چند کاربر رو با هم در یک کوئری برمی‌گردونه (به‌جای یک FindByID +// جدا به‌ازای هر id)؛ برای لیست‌هایی مثل مچ‌ها که باید اطلاعات طرف مقابل +// چندین مچ رو یک‌جا نشون بدن. +func (r *UserRepository) FindByIDs(ids []uint) ([]models.User, error) { + var users []models.User + if len(ids) == 0 { + return users, nil + } + err := r.db.Where("id IN ?", ids).Find(&users).Error + return users, err +} + // GetOrCreateByPhone کاربر را با شماره‌ی داده‌شده پیدا می‌کند یا در صورت نبود می‌سازد. func (r *UserRepository) GetOrCreateByPhone(phone string) (*models.User, bool, error) { var user models.User diff --git a/internal/router/router.go b/internal/router/router.go index 02b93a8..c5abd00 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -16,6 +16,8 @@ type Handlers struct { Admin *handler.AdminHandler Discovery *handler.DiscoveryHandler Chat *handler.ChatHandler + Monitoring *handler.MonitoringHandler + Backup *handler.BackupHandler } func Setup( @@ -25,6 +27,7 @@ func Setup( userRepo *repository.UserRepository, ) { engine.Use(middleware.CORS()) + engine.Use(middleware.Gzip()) engine.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) @@ -36,10 +39,13 @@ func Setup( api := engine.Group("/api/v1") // -------- احراز هویت (عمومی) -------- + // درخواست OTP هزینه‌ی پیامک داره و هدف مناسبی برای اسپم/brute-force هست؛ + // حداکثر ۵ درخواست، بعد هر ثانیه یک توکن شارژ می‌شه (یعنی حدود ۵ تا در دقیقه‌ی اول و بعدش پیوسته). + otpRateLimit := middleware.RateLimiter(5, 1.0/12.0) auth := api.Group("/auth") { - auth.POST("/request-otp", h.Auth.RequestOTP) - auth.POST("/verify-otp", h.Auth.VerifyOTP) + auth.POST("/request-otp", otpRateLimit, h.Auth.RequestOTP) + auth.POST("/verify-otp", otpRateLimit, h.Auth.VerifyOTP) auth.POST("/token/refresh", h.Auth.RefreshToken) } @@ -106,5 +112,14 @@ func Setup( admin.POST("/profile-questions", h.Admin.CreateQuestion) admin.PATCH("/profile-questions/:id", h.Admin.UpdateQuestion) admin.DELETE("/profile-questions/:id", h.Admin.DeleteQuestion) + + // مانیتورینگ: بار/زمان/ریسپانس درخواست‌های اخیر سرور + admin.GET("/monitoring/summary", h.Monitoring.Summary) + admin.GET("/monitoring/requests", h.Monitoring.RecentRequests) + + // بک‌آپ دیتابیس + admin.GET("/backups", h.Backup.List) + admin.POST("/backups", h.Backup.Create) + admin.GET("/backups/:filename", h.Backup.Download) } } diff --git a/internal/service/backup_service.go b/internal/service/backup_service.go new file mode 100644 index 0000000..4d2b9aa --- /dev/null +++ b/internal/service/backup_service.go @@ -0,0 +1,136 @@ +package service + +import ( + "bytes" + "compress/gzip" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "time" + + "dating-app/internal/config" +) + +var ( + ErrBackupNotFound = errors.New("فایل بک‌آپ یافت نشد") + backupNamePattern = regexp.MustCompile(`^backup_\d{8}_\d{6}\.sql\.gz$`) +) + +type BackupInfo struct { + Filename string `json:"filename"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt time.Time `json:"created_at"` +} + +// BackupService با اجرای pg_dump یک نسخه‌ی پشتیبان فشرده از کل دیتابیس +// می‌سازه. برای گرفتن بک‌آپ نیازی به هیچ ابزار بیرونی/سرویس ابری نیست؛ +// فایل‌ها روی همون دیسک سرور (پوشه‌ی dir) نگه داشته می‌شن. +type BackupService struct { + cfg *config.Config + dir string +} + +func NewBackupService(cfg *config.Config, dir string) *BackupService { + return &BackupService{cfg: cfg, dir: dir} +} + +// Create یک بک‌آپ جدید می‌گیره: خروجی pg_dump به‌صورت stream به gzip داده +// می‌شه تا حافظه و فضای دیسک مصرفی کمتر باشه. +func (s *BackupService) Create() (*BackupInfo, error) { + if err := os.MkdirAll(s.dir, 0o755); err != nil { + return nil, fmt.Errorf("ساخت پوشه‌ی بک‌آپ ناموفق بود: %w", err) + } + + filename := fmt.Sprintf("backup_%s.sql.gz", time.Now().Format("20060102_150405")) + fullPath := filepath.Join(s.dir, filename) + + out, err := os.Create(fullPath) + if err != nil { + return nil, fmt.Errorf("ساخت فایل بک‌آپ ناموفق بود: %w", err) + } + + gz := gzip.NewWriter(out) + + cmd := exec.Command("pg_dump", + "-h", s.cfg.DBHost, + "-p", s.cfg.DBPort, + "-U", s.cfg.DBUser, + "-d", s.cfg.DBName, + "--no-password", + "--format=plain", + ) + cmd.Env = append(os.Environ(), "PGPASSWORD="+s.cfg.DBPassword) + cmd.Stdout = gz + var stderr bytes.Buffer + cmd.Stderr = &stderr + + runErr := cmd.Run() + gzErr := gz.Close() + outErr := out.Close() + + if runErr != nil { + os.Remove(fullPath) + return nil, fmt.Errorf("pg_dump ناموفق بود: %v - %s", runErr, stderr.String()) + } + if gzErr != nil { + os.Remove(fullPath) + return nil, gzErr + } + if outErr != nil { + return nil, outErr + } + + info, err := os.Stat(fullPath) + if err != nil { + return nil, err + } + return &BackupInfo{Filename: filename, SizeBytes: info.Size(), CreatedAt: info.ModTime()}, nil +} + +// List بک‌آپ‌های موجود رو، جدیدترین اول، برمی‌گردونه. +func (s *BackupService) List() ([]BackupInfo, error) { + entries, err := os.ReadDir(s.dir) + if err != nil { + if os.IsNotExist(err) { + return []BackupInfo{}, nil + } + return nil, err + } + + backups := make([]BackupInfo, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !backupNamePattern.MatchString(e.Name()) { + continue + } + info, err := e.Info() + if err != nil { + continue + } + backups = append(backups, BackupInfo{ + Filename: e.Name(), + SizeBytes: info.Size(), + CreatedAt: info.ModTime(), + }) + } + sort.Slice(backups, func(i, j int) bool { return backups[i].CreatedAt.After(backups[j].CreatedAt) }) + return backups, nil +} + +// ResolvePath یک نام فایل بک‌آپ (که مستقیماً از پارامتر URL میاد) رو به مسیر +// کامل تبدیل می‌کنه. چون ورودی از کاربره، صراحتاً باید در برابر path traversal +// (مثلاً "../../etc/passwd") محافظت بشه؛ برای همین فقط اسم‌هایی که دقیقاً با +// الگوی خودمون ساخته شدن (backup_YYYYMMDD_HHMMSS.sql.gz) قبول می‌شن. +func (s *BackupService) ResolvePath(filename string) (string, error) { + if !backupNamePattern.MatchString(filename) { + return "", ErrBackupNotFound + } + full := filepath.Join(s.dir, filename) + if _, err := os.Stat(full); err != nil { + return "", ErrBackupNotFound + } + return full, nil +} diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 51d5eb5..72eb4ed 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -7,6 +7,7 @@ import ( "dating-app/internal/dto" "dating-app/internal/models" "dating-app/internal/repository" + "dating-app/internal/utils" ) var ErrNotMatchMember = errors.New("این گفتگو مال تو نیست") @@ -32,34 +33,65 @@ func NewChatService( } } -// ListMatches همه‌ی مچ‌های کاربر رو همراه با اطلاعات طرف مقابل و آخرین پیام برمی‌گردونه +// ListMatches همه‌ی مچ‌های کاربر رو همراه با اطلاعات طرف مقابل و آخرین پیام +// برمی‌گردونه. قبلاً به‌ازای هر مچ ۳ کوئری جدا (کاربر، عکس‌ها، آخرین پیام) +// می‌زد؛ الان هر سه رو batch می‌کنه تا صرف‌نظر از تعداد مچ‌ها، تعداد کوئری‌ها +// ثابت (۴ تا) بمونه. func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) { matches, err := s.matchRepo.ListByUser(userID) if err != nil { return nil, err } + if len(matches) == 0 { + return []dto.MatchView{}, nil + } + + otherIDs := make([]uint, len(matches)) + matchIDs := make([]uint, len(matches)) + for i, m := range matches { + otherIDs[i] = repository.OtherUserID(&m, userID) + matchIDs[i] = m.ID + } + + otherUsers, err := s.userRepo.FindByIDs(otherIDs) + if err != nil { + return nil, err + } + userByID := make(map[uint]models.User, len(otherUsers)) + for _, u := range otherUsers { + userByID[u.ID] = u + } + + photos, err := s.photoRepo.ListByUserIDs(otherIDs) + if err != nil { + return nil, err + } + photosByUser := make(map[uint][]string, len(otherIDs)) + for _, p := range photos { + photosByUser[p.UserID] = append(photosByUser[p.UserID], p.URL) + } + + lastMessages, err := s.messageRepo.LastMessagesByMatchIDs(matchIDs) + if err != nil { + return nil, err + } views := make([]dto.MatchView, 0, len(matches)) for _, m := range matches { otherID := repository.OtherUserID(&m, userID) - other, err := s.userRepo.FindByID(otherID) - if err != nil { + other, ok := userByID[otherID] + if !ok { continue } - photos, _ := s.photoRepo.ListByUser(otherID) - urls := make([]string, len(photos)) - for i, p := range photos { - urls[i] = p.URL - } view := dto.MatchView{ ID: m.ID, UserID: other.ID, FirstName: other.FirstName, - Photos: urls, + Photos: orEmptyStrings(photosByUser[otherID]), CreatedAt: m.CreatedAt.Format(time.RFC3339), } - if last, err := s.messageRepo.LastMessage(m.ID); err == nil { + if last, ok := lastMessages[m.ID]; ok { text := last.Text at := last.CreatedAt.Format(time.RFC3339) view.LastMessage = &text @@ -110,7 +142,12 @@ func (s *ChatService) SendMessage(userID, matchID uint, text string) (*dto.Messa return nil, err } - msg := &models.Message{MatchID: matchID, SenderID: userID, Text: text} + clean, err := utils.SanitizeMessageText(text) + if err != nil { + return nil, err + } + + msg := &models.Message{MatchID: matchID, SenderID: userID, Text: clean} if err := s.messageRepo.Create(msg); err != nil { return nil, err } diff --git a/internal/service/discovery_service.go b/internal/service/discovery_service.go index 657a57e..f4a42f3 100644 --- a/internal/service/discovery_service.go +++ b/internal/service/discovery_service.go @@ -134,33 +134,37 @@ func (s *DiscoveryService) GetFeed(user *models.User, maxDistanceKM *float64, mi result := make([]dto.DiscoverCandidate, 0, len(withDist)) for _, wd := range withDist { result = append(result, dto.DiscoverCandidate{ - ID: wd.user.ID, - FirstName: wd.user.FirstName, - Age: utils.CalcAge(wd.user.BirthDate), - Photos: photosByUser[wd.user.ID], + ID: wd.user.ID, + FirstName: wd.user.FirstName, + Age: utils.CalcAge(wd.user.BirthDate), + // نبود عکس/علاقه‌مندی نباید توی JSON به‌صورت null دربیاد؛ کلاینت‌ها + // همیشه یک آرایه (حتی خالی) انتظار دارن، نه null. + Photos: orEmptyStrings(photosByUser[wd.user.ID]), DistanceKM: wd.distance, - Interests: interestsByUser[wd.user.ID], + Interests: orEmptyStrings(interestsByUser[wd.user.ID]), }) } return result, nil } +func orEmptyStrings(s []string) []string { + if s == nil { + return []string{} + } + return s +} + +// batchInterests علاقه‌مندی‌های تمام کاندیداهای فید رو در یک کوئری واحد +// می‌گیره (به‌جای یک کوئری جدا به‌ازای هر کاندیدا)؛ روی فید دیسکاوری که تا ۶۰ +// کاربر با هم پردازش می‌شن، این تفاوت بین ۱ کوئری و تا ۶۰ کوئری رو ایجاد می‌کنه. func (s *DiscoveryService) batchInterests(userIDs []uint) map[uint][]string { - result := map[uint][]string{} cat, err := s.optionRepo.GetCategoryByKey(models.CategoryInterest) if err != nil { - return result + return map[uint][]string{} } - for _, id := range userIDs { - sels, err := s.optionRepo.GetUserSelections(id, cat.ID) - if err != nil { - continue - } - for _, sel := range sels { - if sel.Option != nil { - result[id] = append(result[id], sel.Option.Title) - } - } + result, err := s.optionRepo.GetSelectionsForUsersInCategory(userIDs, cat.ID) + if err != nil { + return map[uint][]string{} } return result } @@ -254,12 +258,26 @@ func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint WeightKG: target.WeightKG, } - detail.Education = s.firstSelectionTitle(target.ID, models.CategoryEducationLevel) - detail.FieldOfStudy = s.firstSelectionTitle(target.ID, models.CategoryFieldOfStudy) - detail.LookingFor = s.firstSelectionTitle(target.ID, models.CategoryLookingFor) - detail.Occupations = s.allSelectionTitles(target.ID, models.CategoryOccupation) - detail.Interests = s.allSelectionTitles(target.ID, models.CategoryInterest) - detail.ImportantInRelationship = s.allSelectionTitles(target.ID, models.CategoryImportantInRelationship) + titlesByCategory := s.selectionTitlesByCategory(target.ID, []models.CategoryKey{ + models.CategoryEducationLevel, + models.CategoryFieldOfStudy, + models.CategoryLookingFor, + models.CategoryOccupation, + models.CategoryInterest, + models.CategoryImportantInRelationship, + }) + first := func(titles []string) string { + if len(titles) == 0 { + return "" + } + return titles[0] + } + detail.Education = first(titlesByCategory[models.CategoryEducationLevel]) + detail.FieldOfStudy = first(titlesByCategory[models.CategoryFieldOfStudy]) + detail.LookingFor = first(titlesByCategory[models.CategoryLookingFor]) + detail.Occupations = titlesByCategory[models.CategoryOccupation] + detail.Interests = titlesByCategory[models.CategoryInterest] + detail.ImportantInRelationship = titlesByCategory[models.CategoryImportantInRelationship] if answers, err := s.questionRepo.GetUserAnswers(target.ID); err == nil { for _, a := range answers { @@ -276,28 +294,37 @@ func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint return detail, nil } -func (s *DiscoveryService) allSelectionTitles(userID uint, key models.CategoryKey) []string { - cat, err := s.optionRepo.GetCategoryByKey(key) - if err != nil { - return nil +// selectionTitlesByCategory انتخاب‌های یک کاربر رو در چند دسته با هم، طی یک +// کوئری واحد به دیتابیس می‌گیره (به‌جای یک کوئری جدا به‌ازای هر دسته که قبلاً +// برای صفحه‌ی جزئیات پروفایل تا ۶ رفت‌وبرگشت جدا به دیتابیس می‌زد). +// GetCategoryByKey از کش می‌خونه (چون دسته‌ها به‌ندرت تغییر می‌کنن) پس عملاً +// فقط همین یک کوئری واقعی به دیتابیس زده می‌شه. +func (s *DiscoveryService) selectionTitlesByCategory(userID uint, keys []models.CategoryKey) map[models.CategoryKey][]string { + result := make(map[models.CategoryKey][]string, len(keys)) + + categoryIDToKey := make(map[uint]models.CategoryKey, len(keys)) + categoryIDs := make([]uint, 0, len(keys)) + for _, key := range keys { + cat, err := s.optionRepo.GetCategoryByKey(key) + if err != nil { + continue + } + categoryIDToKey[cat.ID] = key + categoryIDs = append(categoryIDs, cat.ID) } - sels, err := s.optionRepo.GetUserSelections(userID, cat.ID) + + selectionsByCategoryID, err := s.optionRepo.GetUserSelectionsForCategories(userID, categoryIDs) if err != nil { - return nil + return result } - titles := make([]string, 0, len(sels)) - for _, sel := range sels { - if sel.Option != nil { - titles = append(titles, sel.Option.Title) + + for categoryID, sels := range selectionsByCategoryID { + key := categoryIDToKey[categoryID] + for _, sel := range sels { + if sel.Option != nil { + result[key] = append(result[key], sel.Option.Title) + } } } - return titles -} - -func (s *DiscoveryService) firstSelectionTitle(userID uint, key models.CategoryKey) string { - titles := s.allSelectionTitles(userID, key) - if len(titles) == 0 { - return "" - } - return titles[0] + return result } diff --git a/internal/utils/age_test.go b/internal/utils/age_test.go new file mode 100644 index 0000000..79165f4 --- /dev/null +++ b/internal/utils/age_test.go @@ -0,0 +1,26 @@ +package utils + +import ( + "testing" + "time" +) + +func TestCalcAge_NilBirthDate(t *testing.T) { + if age := CalcAge(nil); age != 0 { + t.Fatalf("expected 0 for nil birth date, got %d", age) + } +} + +func TestCalcAge_BirthdayAlreadyPassedThisYear(t *testing.T) { + birth := time.Now().AddDate(-25, -1, 0) // ۲۵ سال و ۱ ماه پیش -> تولد امسال رد شده + if age := CalcAge(&birth); age != 25 { + t.Fatalf("expected age 25, got %d", age) + } +} + +func TestCalcAge_BirthdayNotYetThisYear(t *testing.T) { + birth := time.Now().AddDate(-25, 1, 0) // تولد ۱ ماه دیگه‌ست -> هنوز ۲۴ سالشه + if age := CalcAge(&birth); age != 24 { + t.Fatalf("expected age 24, got %d", age) + } +} diff --git a/internal/utils/geo_test.go b/internal/utils/geo_test.go new file mode 100644 index 0000000..b3e547b --- /dev/null +++ b/internal/utils/geo_test.go @@ -0,0 +1,18 @@ +package utils + +import "testing" + +func TestHaversineKM_SamePoint(t *testing.T) { + d := HaversineKM(35.6892, 51.3890, 35.6892, 51.3890) + if d > 0.001 { + t.Fatalf("expected ~0km for identical points, got %v", d) + } +} + +func TestHaversineKM_TehranToIsfahan(t *testing.T) { + // فاصله‌ی تهران تا اصفهان تقریباً ۳۴۰ کیلومتره (خط مستقیم) + d := HaversineKM(35.6892, 51.3890, 32.6546, 51.6680) + if d < 300 || d > 380 { + t.Fatalf("expected ~340km between Tehran and Isfahan, got %v", d) + } +} diff --git a/internal/utils/sanitize.go b/internal/utils/sanitize.go new file mode 100644 index 0000000..ddd2bc3 --- /dev/null +++ b/internal/utils/sanitize.go @@ -0,0 +1,47 @@ +package utils + +import ( + "errors" + "strings" + "unicode/utf8" +) + +var ( + ErrInvalidMessageEncoding = errors.New("متن پیام معتبر نیست (کدگذاری اشتباه)") + ErrMessageEmpty = errors.New("متن پیام نمی‌تواند خالی باشد") + ErrMessageTooLong = errors.New("متن پیام بیش از حد مجاز طولانی است") +) + +const MaxMessageLength = 2000 + +// SanitizeMessageText متن یک پیام چت رو قبل از ذخیره‌سازی پاک‌سازی/اعتبارسنجی می‌کنه: +// - رد کردن رشته‌هایی که UTF-8 معتبر نیستن (بایت خراب/دستکاری‌شده) +// - حذف کاراکترهای کنترلی (به‌جز \n و \t) که می‌تونن نمایش یا لاگ‌ها رو خراب کنن +// - trim فاصله‌های اضافه و محدود کردن طول +func SanitizeMessageText(raw string) (string, error) { + if !utf8.ValidString(raw) { + return "", ErrInvalidMessageEncoding + } + + var b strings.Builder + b.Grow(len(raw)) + for _, r := range raw { + if r == '\n' || r == '\t' { + b.WriteRune(r) + continue + } + if r < 0x20 || r == 0x7F { + continue + } + b.WriteRune(r) + } + + text := strings.TrimSpace(b.String()) + if text == "" { + return "", ErrMessageEmpty + } + if utf8.RuneCountInString(text) > MaxMessageLength { + return "", ErrMessageTooLong + } + return text, nil +} diff --git a/internal/utils/sanitize_test.go b/internal/utils/sanitize_test.go new file mode 100644 index 0000000..bfe8a8d --- /dev/null +++ b/internal/utils/sanitize_test.go @@ -0,0 +1,71 @@ +package utils + +import ( + "errors" + "strings" + "testing" +) + +func TestSanitizeMessageText_TrimsWhitespace(t *testing.T) { + got, err := SanitizeMessageText(" سلام دوست من ") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "سلام دوست من" { + t.Fatalf("expected trimmed text, got %q", got) + } +} + +func TestSanitizeMessageText_EmptyAfterTrim(t *testing.T) { + _, err := SanitizeMessageText(" ") + if !errors.Is(err, ErrMessageEmpty) { + t.Fatalf("expected ErrMessageEmpty, got %v", err) + } +} + +func TestSanitizeMessageText_StripsControlChars(t *testing.T) { + got, err := SanitizeMessageText("سلام\x00\x07دنیا") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "سلامدنیا" { + t.Fatalf("expected control chars stripped, got %q", got) + } +} + +func TestSanitizeMessageText_KeepsNewlinesAndTabs(t *testing.T) { + got, err := SanitizeMessageText("خط اول\nخط دوم\tبا تب") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "خط اول\nخط دوم\tبا تب" { + t.Fatalf("expected newline/tab preserved, got %q", got) + } +} + +func TestSanitizeMessageText_InvalidUTF8(t *testing.T) { + invalid := string([]byte{0xff, 0xfe, 0xfd}) + _, err := SanitizeMessageText(invalid) + if !errors.Is(err, ErrInvalidMessageEncoding) { + t.Fatalf("expected ErrInvalidMessageEncoding, got %v", err) + } +} + +func TestSanitizeMessageText_TooLong(t *testing.T) { + long := strings.Repeat("a", MaxMessageLength+1) + _, err := SanitizeMessageText(long) + if !errors.Is(err, ErrMessageTooLong) { + t.Fatalf("expected ErrMessageTooLong, got %v", err) + } +} + +func TestSanitizeMessageText_ExactlyMaxLength(t *testing.T) { + exact := strings.Repeat("a", MaxMessageLength) + got, err := SanitizeMessageText(exact) + if err != nil { + t.Fatalf("unexpected error at exact max length: %v", err) + } + if len(got) != MaxMessageLength { + t.Fatalf("expected length %d, got %d", MaxMessageLength, len(got)) + } +}