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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user