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:
@@ -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
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
# فایلهای آپلودشدهی کاربران (نباید توی گیت باشن)
|
||||
/uploads/
|
||||
|
||||
# بکآپهای دیتابیس (نباید توی گیت باشن؛ حجیم و شامل دادهی کاربرانه)
|
||||
/backups/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
+53
-2
@@ -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)
|
||||
|
||||
// یک 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 := engine.Run(":" + cfg.AppPort); err != nil {
|
||||
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("سرور با موفقیت خاموش شد")
|
||||
}
|
||||
|
||||
Vendored
+55
@@ -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])
|
||||
}
|
||||
Vendored
+62
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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 پاسخهای قبلی کاربر رو با پاسخهای جدید جایگزین میکنه (تراکنشی)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -137,30 +137,34 @@ func (s *DiscoveryService) GetFeed(user *models.User, maxDistanceKM *float64, mi
|
||||
ID: wd.user.ID,
|
||||
FirstName: wd.user.FirstName,
|
||||
Age: utils.CalcAge(wd.user.BirthDate),
|
||||
Photos: photosByUser[wd.user.ID],
|
||||
// نبود عکس/علاقهمندی نباید توی 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)
|
||||
result, err := s.optionRepo.GetSelectionsForUsersInCategory(userIDs, cat.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, sel := range sels {
|
||||
if sel.Option != nil {
|
||||
result[id] = append(result[id], sel.Option.Title)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
// 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 {
|
||||
return nil
|
||||
continue
|
||||
}
|
||||
sels, err := s.optionRepo.GetUserSelections(userID, cat.ID)
|
||||
categoryIDToKey[cat.ID] = key
|
||||
categoryIDs = append(categoryIDs, cat.ID)
|
||||
}
|
||||
|
||||
selectionsByCategoryID, err := s.optionRepo.GetUserSelectionsForCategories(userID, categoryIDs)
|
||||
if err != nil {
|
||||
return nil
|
||||
return result
|
||||
}
|
||||
titles := make([]string, 0, len(sels))
|
||||
|
||||
for categoryID, sels := range selectionsByCategoryID {
|
||||
key := categoryIDToKey[categoryID]
|
||||
for _, sel := range sels {
|
||||
if sel.Option != nil {
|
||||
titles = append(titles, sel.Option.Title)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user