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_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
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
# فایلهای آپلودشدهی کاربران (نباید توی گیت باشن)
|
# فایلهای آپلودشدهی کاربران (نباید توی گیت باشن)
|
||||||
/uploads/
|
/uploads/
|
||||||
|
|
||||||
|
# بکآپهای دیتابیس (نباید توی گیت باشن؛ حجیم و شامل دادهی کاربرانه)
|
||||||
|
/backups/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
+53
-2
@@ -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("سرور با موفقیت خاموش شد")
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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 (
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 پاسخهای قبلی کاربر رو با پاسخهای جدید جایگزین میکنه (تراکنشی)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
categoryIDs = append(categoryIDs, cat.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionsByCategoryID, err := s.optionRepo.GetUserSelectionsForCategories(userID, categoryIDs)
|
||||||
if err != nil {
|
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 {
|
for _, sel := range sels {
|
||||||
if sel.Option != nil {
|
if sel.Option != nil {
|
||||||
titles = append(titles, sel.Option.Title)
|
result[key] = append(result[key], sel.Option.Title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return titles
|
}
|
||||||
}
|
return result
|
||||||
|
|
||||||
func (s *DiscoveryService) firstSelectionTitle(userID uint, key models.CategoryKey) string {
|
|
||||||
titles := s.allSelectionTitles(userID, key)
|
|
||||||
if len(titles) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return titles[0]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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