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
+102 -7
View File
@@ -2,23 +2,41 @@ package repository
import (
"errors"
"time"
"gorm.io/gorm"
"dating-app/internal/cache"
"dating-app/internal/models"
)
var ErrCategoryNotFound = errors.New("دسته‌ی موردنظر یافت نشد")
// activeOptionsCacheTTL: گزینه‌های فعال هر دسته و خودِ رکورد دسته فقط از پنل
// ادمین تغییر می‌کنن (که خیلی به‌ندرت اتفاق می‌افته) ولی توی هر مرحله‌ی
// آنبوردینگ/دیسکاوری هر کاربر خونده می‌شن؛ کش کردنشون فشار روی دیتابیس رو
// زیاد کم می‌کنه.
const activeOptionsCacheTTL = 5 * time.Minute
type OptionRepository struct {
db *gorm.DB
db *gorm.DB
activeOptionsByKey *cache.TTLCache[models.CategoryKey, []models.Option]
categoryByKey *cache.TTLCache[models.CategoryKey, models.Category]
}
func NewOptionRepository(db *gorm.DB) *OptionRepository {
return &OptionRepository{db: db}
return &OptionRepository{
db: db,
activeOptionsByKey: cache.New[models.CategoryKey, []models.Option](activeOptionsCacheTTL),
categoryByKey: cache.New[models.CategoryKey, models.Category](activeOptionsCacheTTL),
}
}
func (r *OptionRepository) GetCategoryByKey(key models.CategoryKey) (*models.Category, error) {
if cached, ok := r.categoryByKey.Get(key); ok {
return &cached, nil
}
var cat models.Category
err := r.db.Where("key = ?", key).First(&cat).Error
if err == gorm.ErrRecordNotFound {
@@ -27,14 +45,25 @@ func (r *OptionRepository) GetCategoryByKey(key models.CategoryKey) (*models.Cat
if err != nil {
return nil, err
}
r.categoryByKey.Set(key, cat)
return &cat, nil
}
func (r *OptionRepository) UpdateCategory(cat *models.Category) error {
return r.db.Save(cat).Error
if err := r.db.Save(cat).Error; err != nil {
return err
}
r.activeOptionsByKey.Delete(cat.Key)
r.categoryByKey.Delete(cat.Key)
return nil
}
func (r *OptionRepository) ListActiveOptionsByCategoryKey(key models.CategoryKey) ([]models.Option, error) {
if cached, ok := r.activeOptionsByKey.Get(key); ok {
return cached, nil
}
cat, err := r.GetCategoryByKey(key)
if err != nil {
return nil, err
@@ -43,7 +72,12 @@ func (r *OptionRepository) ListActiveOptionsByCategoryKey(key models.CategoryKey
err = r.db.Where("category_id = ? AND is_active = ?", cat.ID, true).
Order("sort_order asc, id asc").
Find(&options).Error
return options, err
if err != nil {
return nil, err
}
r.activeOptionsByKey.Set(key, options)
return options, nil
}
// ListOptionsByCategoryKeyAdmin تمام گزینه‌ها (فعال و غیرفعال) رو برمی‌گردونه - برای پنل ادمین
@@ -60,7 +94,11 @@ func (r *OptionRepository) ListOptionsByCategoryKeyAdmin(key models.CategoryKey)
}
func (r *OptionRepository) CreateOption(opt *models.Option) error {
return r.db.Create(opt).Error
if err := r.db.Create(opt).Error; err != nil {
return err
}
r.activeOptionsByKey.Clear()
return nil
}
func (r *OptionRepository) FindOptionByID(id uint) (*models.Option, error) {
@@ -73,11 +111,19 @@ func (r *OptionRepository) FindOptionByID(id uint) (*models.Option, error) {
}
func (r *OptionRepository) UpdateOption(opt *models.Option) error {
return r.db.Save(opt).Error
if err := r.db.Save(opt).Error; err != nil {
return err
}
r.activeOptionsByKey.Clear()
return nil
}
func (r *OptionRepository) DeleteOption(id uint) error {
return r.db.Delete(&models.Option{}, id).Error
if err := r.db.Delete(&models.Option{}, id).Error; err != nil {
return err
}
r.activeOptionsByKey.Clear()
return nil
}
// FindActiveOptionsByIDsInCategory فقط گزینه‌های فعالی که در این دسته هستند و id شون
@@ -128,3 +174,52 @@ func (r *OptionRepository) CountUserSelections(userID, categoryID uint) (int64,
Count(&count).Error
return count, err
}
// GetSelectionsForUsersInCategory انتخاب‌های چند کاربر رو در یک دسته‌ی مشخص،
// همه با هم در یک کوئری برمی‌گردونه (به‌جای یک کوئری جدا برای هر کاربر).
// برای فید دیسکاوری استفاده می‌شه که تا ۶۰ کاندیدا با هم پردازش می‌شن.
func (r *OptionRepository) GetSelectionsForUsersInCategory(userIDs []uint, categoryID uint) (map[uint][]string, error) {
result := make(map[uint][]string, len(userIDs))
if len(userIDs) == 0 {
return result, nil
}
var selections []models.UserOptionSelection
err := r.db.Preload("Option").
Where("user_id IN ? AND category_id = ?", userIDs, categoryID).
Find(&selections).Error
if err != nil {
return nil, err
}
for _, sel := range selections {
if sel.Option != nil {
result[sel.UserID] = append(result[sel.UserID], sel.Option.Title)
}
}
return result, nil
}
// GetUserSelectionsForCategories انتخاب‌های یک کاربر رو در چند دسته با هم، در
// یک کوئری برمی‌گردونه (به‌جای یک کوئری جدا به‌ازای هر دسته). برای صفحه‌ی
// جزئیات پروفایل استفاده می‌شه که هم‌زمان چند دسته (تحصیلات، شغل، علاقه‌مندی و...)
// رو نشون می‌ده.
func (r *OptionRepository) GetUserSelectionsForCategories(userID uint, categoryIDs []uint) (map[uint][]models.UserOptionSelection, error) {
result := make(map[uint][]models.UserOptionSelection)
if len(categoryIDs) == 0 {
return result, nil
}
var selections []models.UserOptionSelection
err := r.db.Preload("Option").
Where("user_id = ? AND category_id IN ?", userID, categoryIDs).
Find(&selections).Error
if err != nil {
return nil, err
}
for _, sel := range selections {
result[sel.CategoryID] = append(result[sel.CategoryID], sel)
}
return result, nil
}