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:
@@ -33,3 +33,29 @@ func (r *MessageRepository) LastMessage(matchID uint) (*models.Message, error) {
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// LastMessagesByMatchIDs آخرین پیام هر مچ رو، برای چند مچ با هم در یک کوئری
|
||||
// برمیگردونه (بهجای یک کوئری جدا بهازای هر مچ توی لیست گفتگوها). از
|
||||
// DISTINCT ON پستگرس استفاده میکنه که دقیقاً برای همین کار («آخرین ردیف هر
|
||||
// گروه») ساخته شده.
|
||||
func (r *MessageRepository) LastMessagesByMatchIDs(matchIDs []uint) (map[uint]models.Message, error) {
|
||||
result := make(map[uint]models.Message, len(matchIDs))
|
||||
if len(matchIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var messages []models.Message
|
||||
err := r.db.
|
||||
Distinct("ON (match_id) *").
|
||||
Where("match_id IN ?", matchIDs).
|
||||
Order("match_id, created_at desc").
|
||||
Find(&messages).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range messages {
|
||||
result[m.MatchID] = m
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -2,23 +2,41 @@ package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dating-app/internal/cache"
|
||||
"dating-app/internal/models"
|
||||
)
|
||||
|
||||
var ErrCategoryNotFound = errors.New("دستهی موردنظر یافت نشد")
|
||||
|
||||
// activeOptionsCacheTTL: گزینههای فعال هر دسته و خودِ رکورد دسته فقط از پنل
|
||||
// ادمین تغییر میکنن (که خیلی بهندرت اتفاق میافته) ولی توی هر مرحلهی
|
||||
// آنبوردینگ/دیسکاوری هر کاربر خونده میشن؛ کش کردنشون فشار روی دیتابیس رو
|
||||
// زیاد کم میکنه.
|
||||
const activeOptionsCacheTTL = 5 * time.Minute
|
||||
|
||||
type OptionRepository struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
activeOptionsByKey *cache.TTLCache[models.CategoryKey, []models.Option]
|
||||
categoryByKey *cache.TTLCache[models.CategoryKey, models.Category]
|
||||
}
|
||||
|
||||
func NewOptionRepository(db *gorm.DB) *OptionRepository {
|
||||
return &OptionRepository{db: db}
|
||||
return &OptionRepository{
|
||||
db: db,
|
||||
activeOptionsByKey: cache.New[models.CategoryKey, []models.Option](activeOptionsCacheTTL),
|
||||
categoryByKey: cache.New[models.CategoryKey, models.Category](activeOptionsCacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *OptionRepository) GetCategoryByKey(key models.CategoryKey) (*models.Category, error) {
|
||||
if cached, ok := r.categoryByKey.Get(key); ok {
|
||||
return &cached, nil
|
||||
}
|
||||
|
||||
var cat models.Category
|
||||
err := r.db.Where("key = ?", key).First(&cat).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -27,14 +45,25 @@ func (r *OptionRepository) GetCategoryByKey(key models.CategoryKey) (*models.Cat
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.categoryByKey.Set(key, cat)
|
||||
return &cat, nil
|
||||
}
|
||||
|
||||
func (r *OptionRepository) UpdateCategory(cat *models.Category) error {
|
||||
return r.db.Save(cat).Error
|
||||
if err := r.db.Save(cat).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeOptionsByKey.Delete(cat.Key)
|
||||
r.categoryByKey.Delete(cat.Key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *OptionRepository) ListActiveOptionsByCategoryKey(key models.CategoryKey) ([]models.Option, error) {
|
||||
if cached, ok := r.activeOptionsByKey.Get(key); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
cat, err := r.GetCategoryByKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -43,7 +72,12 @@ func (r *OptionRepository) ListActiveOptionsByCategoryKey(key models.CategoryKey
|
||||
err = r.db.Where("category_id = ? AND is_active = ?", cat.ID, true).
|
||||
Order("sort_order asc, id asc").
|
||||
Find(&options).Error
|
||||
return options, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.activeOptionsByKey.Set(key, options)
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// ListOptionsByCategoryKeyAdmin تمام گزینهها (فعال و غیرفعال) رو برمیگردونه - برای پنل ادمین
|
||||
@@ -60,7 +94,11 @@ func (r *OptionRepository) ListOptionsByCategoryKeyAdmin(key models.CategoryKey)
|
||||
}
|
||||
|
||||
func (r *OptionRepository) CreateOption(opt *models.Option) error {
|
||||
return r.db.Create(opt).Error
|
||||
if err := r.db.Create(opt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeOptionsByKey.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *OptionRepository) FindOptionByID(id uint) (*models.Option, error) {
|
||||
@@ -73,11 +111,19 @@ func (r *OptionRepository) FindOptionByID(id uint) (*models.Option, error) {
|
||||
}
|
||||
|
||||
func (r *OptionRepository) UpdateOption(opt *models.Option) error {
|
||||
return r.db.Save(opt).Error
|
||||
if err := r.db.Save(opt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeOptionsByKey.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *OptionRepository) DeleteOption(id uint) error {
|
||||
return r.db.Delete(&models.Option{}, id).Error
|
||||
if err := r.db.Delete(&models.Option{}, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeOptionsByKey.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindActiveOptionsByIDsInCategory فقط گزینههای فعالی که در این دسته هستند و id شون
|
||||
@@ -128,3 +174,52 @@ func (r *OptionRepository) CountUserSelections(userID, categoryID uint) (int64,
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetSelectionsForUsersInCategory انتخابهای چند کاربر رو در یک دستهی مشخص،
|
||||
// همه با هم در یک کوئری برمیگردونه (بهجای یک کوئری جدا برای هر کاربر).
|
||||
// برای فید دیسکاوری استفاده میشه که تا ۶۰ کاندیدا با هم پردازش میشن.
|
||||
func (r *OptionRepository) GetSelectionsForUsersInCategory(userIDs []uint, categoryID uint) (map[uint][]string, error) {
|
||||
result := make(map[uint][]string, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var selections []models.UserOptionSelection
|
||||
err := r.db.Preload("Option").
|
||||
Where("user_id IN ? AND category_id = ?", userIDs, categoryID).
|
||||
Find(&selections).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, sel := range selections {
|
||||
if sel.Option != nil {
|
||||
result[sel.UserID] = append(result[sel.UserID], sel.Option.Title)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetUserSelectionsForCategories انتخابهای یک کاربر رو در چند دسته با هم، در
|
||||
// یک کوئری برمیگردونه (بهجای یک کوئری جدا بهازای هر دسته). برای صفحهی
|
||||
// جزئیات پروفایل استفاده میشه که همزمان چند دسته (تحصیلات، شغل، علاقهمندی و...)
|
||||
// رو نشون میده.
|
||||
func (r *OptionRepository) GetUserSelectionsForCategories(userID uint, categoryIDs []uint) (map[uint][]models.UserOptionSelection, error) {
|
||||
result := make(map[uint][]models.UserOptionSelection)
|
||||
if len(categoryIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var selections []models.UserOptionSelection
|
||||
err := r.db.Preload("Option").
|
||||
Where("user_id = ? AND category_id IN ?", userID, categoryIDs).
|
||||
Find(&selections).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, sel := range selections {
|
||||
result[sel.CategoryID] = append(result[sel.CategoryID], sel)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dating-app/internal/cache"
|
||||
"dating-app/internal/models"
|
||||
)
|
||||
|
||||
const activeQuestionsCacheTTL = 5 * time.Minute
|
||||
const activeQuestionsCacheKey = "active"
|
||||
|
||||
type QuestionRepository struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
activeCache *cache.TTLCache[string, []models.ProfileQuestion]
|
||||
}
|
||||
|
||||
func NewQuestionRepository(db *gorm.DB) *QuestionRepository {
|
||||
return &QuestionRepository{db: db}
|
||||
return &QuestionRepository{
|
||||
db: db,
|
||||
activeCache: cache.New[string, []models.ProfileQuestion](activeQuestionsCacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) ListActive() ([]models.ProfileQuestion, error) {
|
||||
if cached, ok := r.activeCache.Get(activeQuestionsCacheKey); ok {
|
||||
return cached, nil
|
||||
}
|
||||
var qs []models.ProfileQuestion
|
||||
err := r.db.Where("is_active = ?", true).Order("sort_order asc, id asc").Find(&qs).Error
|
||||
return qs, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.activeCache.Set(activeQuestionsCacheKey, qs)
|
||||
return qs, nil
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) ListAllAdmin() ([]models.ProfileQuestion, error) {
|
||||
@@ -42,15 +59,27 @@ func (r *QuestionRepository) FindActiveByIDs(ids []uint) ([]models.ProfileQuesti
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) Create(q *models.ProfileQuestion) error {
|
||||
return r.db.Create(q).Error
|
||||
if err := r.db.Create(q).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeCache.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) Update(q *models.ProfileQuestion) error {
|
||||
return r.db.Save(q).Error
|
||||
if err := r.db.Save(q).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeCache.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.ProfileQuestion{}, id).Error
|
||||
if err := r.db.Delete(&models.ProfileQuestion{}, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeCache.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceUserAnswers پاسخهای قبلی کاربر رو با پاسخهای جدید جایگزین میکنه (تراکنشی)
|
||||
|
||||
@@ -34,6 +34,18 @@ func (r *UserRepository) FindByID(id uint) (*models.User, error) {
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// FindByIDs چند کاربر رو با هم در یک کوئری برمیگردونه (بهجای یک FindByID
|
||||
// جدا بهازای هر id)؛ برای لیستهایی مثل مچها که باید اطلاعات طرف مقابل
|
||||
// چندین مچ رو یکجا نشون بدن.
|
||||
func (r *UserRepository) FindByIDs(ids []uint) ([]models.User, error) {
|
||||
var users []models.User
|
||||
if len(ids) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
err := r.db.Where("id IN ?", ids).Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
// GetOrCreateByPhone کاربر را با شمارهی دادهشده پیدا میکند یا در صورت نبود میسازد.
|
||||
func (r *UserRepository) GetOrCreateByPhone(phone string) (*models.User, bool, error) {
|
||||
var user models.User
|
||||
|
||||
Reference in New Issue
Block a user