Add production hardening: monitoring, backups, caching, and high-traffic resilience

- Recovery middleware (no more crash-on-panic), request metrics/monitoring
  store + admin API, in-memory TTL caching for read-heavy option/question/
  category lookups, gzip + rate-limiting middleware, sanitized chat message
  encoding, and pg_dump-based database backups with an admin API.
- Fix N+1 queries in the discover feed, candidate detail, and match list
  endpoints by batching selection/photo/user/last-message lookups into
  single queries instead of one-per-item loops.
- Tune DB connection pooling and add HTTP server timeouts + graceful
  shutdown so the API degrades gracefully instead of falling over under load.
- Fix a null (vs empty array) JSON response for candidates/matches without
  photos or interests, which crashed the web app's swipe card.
This commit is contained in:
2026-09-19 22:17:12 +03:30
parent 15583009e0
commit 769e76313f
29 changed files with 1377 additions and 72 deletions
+5
View File
@@ -7,6 +7,11 @@ DB_USER=postgres
DB_PASSWORD=postgres DB_PASSWORD=postgres
DB_NAME=dating_app DB_NAME=dating_app
DB_SSLMODE=disable 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_ACCESS_SECRET=test-access-secret
JWT_REFRESH_SECRET=test-refresh-secret JWT_REFRESH_SECRET=test-refresh-secret
+3
View File
@@ -11,6 +11,9 @@
# فایل‌های آپلودشده‌ی کاربران (نباید توی گیت باشن) # فایل‌های آپلودشده‌ی کاربران (نباید توی گیت باشن)
/uploads/ /uploads/
# بک‌آپ‌های دیتابیس (نباید توی گیت باشن؛ حجیم و شامل داده‌ی کاربرانه)
/backups/
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/
+53 -2
View File
@@ -1,13 +1,22 @@
package main package main
import ( import (
"context"
"errors"
"log" "log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"dating-app/internal/config" "dating-app/internal/config"
"dating-app/internal/database" "dating-app/internal/database"
"dating-app/internal/handler" "dating-app/internal/handler"
"dating-app/internal/middleware"
"dating-app/internal/monitoring"
"dating-app/internal/repository" "dating-app/internal/repository"
"dating-app/internal/router" "dating-app/internal/router"
"dating-app/internal/service" "dating-app/internal/service"
@@ -65,6 +74,10 @@ func main() {
profileService := service.NewProfileService(photoRepo, userRepo, photoStorage) profileService := service.NewProfileService(photoRepo, userRepo, photoStorage)
discoveryService := service.NewDiscoveryService(userRepo, photoRepo, swipeRepo, matchRepo, optionRepo, questionRepo) discoveryService := service.NewDiscoveryService(userRepo, photoRepo, swipeRepo, matchRepo, optionRepo, questionRepo)
chatService := service.NewChatService(matchRepo, messageRepo, userRepo, photoRepo) chatService := service.NewChatService(matchRepo, messageRepo, userRepo, photoRepo)
backupService := service.NewBackupService(cfg, "./backups")
// -------- مانیتورینگ --------
metricsStore := monitoring.NewStore()
// -------- Handlers -------- // -------- Handlers --------
handlers := router.Handlers{ handlers := router.Handlers{
@@ -74,16 +87,54 @@ func main() {
Admin: handler.NewAdminHandler(adminService, optionRepo, questionRepo, photoRepo, onboardingService), Admin: handler.NewAdminHandler(adminService, optionRepo, questionRepo, photoRepo, onboardingService),
Discovery: handler.NewDiscoveryHandler(discoveryService), Discovery: handler.NewDiscoveryHandler(discoveryService),
Chat: handler.NewChatHandler(chatService), Chat: handler.NewChatHandler(chatService),
Monitoring: handler.NewMonitoringHandler(metricsStore),
Backup: handler.NewBackupHandler(backupService),
} }
if cfg.AppEnv == "production" { if cfg.AppEnv == "production" {
gin.SetMode(gin.ReleaseMode) 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) router.Setup(engine, handlers, jwtManager, userRepo)
// یک 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) log.Printf("سرور روی پورت %s در حال اجراست (env=%s)", cfg.AppPort, cfg.AppEnv)
if err := engine.Run(":" + cfg.AppPort); err != nil { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("اجرای سرور ناموفق بود: %v", err) 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("سرور با موفقیت خاموش شد")
} }
+55
View File
@@ -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])
}
+62
View File
@@ -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")
}
}
+12
View File
@@ -20,6 +20,13 @@ type Config struct {
DBName string DBName string
DBSSLMode string DBSSLMode string
// تنظیمات connection pool؛ زیر بار بالا این‌ها از باز شدن بی‌رویه‌ی اتصال
// (که خود Postgres رو زمین می‌زنه) و از نشتی اتصالات راکد جلوگیری می‌کنن.
DBMaxOpenConns int
DBMaxIdleConns int
DBConnMaxLifetimeMins int
DBConnMaxIdleTimeMins int
JWTAccessSecret string JWTAccessSecret string
JWTRefreshSecret string JWTRefreshSecret string
JWTAccessTTL time.Duration JWTAccessTTL time.Duration
@@ -49,6 +56,11 @@ func Load() *Config {
DBName: getEnv("DB_NAME", "dating_app"), DBName: getEnv("DB_NAME", "dating_app"),
DBSSLMode: getEnv("DB_SSLMODE", "disable"), 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"), JWTAccessSecret: getEnv("JWT_ACCESS_SECRET", "change-me-access-secret"),
JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", "change-me-refresh-secret"), JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", "change-me-refresh-secret"),
JWTAccessTTL: time.Duration(getEnvInt("JWT_ACCESS_TTL_MINUTES", 60*24)) * time.Minute, JWTAccessTTL: time.Duration(getEnvInt("JWT_ACCESS_TTL_MINUTES", 60*24)) * time.Minute,
+15
View File
@@ -2,6 +2,7 @@ package database
import ( import (
"log" "log"
"time"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
@@ -24,6 +25,20 @@ func Connect(cfg *config.Config) (*gorm.DB, error) {
if err != nil { if err != nil {
return nil, err 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 return db, nil
} }
+49
View File
@@ -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)
}
+4
View File
@@ -81,6 +81,10 @@ func (h *ChatHandler) handleError(c *gin.Context, err error) {
switch { switch {
case errors.Is(err, service.ErrNotMatchMember): case errors.Is(err, service.ErrNotMatchMember):
utils.Fail(c, http.StatusForbidden, err.Error(), nil) 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: default:
utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil) utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil)
} }
+35
View File
@@ -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))
}
+49
View File
@@ -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()
}
}
+44
View File
@@ -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()
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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()
}
}
+134
View File
@@ -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(),
}
}
+74
View File
@@ -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))
}
}
+26
View File
@@ -33,3 +33,29 @@ func (r *MessageRepository) LastMessage(matchID uint) (*models.Message, error) {
} }
return &msg, nil 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
}
+101 -6
View File
@@ -2,23 +2,41 @@ package repository
import ( import (
"errors" "errors"
"time"
"gorm.io/gorm" "gorm.io/gorm"
"dating-app/internal/cache"
"dating-app/internal/models" "dating-app/internal/models"
) )
var ErrCategoryNotFound = errors.New("دسته‌ی موردنظر یافت نشد") var ErrCategoryNotFound = errors.New("دسته‌ی موردنظر یافت نشد")
// activeOptionsCacheTTL: گزینه‌های فعال هر دسته و خودِ رکورد دسته فقط از پنل
// ادمین تغییر می‌کنن (که خیلی به‌ندرت اتفاق می‌افته) ولی توی هر مرحله‌ی
// آنبوردینگ/دیسکاوری هر کاربر خونده می‌شن؛ کش کردنشون فشار روی دیتابیس رو
// زیاد کم می‌کنه.
const activeOptionsCacheTTL = 5 * time.Minute
type OptionRepository struct { 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 { 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) { 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 var cat models.Category
err := r.db.Where("key = ?", key).First(&cat).Error err := r.db.Where("key = ?", key).First(&cat).Error
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
@@ -27,14 +45,25 @@ func (r *OptionRepository) GetCategoryByKey(key models.CategoryKey) (*models.Cat
if err != nil { if err != nil {
return nil, err return nil, err
} }
r.categoryByKey.Set(key, cat)
return &cat, nil return &cat, nil
} }
func (r *OptionRepository) UpdateCategory(cat *models.Category) error { 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) { 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) cat, err := r.GetCategoryByKey(key)
if err != nil { if err != nil {
return nil, err 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). err = r.db.Where("category_id = ? AND is_active = ?", cat.ID, true).
Order("sort_order asc, id asc"). Order("sort_order asc, id asc").
Find(&options).Error Find(&options).Error
return options, err if err != nil {
return nil, err
}
r.activeOptionsByKey.Set(key, options)
return options, nil
} }
// ListOptionsByCategoryKeyAdmin تمام گزینه‌ها (فعال و غیرفعال) رو برمی‌گردونه - برای پنل ادمین // ListOptionsByCategoryKeyAdmin تمام گزینه‌ها (فعال و غیرفعال) رو برمی‌گردونه - برای پنل ادمین
@@ -60,7 +94,11 @@ func (r *OptionRepository) ListOptionsByCategoryKeyAdmin(key models.CategoryKey)
} }
func (r *OptionRepository) CreateOption(opt *models.Option) error { 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) { 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 { 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 { 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 شون // FindActiveOptionsByIDsInCategory فقط گزینه‌های فعالی که در این دسته هستند و id شون
@@ -128,3 +174,52 @@ func (r *OptionRepository) CountUserSelections(userID, categoryID uint) (int64,
Count(&count).Error Count(&count).Error
return count, err 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
}
+34 -5
View File
@@ -1,23 +1,40 @@
package repository package repository
import ( import (
"time"
"gorm.io/gorm" "gorm.io/gorm"
"dating-app/internal/cache"
"dating-app/internal/models" "dating-app/internal/models"
) )
const activeQuestionsCacheTTL = 5 * time.Minute
const activeQuestionsCacheKey = "active"
type QuestionRepository struct { type QuestionRepository struct {
db *gorm.DB db *gorm.DB
activeCache *cache.TTLCache[string, []models.ProfileQuestion]
} }
func NewQuestionRepository(db *gorm.DB) *QuestionRepository { 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) { func (r *QuestionRepository) ListActive() ([]models.ProfileQuestion, error) {
if cached, ok := r.activeCache.Get(activeQuestionsCacheKey); ok {
return cached, nil
}
var qs []models.ProfileQuestion var qs []models.ProfileQuestion
err := r.db.Where("is_active = ?", true).Order("sort_order asc, id asc").Find(&qs).Error 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) { 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 { 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 { 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 { 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 پاسخ‌های قبلی کاربر رو با پاسخ‌های جدید جایگزین می‌کنه (تراکنشی) // ReplaceUserAnswers پاسخ‌های قبلی کاربر رو با پاسخ‌های جدید جایگزین می‌کنه (تراکنشی)
+12
View File
@@ -34,6 +34,18 @@ func (r *UserRepository) FindByID(id uint) (*models.User, error) {
return &user, nil 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 کاربر را با شماره‌ی داده‌شده پیدا می‌کند یا در صورت نبود می‌سازد. // GetOrCreateByPhone کاربر را با شماره‌ی داده‌شده پیدا می‌کند یا در صورت نبود می‌سازد.
func (r *UserRepository) GetOrCreateByPhone(phone string) (*models.User, bool, error) { func (r *UserRepository) GetOrCreateByPhone(phone string) (*models.User, bool, error) {
var user models.User var user models.User
+17 -2
View File
@@ -16,6 +16,8 @@ type Handlers struct {
Admin *handler.AdminHandler Admin *handler.AdminHandler
Discovery *handler.DiscoveryHandler Discovery *handler.DiscoveryHandler
Chat *handler.ChatHandler Chat *handler.ChatHandler
Monitoring *handler.MonitoringHandler
Backup *handler.BackupHandler
} }
func Setup( func Setup(
@@ -25,6 +27,7 @@ func Setup(
userRepo *repository.UserRepository, userRepo *repository.UserRepository,
) { ) {
engine.Use(middleware.CORS()) engine.Use(middleware.CORS())
engine.Use(middleware.Gzip())
engine.GET("/health", func(c *gin.Context) { engine.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"}) c.JSON(200, gin.H{"status": "ok"})
@@ -36,10 +39,13 @@ func Setup(
api := engine.Group("/api/v1") api := engine.Group("/api/v1")
// -------- احراز هویت (عمومی) -------- // -------- احراز هویت (عمومی) --------
// درخواست OTP هزینه‌ی پیامک داره و هدف مناسبی برای اسپم/brute-force هست؛
// حداکثر ۵ درخواست، بعد هر ثانیه یک توکن شارژ می‌شه (یعنی حدود ۵ تا در دقیقه‌ی اول و بعدش پیوسته).
otpRateLimit := middleware.RateLimiter(5, 1.0/12.0)
auth := api.Group("/auth") auth := api.Group("/auth")
{ {
auth.POST("/request-otp", h.Auth.RequestOTP) auth.POST("/request-otp", otpRateLimit, h.Auth.RequestOTP)
auth.POST("/verify-otp", h.Auth.VerifyOTP) auth.POST("/verify-otp", otpRateLimit, h.Auth.VerifyOTP)
auth.POST("/token/refresh", h.Auth.RefreshToken) auth.POST("/token/refresh", h.Auth.RefreshToken)
} }
@@ -106,5 +112,14 @@ func Setup(
admin.POST("/profile-questions", h.Admin.CreateQuestion) admin.POST("/profile-questions", h.Admin.CreateQuestion)
admin.PATCH("/profile-questions/:id", h.Admin.UpdateQuestion) admin.PATCH("/profile-questions/:id", h.Admin.UpdateQuestion)
admin.DELETE("/profile-questions/:id", h.Admin.DeleteQuestion) 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)
} }
} }
+136
View File
@@ -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
}
+48 -11
View File
@@ -7,6 +7,7 @@ import (
"dating-app/internal/dto" "dating-app/internal/dto"
"dating-app/internal/models" "dating-app/internal/models"
"dating-app/internal/repository" "dating-app/internal/repository"
"dating-app/internal/utils"
) )
var ErrNotMatchMember = errors.New("این گفتگو مال تو نیست") var ErrNotMatchMember = errors.New("این گفتگو مال تو نیست")
@@ -32,34 +33,65 @@ func NewChatService(
} }
} }
// ListMatches همه‌ی مچ‌های کاربر رو همراه با اطلاعات طرف مقابل و آخرین پیام برمی‌گردونه // ListMatches همه‌ی مچ‌های کاربر رو همراه با اطلاعات طرف مقابل و آخرین پیام
// برمی‌گردونه. قبلاً به‌ازای هر مچ ۳ کوئری جدا (کاربر، عکس‌ها، آخرین پیام)
// می‌زد؛ الان هر سه رو batch می‌کنه تا صرف‌نظر از تعداد مچ‌ها، تعداد کوئری‌ها
// ثابت (۴ تا) بمونه.
func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) { func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) {
matches, err := s.matchRepo.ListByUser(userID) matches, err := s.matchRepo.ListByUser(userID)
if err != nil { if err != nil {
return nil, err 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)) views := make([]dto.MatchView, 0, len(matches))
for _, m := range matches { for _, m := range matches {
otherID := repository.OtherUserID(&m, userID) otherID := repository.OtherUserID(&m, userID)
other, err := s.userRepo.FindByID(otherID) other, ok := userByID[otherID]
if err != nil { if !ok {
continue continue
} }
photos, _ := s.photoRepo.ListByUser(otherID)
urls := make([]string, len(photos))
for i, p := range photos {
urls[i] = p.URL
}
view := dto.MatchView{ view := dto.MatchView{
ID: m.ID, ID: m.ID,
UserID: other.ID, UserID: other.ID,
FirstName: other.FirstName, FirstName: other.FirstName,
Photos: urls, Photos: orEmptyStrings(photosByUser[otherID]),
CreatedAt: m.CreatedAt.Format(time.RFC3339), 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 text := last.Text
at := last.CreatedAt.Format(time.RFC3339) at := last.CreatedAt.Format(time.RFC3339)
view.LastMessage = &text view.LastMessage = &text
@@ -110,7 +142,12 @@ func (s *ChatService) SendMessage(userID, matchID uint, text string) (*dto.Messa
return nil, err 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 { if err := s.messageRepo.Create(msg); err != nil {
return nil, err return nil, err
} }
+64 -37
View File
@@ -137,30 +137,34 @@ func (s *DiscoveryService) GetFeed(user *models.User, maxDistanceKM *float64, mi
ID: wd.user.ID, ID: wd.user.ID,
FirstName: wd.user.FirstName, FirstName: wd.user.FirstName,
Age: utils.CalcAge(wd.user.BirthDate), Age: utils.CalcAge(wd.user.BirthDate),
Photos: photosByUser[wd.user.ID], // نبود عکس/علاقه‌مندی نباید توی JSON به‌صورت null دربیاد؛ کلاینت‌ها
// همیشه یک آرایه (حتی خالی) انتظار دارن، نه null.
Photos: orEmptyStrings(photosByUser[wd.user.ID]),
DistanceKM: wd.distance, DistanceKM: wd.distance,
Interests: interestsByUser[wd.user.ID], Interests: orEmptyStrings(interestsByUser[wd.user.ID]),
}) })
} }
return result, nil 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 { func (s *DiscoveryService) batchInterests(userIDs []uint) map[uint][]string {
result := map[uint][]string{}
cat, err := s.optionRepo.GetCategoryByKey(models.CategoryInterest) cat, err := s.optionRepo.GetCategoryByKey(models.CategoryInterest)
if err != nil { if err != nil {
return result return map[uint][]string{}
} }
for _, id := range userIDs { result, err := s.optionRepo.GetSelectionsForUsersInCategory(userIDs, cat.ID)
sels, err := s.optionRepo.GetUserSelections(id, cat.ID)
if err != nil { if err != nil {
continue return map[uint][]string{}
}
for _, sel := range sels {
if sel.Option != nil {
result[id] = append(result[id], sel.Option.Title)
}
}
} }
return result return result
} }
@@ -254,12 +258,26 @@ func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint
WeightKG: target.WeightKG, WeightKG: target.WeightKG,
} }
detail.Education = s.firstSelectionTitle(target.ID, models.CategoryEducationLevel) titlesByCategory := s.selectionTitlesByCategory(target.ID, []models.CategoryKey{
detail.FieldOfStudy = s.firstSelectionTitle(target.ID, models.CategoryFieldOfStudy) models.CategoryEducationLevel,
detail.LookingFor = s.firstSelectionTitle(target.ID, models.CategoryLookingFor) models.CategoryFieldOfStudy,
detail.Occupations = s.allSelectionTitles(target.ID, models.CategoryOccupation) models.CategoryLookingFor,
detail.Interests = s.allSelectionTitles(target.ID, models.CategoryInterest) models.CategoryOccupation,
detail.ImportantInRelationship = s.allSelectionTitles(target.ID, models.CategoryImportantInRelationship) 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 { if answers, err := s.questionRepo.GetUserAnswers(target.ID); err == nil {
for _, a := range answers { for _, a := range answers {
@@ -276,28 +294,37 @@ func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint
return detail, nil return detail, nil
} }
func (s *DiscoveryService) allSelectionTitles(userID uint, key models.CategoryKey) []string { // 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) cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil { if err != nil {
return nil continue
} }
sels, err := s.optionRepo.GetUserSelections(userID, cat.ID) categoryIDToKey[cat.ID] = key
if err != nil { categoryIDs = append(categoryIDs, cat.ID)
return nil
}
titles := make([]string, 0, len(sels))
for _, sel := range sels {
if sel.Option != nil {
titles = append(titles, sel.Option.Title)
}
}
return titles
} }
func (s *DiscoveryService) firstSelectionTitle(userID uint, key models.CategoryKey) string { selectionsByCategoryID, err := s.optionRepo.GetUserSelectionsForCategories(userID, categoryIDs)
titles := s.allSelectionTitles(userID, key) if err != nil {
if len(titles) == 0 { return result
return ""
} }
return titles[0]
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 result
} }
+26
View File
@@ -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)
}
}
+18
View File
@@ -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)
}
}
+47
View File
@@ -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
}
+71
View File
@@ -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))
}
}