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

- Recovery middleware (no more crash-on-panic), request metrics/monitoring
  store + admin API, in-memory TTL caching for read-heavy option/question/
  category lookups, gzip + rate-limiting middleware, sanitized chat message
  encoding, and pg_dump-based database backups with an admin API.
- Fix N+1 queries in the discover feed, candidate detail, and match list
  endpoints by batching selection/photo/user/last-message lookups into
  single queries instead of one-per-item loops.
- Tune DB connection pooling and add HTTP server timeouts + graceful
  shutdown so the API degrades gracefully instead of falling over under load.
- Fix a null (vs empty array) JSON response for candidates/matches without
  photos or interests, which crashed the web app's swipe card.
This commit is contained in:
2026-09-19 22:17:12 +03:30
parent 15583009e0
commit 769e76313f
29 changed files with 1377 additions and 72 deletions
+136
View File
@@ -0,0 +1,136 @@
package service
import (
"bytes"
"compress/gzip"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"time"
"dating-app/internal/config"
)
var (
ErrBackupNotFound = errors.New("فایل بک‌آپ یافت نشد")
backupNamePattern = regexp.MustCompile(`^backup_\d{8}_\d{6}\.sql\.gz$`)
)
type BackupInfo struct {
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt time.Time `json:"created_at"`
}
// BackupService با اجرای pg_dump یک نسخه‌ی پشتیبان فشرده از کل دیتابیس
// می‌سازه. برای گرفتن بک‌آپ نیازی به هیچ ابزار بیرونی/سرویس ابری نیست؛
// فایل‌ها روی همون دیسک سرور (پوشه‌ی dir) نگه داشته می‌شن.
type BackupService struct {
cfg *config.Config
dir string
}
func NewBackupService(cfg *config.Config, dir string) *BackupService {
return &BackupService{cfg: cfg, dir: dir}
}
// Create یک بک‌آپ جدید می‌گیره: خروجی pg_dump به‌صورت stream به gzip داده
// می‌شه تا حافظه و فضای دیسک مصرفی کمتر باشه.
func (s *BackupService) Create() (*BackupInfo, error) {
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return nil, fmt.Errorf("ساخت پوشه‌ی بک‌آپ ناموفق بود: %w", err)
}
filename := fmt.Sprintf("backup_%s.sql.gz", time.Now().Format("20060102_150405"))
fullPath := filepath.Join(s.dir, filename)
out, err := os.Create(fullPath)
if err != nil {
return nil, fmt.Errorf("ساخت فایل بک‌آپ ناموفق بود: %w", err)
}
gz := gzip.NewWriter(out)
cmd := exec.Command("pg_dump",
"-h", s.cfg.DBHost,
"-p", s.cfg.DBPort,
"-U", s.cfg.DBUser,
"-d", s.cfg.DBName,
"--no-password",
"--format=plain",
)
cmd.Env = append(os.Environ(), "PGPASSWORD="+s.cfg.DBPassword)
cmd.Stdout = gz
var stderr bytes.Buffer
cmd.Stderr = &stderr
runErr := cmd.Run()
gzErr := gz.Close()
outErr := out.Close()
if runErr != nil {
os.Remove(fullPath)
return nil, fmt.Errorf("pg_dump ناموفق بود: %v - %s", runErr, stderr.String())
}
if gzErr != nil {
os.Remove(fullPath)
return nil, gzErr
}
if outErr != nil {
return nil, outErr
}
info, err := os.Stat(fullPath)
if err != nil {
return nil, err
}
return &BackupInfo{Filename: filename, SizeBytes: info.Size(), CreatedAt: info.ModTime()}, nil
}
// List بک‌آپ‌های موجود رو، جدیدترین اول، برمی‌گردونه.
func (s *BackupService) List() ([]BackupInfo, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
if os.IsNotExist(err) {
return []BackupInfo{}, nil
}
return nil, err
}
backups := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !backupNamePattern.MatchString(e.Name()) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
backups = append(backups, BackupInfo{
Filename: e.Name(),
SizeBytes: info.Size(),
CreatedAt: info.ModTime(),
})
}
sort.Slice(backups, func(i, j int) bool { return backups[i].CreatedAt.After(backups[j].CreatedAt) })
return backups, nil
}
// ResolvePath یک نام فایل بک‌آپ (که مستقیماً از پارامتر URL میاد) رو به مسیر
// کامل تبدیل می‌کنه. چون ورودی از کاربره، صراحتاً باید در برابر path traversal
// (مثلاً "../../etc/passwd") محافظت بشه؛ برای همین فقط اسم‌هایی که دقیقاً با
// الگوی خودمون ساخته شدن (backup_YYYYMMDD_HHMMSS.sql.gz) قبول می‌شن.
func (s *BackupService) ResolvePath(filename string) (string, error) {
if !backupNamePattern.MatchString(filename) {
return "", ErrBackupNotFound
}
full := filepath.Join(s.dir, filename)
if _, err := os.Stat(full); err != nil {
return "", ErrBackupNotFound
}
return full, nil
}
+48 -11
View File
@@ -7,6 +7,7 @@ import (
"dating-app/internal/dto"
"dating-app/internal/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
}
+69 -42
View File
@@ -134,33 +134,37 @@ func (s *DiscoveryService) GetFeed(user *models.User, maxDistanceKM *float64, mi
result := make([]dto.DiscoverCandidate, 0, len(withDist))
for _, wd := range withDist {
result = append(result, dto.DiscoverCandidate{
ID: wd.user.ID,
FirstName: wd.user.FirstName,
Age: utils.CalcAge(wd.user.BirthDate),
Photos: photosByUser[wd.user.ID],
ID: wd.user.ID,
FirstName: wd.user.FirstName,
Age: utils.CalcAge(wd.user.BirthDate),
// نبود عکس/علاقه‌مندی نباید توی JSON به‌صورت null دربیاد؛ کلاینت‌ها
// همیشه یک آرایه (حتی خالی) انتظار دارن، نه null.
Photos: orEmptyStrings(photosByUser[wd.user.ID]),
DistanceKM: wd.distance,
Interests: interestsByUser[wd.user.ID],
Interests: orEmptyStrings(interestsByUser[wd.user.ID]),
})
}
return result, nil
}
func orEmptyStrings(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// batchInterests علاقه‌مندی‌های تمام کاندیداهای فید رو در یک کوئری واحد
// می‌گیره (به‌جای یک کوئری جدا به‌ازای هر کاندیدا)؛ روی فید دیسکاوری که تا ۶۰
// کاربر با هم پردازش می‌شن، این تفاوت بین ۱ کوئری و تا ۶۰ کوئری رو ایجاد می‌کنه.
func (s *DiscoveryService) batchInterests(userIDs []uint) map[uint][]string {
result := map[uint][]string{}
cat, err := s.optionRepo.GetCategoryByKey(models.CategoryInterest)
if err != nil {
return result
return map[uint][]string{}
}
for _, id := range userIDs {
sels, err := s.optionRepo.GetUserSelections(id, cat.ID)
if err != nil {
continue
}
for _, sel := range sels {
if sel.Option != nil {
result[id] = append(result[id], sel.Option.Title)
}
}
result, err := s.optionRepo.GetSelectionsForUsersInCategory(userIDs, cat.ID)
if err != nil {
return map[uint][]string{}
}
return result
}
@@ -254,12 +258,26 @@ func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint
WeightKG: target.WeightKG,
}
detail.Education = s.firstSelectionTitle(target.ID, models.CategoryEducationLevel)
detail.FieldOfStudy = s.firstSelectionTitle(target.ID, models.CategoryFieldOfStudy)
detail.LookingFor = s.firstSelectionTitle(target.ID, models.CategoryLookingFor)
detail.Occupations = s.allSelectionTitles(target.ID, models.CategoryOccupation)
detail.Interests = s.allSelectionTitles(target.ID, models.CategoryInterest)
detail.ImportantInRelationship = s.allSelectionTitles(target.ID, models.CategoryImportantInRelationship)
titlesByCategory := s.selectionTitlesByCategory(target.ID, []models.CategoryKey{
models.CategoryEducationLevel,
models.CategoryFieldOfStudy,
models.CategoryLookingFor,
models.CategoryOccupation,
models.CategoryInterest,
models.CategoryImportantInRelationship,
})
first := func(titles []string) string {
if len(titles) == 0 {
return ""
}
return titles[0]
}
detail.Education = first(titlesByCategory[models.CategoryEducationLevel])
detail.FieldOfStudy = first(titlesByCategory[models.CategoryFieldOfStudy])
detail.LookingFor = first(titlesByCategory[models.CategoryLookingFor])
detail.Occupations = titlesByCategory[models.CategoryOccupation]
detail.Interests = titlesByCategory[models.CategoryInterest]
detail.ImportantInRelationship = titlesByCategory[models.CategoryImportantInRelationship]
if answers, err := s.questionRepo.GetUserAnswers(target.ID); err == nil {
for _, a := range answers {
@@ -276,28 +294,37 @@ func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint
return detail, nil
}
func (s *DiscoveryService) allSelectionTitles(userID uint, key models.CategoryKey) []string {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
return nil
// selectionTitlesByCategory انتخاب‌های یک کاربر رو در چند دسته با هم، طی یک
// کوئری واحد به دیتابیس می‌گیره (به‌جای یک کوئری جدا به‌ازای هر دسته که قبلاً
// برای صفحه‌ی جزئیات پروفایل تا ۶ رفت‌وبرگشت جدا به دیتابیس می‌زد).
// GetCategoryByKey از کش می‌خونه (چون دسته‌ها به‌ندرت تغییر می‌کنن) پس عملاً
// فقط همین یک کوئری واقعی به دیتابیس زده می‌شه.
func (s *DiscoveryService) selectionTitlesByCategory(userID uint, keys []models.CategoryKey) map[models.CategoryKey][]string {
result := make(map[models.CategoryKey][]string, len(keys))
categoryIDToKey := make(map[uint]models.CategoryKey, len(keys))
categoryIDs := make([]uint, 0, len(keys))
for _, key := range keys {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
continue
}
categoryIDToKey[cat.ID] = key
categoryIDs = append(categoryIDs, cat.ID)
}
sels, err := s.optionRepo.GetUserSelections(userID, cat.ID)
selectionsByCategoryID, err := s.optionRepo.GetUserSelectionsForCategories(userID, categoryIDs)
if err != nil {
return nil
return result
}
titles := make([]string, 0, len(sels))
for _, sel := range sels {
if sel.Option != nil {
titles = append(titles, sel.Option.Title)
for categoryID, sels := range selectionsByCategoryID {
key := categoryIDToKey[categoryID]
for _, sel := range sels {
if sel.Option != nil {
result[key] = append(result[key], sel.Option.Title)
}
}
}
return titles
}
func (s *DiscoveryService) firstSelectionTitle(userID uint, key models.CategoryKey) string {
titles := s.allSelectionTitles(userID, key)
if len(titles) == 0 {
return ""
}
return titles[0]
return result
}