chore: merge backend repo history into monorepo at backend/

git-subtree-dir: backend
git-subtree-mainline: dc11190527
git-subtree-split: acae976622
This commit is contained in:
2026-09-27 10:30:16 +03:30
67 changed files with 6835 additions and 0 deletions
+55
View File
@@ -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])
}
+62
View File
@@ -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")
}
}
+101
View File
@@ -0,0 +1,101 @@
package config
import (
"os"
"strconv"
"time"
"github.com/joho/godotenv"
)
// Config تمام تنظیمات قابل‌پیکربندی برنامه از طریق متغیرهای محیطی
type Config struct {
AppEnv string
AppPort string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
DBSSLMode string
// تنظیمات connection pool؛ زیر بار بالا این‌ها از باز شدن بی‌رویه‌ی اتصال
// (که خود Postgres رو زمین می‌زنه) و از نشتی اتصالات راکد جلوگیری می‌کنن.
DBMaxOpenConns int
DBMaxIdleConns int
DBConnMaxLifetimeMins int
DBConnMaxIdleTimeMins int
JWTAccessSecret string
JWTRefreshSecret string
JWTAccessTTL time.Duration
JWTRefreshTTL time.Duration
OTPExpirySeconds int
OTPLength int
OTPHashSecret string
// اگر پر باشد، در اولین اجرا یک کاربر ادمین با این شماره ساخته می‌شود
AdminBootstrapPhone string
}
// Load فایل .env را (در صورت وجود) می‌خواند و Config را برمی‌گرداند.
// نبودن فایل .env خطا محسوب نمی‌شود (مثلاً در پروداکشن env مستقیم ست می‌شود).
func Load() *Config {
_ = godotenv.Load()
return &Config{
AppEnv: getEnv("APP_ENV", "development"),
AppPort: getEnv("APP_PORT", "8080"),
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", "postgres"),
DBPassword: getEnv("DB_PASSWORD", "postgres"),
DBName: getEnv("DB_NAME", "dating_app"),
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"),
JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", "change-me-refresh-secret"),
JWTAccessTTL: time.Duration(getEnvInt("JWT_ACCESS_TTL_MINUTES", 60*24)) * time.Minute,
JWTRefreshTTL: time.Duration(getEnvInt("JWT_REFRESH_TTL_DAYS", 30)) * 24 * time.Hour,
OTPExpirySeconds: getEnvInt("OTP_EXPIRY_SECONDS", 180),
OTPLength: getEnvInt("OTP_LENGTH", 4),
OTPHashSecret: getEnv("OTP_HASH_SECRET", "change-me-otp-secret"),
AdminBootstrapPhone: getEnv("ADMIN_BOOTSTRAP_PHONE", ""),
}
}
func (c *Config) DSN() string {
return "host=" + c.DBHost +
" user=" + c.DBUser +
" password=" + c.DBPassword +
" dbname=" + c.DBName +
" port=" + c.DBPort +
" sslmode=" + c.DBSSLMode +
" TimeZone=Asia/Tehran"
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getEnvInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
+148
View File
@@ -0,0 +1,148 @@
package database
import (
"log"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"dating-app/internal/config"
"dating-app/internal/models"
)
// Connect به دیتابیس پستگرس وصل می‌شود
func Connect(cfg *config.Config) (*gorm.DB, error) {
logLevel := logger.Warn
if cfg.AppEnv == "development" {
logLevel = logger.Info
}
db, err := gorm.Open(postgres.Open(cfg.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(logLevel),
})
if err != nil {
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
}
// AutoMigrate جدول‌های مورد نیاز را می‌سازد/به‌روزرسانی می‌کند
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(
&models.User{},
&models.Category{},
&models.Option{},
&models.UserOptionSelection{},
&models.ProfileQuestion{},
&models.UserProfileAnswer{},
&models.Photo{},
&models.Swipe{},
&models.Match{},
&models.Message{},
&models.Report{},
)
}
// SeedCategories دسته‌های ثابت لیست‌های انتخابی را در صورت نبود می‌سازد.
// خودِ گزینه‌های داخل هر دسته (به‌جز looking_for که صراحتاً در نیازمندی
// پروژه مشخص شده) باید از پنل ادمین اضافه شوند.
func SeedCategories(db *gorm.DB) error {
defaults := []models.Category{
{Key: models.CategoryEducationLevel, Title: "سطح تحصیلات", MinSelect: 1, MaxSelect: 1},
{Key: models.CategoryFieldOfStudy, Title: "رشته تحصیلی", MinSelect: 1, MaxSelect: 1},
{Key: models.CategoryOccupation, Title: "کارهایی که انجام می‌دهید", MinSelect: 1, MaxSelect: 0},
{Key: models.CategoryImportantInRelationship, Title: "برات توی یک رابطه مهمه", MinSelect: 3, MaxSelect: 3},
{Key: models.CategoryInterest, Title: "علاقه‌مندی‌ها", MinSelect: 5, MaxSelect: 5},
{Key: models.CategoryLookingFor, Title: "دنبال چی می‌گردی", MinSelect: 1, MaxSelect: 1},
}
for _, c := range defaults {
var existing models.Category
err := db.Where("key = ?", c.Key).First(&existing).Error
if err == gorm.ErrRecordNotFound {
if err := db.Create(&c).Error; err != nil {
return err
}
log.Printf("دسته‌ی «%s» ساخته شد", c.Title)
} else if err != nil {
return err
}
}
// گزینه‌های "دنبال چی می‌گردی" مطابق چیزی که در نیازمندی مشخص شده seed می‌شن
var lookingFor models.Category
if err := db.Where("key = ?", models.CategoryLookingFor).First(&lookingFor).Error; err == nil {
var count int64
db.Model(&models.Option{}).Where("category_id = ?", lookingFor.ID).Count(&count)
if count == 0 {
titles := []string{
"رابطه بلندمدت",
"همسر زندگی",
"قرارهای تفریحی",
"آشنایی با افراد جدید",
"ازدواج",
"دوست ساده",
}
for i, t := range titles {
db.Create(&models.Option{
CategoryID: lookingFor.ID,
Title: t,
SortOrder: i,
IsActive: true,
})
}
log.Println("گزینه‌های «دنبال چی می‌گردی» seed شدند")
}
}
return nil
}
// BootstrapAdmin اگر شماره‌ای برای ادمین اولیه مشخص شده باشد،
// یک کاربر ادمین با آن شماره می‌سازد (در صورت نبود).
func BootstrapAdmin(db *gorm.DB, phone string) error {
if phone == "" {
return nil
}
var user models.User
err := db.Where("phone = ?", phone).First(&user).Error
if err == gorm.ErrRecordNotFound {
user = models.User{
Phone: phone,
State: models.UserStateActive,
IsAdmin: true,
IsActive: true,
ApprovalStatus: models.ApprovalApproved,
}
if err := db.Create(&user).Error; err != nil {
return err
}
log.Printf("کاربر ادمین اولیه با شماره %s ساخته شد", phone)
return nil
} else if err != nil {
return err
}
if !user.IsAdmin {
user.IsAdmin = true
db.Save(&user)
log.Printf("کاربر %s به ادمین ارتقا یافت", phone)
}
return nil
}
+49
View File
@@ -0,0 +1,49 @@
package dto
// CreateOptionRequest ساخت یک گزینه‌ی جدید در یک دسته
type CreateOptionRequest struct {
Title string `json:"title" binding:"required,min=1,max=150"`
SortOrder int `json:"sort_order"`
}
// UpdateOptionRequest ویرایش یک گزینه
type UpdateOptionRequest struct {
Title *string `json:"title"`
SortOrder *int `json:"sort_order"`
IsActive *bool `json:"is_active"`
}
// UpdateCategoryRequest ویرایش تنظیمات یک دسته (مثلاً تغییر حداقل/حداکثر انتخاب)
type UpdateCategoryRequest struct {
Title *string `json:"title"`
MinSelect *int `json:"min_select"`
MaxSelect *int `json:"max_select"`
}
// CreateProfileQuestionRequest ساخت سوال جدید پروفایل
type CreateProfileQuestionRequest struct {
Text string `json:"text" binding:"required,min=3,max=255"`
SortOrder int `json:"sort_order"`
}
// UpdateProfileQuestionRequest ویرایش سوال پروفایل
type UpdateProfileQuestionRequest struct {
Text *string `json:"text"`
SortOrder *int `json:"sort_order"`
IsActive *bool `json:"is_active"`
}
// RejectUserRequest رد کردن درخواست عضویت یک کاربر توسط ادمین (دلیل الزامی است)
type RejectUserRequest struct {
Reason string `json:"reason" binding:"required,min=3,max=500"`
}
// BlockUserRequest بلاک کردن یک کاربر توسط ادمین (دلیل الزامی است)
type BlockUserRequest struct {
Reason string `json:"reason" binding:"required,min=3,max=500"`
}
// ReportUserRequest ثبت یک یادداشت نظارتی روی یک کاربر (دلیل الزامی است)
type ReportUserRequest struct {
Reason string `json:"reason" binding:"required,min=3,max=500"`
}
+64
View File
@@ -0,0 +1,64 @@
package dto
import "time"
// RequestOTPRequest بدنه‌ی درخواست ارسال کد تایید
type RequestOTPRequest struct {
Phone string `json:"phone" binding:"required"`
}
// VerifyOTPRequest بدنه‌ی درخواست تایید کد
type VerifyOTPRequest struct {
Phone string `json:"phone" binding:"required"`
OTP string `json:"otp" binding:"required"`
}
// RefreshTokenRequest بدنه‌ی درخواست رفرش توکن
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
// TokenPair جفت توکن access/refresh
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
// AuthResponse پاسخ استاندارد بعد از ورود موفق
type AuthResponse struct {
Tokens TokenPair `json:"tokens"`
User UserProfile `json:"user"`
IsNewUser bool `json:"is_new_user"`
OnboardingDone bool `json:"onboarding_done"`
}
// UserProfile نمایش خلاصه‌ی پروفایل کاربر (بدون داده‌های حساس)
type UserProfile struct {
ID uint `json:"id"`
Phone string `json:"phone"`
FirstName string `json:"first_name,omitempty"`
Gender *string `json:"gender,omitempty"`
BirthDate *string `json:"birth_date,omitempty"`
HeightCM *int `json:"height_cm,omitempty"`
WeightKG *int `json:"weight_kg,omitempty"`
CurrentStep int `json:"current_step"`
IsOnboarded bool `json:"is_onboarded"`
ApprovalStatus string `json:"approval_status"`
RejectionReason *string `json:"rejection_reason,omitempty"`
IsAdmin bool `json:"is_admin"`
State string `json:"state"`
BlockedReason *string `json:"blocked_reason,omitempty"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
Bio *string `json:"bio,omitempty"`
StreakCount int `json:"streak_count"`
NotifyMatches bool `json:"notify_matches"`
NotifyMessages bool `json:"notify_messages"`
NotifyLikes bool `json:"notify_likes"`
}
// UserProfileWithPhotos برای لیست‌های ادمینی که علاوه بر خلاصه‌ی پروفایل، عکس
// کاربر رو هم نشون می‌دن (مثلاً کاربران در انتظار تایید).
type UserProfileWithPhotos struct {
UserProfile
Photos []string `json:"photos"`
}
+89
View File
@@ -0,0 +1,89 @@
package dto
// DiscoverCandidate یک پروفایل پیشنهادی توی صفحه‌ی دوستیابی
type DiscoverCandidate struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
Age int `json:"age"`
Bio string `json:"bio,omitempty"`
Photos []string `json:"photos"`
DistanceKM *float64 `json:"distance_km,omitempty"`
Interests []string `json:"interests,omitempty"`
}
// SwipeRequest بدنه‌ی درخواست ثبت لایک/ردّ
type SwipeRequest struct {
Action string `json:"action" binding:"required,oneof=like pass superlike"`
}
// SwipeResponse نتیجه‌ی یک سواپ؛ اگه Matched=true باشه یعنی مچ تازه (یا از قبل) ساخته شده
type SwipeResponse struct {
Matched bool `json:"matched"`
Match *MatchView `json:"match,omitempty"`
}
// MatchView خلاصه‌ی یک مچ برای نمایش در لیست مچ‌ها/گفتگوها. برای پیش‌نمایش
// آخرین پیام، سرور رمزگشایی نمی‌کنه (نمی‌تونه) — CipherText/Nonce خودِ آخرین
// پیام رو برمی‌گردونه تا کلاینت خودش رمزگشایی کنه؛ LastMessage فقط برای
// پیام‌های قدیمیِ قبل از E2EE پر می‌شه.
type MatchView struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
FirstName string `json:"first_name"`
Photos []string `json:"photos"`
CreatedAt string `json:"created_at"`
LastMessage *string `json:"last_message,omitempty"`
LastMessageCipherText *string `json:"last_message_cipher_text,omitempty"`
LastMessageNonce *string `json:"last_message_nonce,omitempty"`
LastAt *string `json:"last_at,omitempty"`
// PartnerPublicKey کلید عمومی X25519 طرف مقابل (base64) برای رمزنگاری
// سرتاسری؛ اگه طرف مقابل هنوز کلیدی ثبت نکرده باشه خالیه.
PartnerPublicKey *string `json:"partner_public_key,omitempty"`
}
// MessageView یک پیام در یک گفتگو. یا Text (پیام قدیمیِ قبل از E2EE) پر
// می‌شه، یا CipherText+Nonce (پیام رمزنگاری‌شده) — سرور خودش نمی‌دونه محتوای
// رمزنگاری‌شده چیه، فقط بین دو طرف مچ ردوبدلش می‌کنه.
type MessageView struct {
ID uint `json:"id"`
MatchID uint `json:"match_id"`
SenderID uint `json:"sender_id"`
Text string `json:"text,omitempty"`
CipherText *string `json:"cipher_text,omitempty"`
Nonce *string `json:"nonce,omitempty"`
CreatedAt string `json:"created_at"`
IsMine bool `json:"is_mine"`
}
// SendMessageRequest بدنه‌ی درخواست ارسال پیام رمزنگاری‌شده. متن خام پیام
// هیچ‌وقت به سرور نمی‌رسه؛ کلاینت قبل از ارسال با کلید عمومی طرف مقابل
// رمزنگاریش می‌کنه.
type SendMessageRequest struct {
CipherText string `json:"cipher_text" binding:"required,min=1,max=12000"`
Nonce string `json:"nonce" binding:"required,len=32"`
}
// CandidateAnswer پاسخ یک سوال پروفایل، برای نمایش عمومی
type CandidateAnswer struct {
Question string `json:"question"`
Answer string `json:"answer"`
}
// CandidateDetail پروفایل کامل یک کاربر دیگه (برای نمایش صفحه‌ی جزئیات پروفایل)
type CandidateDetail struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
Age int `json:"age"`
Bio string `json:"bio,omitempty"`
Photos []string `json:"photos"`
DistanceKM *float64 `json:"distance_km,omitempty"`
HeightCM *int `json:"height_cm,omitempty"`
WeightKG *int `json:"weight_kg,omitempty"`
Education string `json:"education,omitempty"`
FieldOfStudy string `json:"field_of_study,omitempty"`
Occupations []string `json:"occupations,omitempty"`
Interests []string `json:"interests,omitempty"`
ImportantInRelationship []string `json:"important_in_relationship,omitempty"`
LookingFor string `json:"looking_for,omitempty"`
Answers []CandidateAnswer `json:"profile_answers,omitempty"`
}
+56
View File
@@ -0,0 +1,56 @@
package dto
// BasicInfoRequest مرحله ۱: جنسیت، اسم کوچک، تاریخ تولد
type BasicInfoRequest struct {
Gender string `json:"gender" binding:"required,oneof=male female"`
FirstName string `json:"first_name" binding:"required,min=2,max=50"`
// فرمت: YYYY-MM-DD
BirthDate string `json:"birth_date" binding:"required"`
}
// BodyInfoRequest مرحله ۲: قد و وزن
type BodyInfoRequest struct {
HeightCM int `json:"height_cm" binding:"required,min=100,max=250"`
WeightKG int `json:"weight_kg" binding:"required,min=30,max=250"`
}
// ProfileQuestionAnswerInput یک جفت (سوال انتخاب‌شده، پاسخ) از سوالات پروفایل
type ProfileQuestionAnswerInput struct {
QuestionID uint `json:"question_id" binding:"required"`
Answer string `json:"answer" binding:"required,min=1,max=1000"`
}
// ProfileQuestionsRequest مرحله ۳: دقیقاً ۳ سوال انتخابی همراه با پاسخ
type ProfileQuestionsRequest struct {
Answers []ProfileQuestionAnswerInput `json:"answers" binding:"required,len=3,dive"`
}
// EducationRequest مرحله ۴: سطح تحصیلات + رشته تحصیلی (هر دو از لیست‌های ادمین)
type EducationRequest struct {
EducationLevelOptionID uint `json:"education_level_option_id" binding:"required"`
FieldOfStudyOptionID uint `json:"field_of_study_option_id" binding:"required"`
}
// OptionSelectionRequest برای مراحلی که فقط یک لیست از option_id ارسال می‌شود
// (کارها، اهمیت‌های رابطه، علاقه‌مندی‌ها، دنبال چی می‌گردی)
type OptionSelectionRequest struct {
OptionIDs []uint `json:"option_ids" binding:"required,min=1,dive"`
}
// OnboardingStatusResponse وضعیت فعلی آنبوردینگ کاربر
type OnboardingStatusResponse struct {
CurrentStep int `json:"current_step"`
CurrentStepKey string `json:"current_step_key"`
IsOnboarded bool `json:"is_onboarded"`
// ApprovalStatus بعد از تکمیل آنبوردینگ معنا پیدا می‌کنه: pending/approved/rejected.
// وقتی pending هست، اپ باید صفحه‌ی «در انتظار تایید ادمین» رو نشون بده.
ApprovalStatus string `json:"approval_status"`
RejectionReason *string `json:"rejection_reason,omitempty"`
Steps []StepView `json:"steps"`
}
type StepView struct {
Step int `json:"step"`
Key string `json:"key"`
Done bool `json:"done"`
}
+32
View File
@@ -0,0 +1,32 @@
package dto
// UpdateLocationRequest به‌روزرسانی موقعیت مکانی کاربر (برای محاسبه‌ی فاصله‌ی تقریبی)
type UpdateLocationRequest struct {
Latitude float64 `json:"latitude" binding:"required"`
Longitude float64 `json:"longitude" binding:"required"`
}
// UpdateBioRequest ویرایش بیوگرافی کوتاه پروفایل
type UpdateBioRequest struct {
Bio string `json:"bio" binding:"max=300"`
}
// CheckInResult نتیجه‌ی ثبت ورود روزانه (برای استریک گیمیفیکیشن)
type CheckInResult struct {
StreakCount int `json:"streak_count"`
IsNewToday bool `json:"is_new_today"`
}
// UpdateNotificationSettingsRequest ترجیحات اعلان کاربر
type UpdateNotificationSettingsRequest struct {
NotifyMatches bool `json:"notify_matches"`
NotifyMessages bool `json:"notify_messages"`
NotifyLikes bool `json:"notify_likes"`
}
// UpdatePublicKeyRequest ثبت/به‌روزرسانی کلید عمومی X25519 کاربر برای
// رمزنگاری سرتاسری پیام‌ها. کلید باید دقیقاً ۳۲ بایت خام باشه که به‌صورت
// base64 استاندارد (۴۴ کاراکتر، شامل padding) کدگذاری شده.
type UpdatePublicKeyRequest struct {
PublicKey string `json:"public_key" binding:"required,len=44"`
}
+479
View File
@@ -0,0 +1,479 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
"dating-app/internal/service"
"dating-app/internal/utils"
)
type AdminHandler struct {
adminService *service.AdminService
optionRepo *repository.OptionRepository
questionRepo *repository.QuestionRepository
photoRepo *repository.PhotoRepository
onboardingService *service.OnboardingService
}
func NewAdminHandler(
adminService *service.AdminService,
optionRepo *repository.OptionRepository,
questionRepo *repository.QuestionRepository,
photoRepo *repository.PhotoRepository,
onboardingService *service.OnboardingService,
) *AdminHandler {
return &AdminHandler{
adminService: adminService,
optionRepo: optionRepo,
questionRepo: questionRepo,
photoRepo: photoRepo,
onboardingService: onboardingService,
}
}
func validCategoryKey(key string) (models.CategoryKey, bool) {
k := models.CategoryKey(key)
for _, valid := range models.AllCategoryKeys {
if valid == k {
return k, true
}
}
return "", false
}
// ---------------- دسته‌ها ----------------
// GetCategory GET /admin/categories/:key
func (h *AdminHandler) GetCategory(c *gin.Context) {
key, ok := validCategoryKey(c.Param("key"))
if !ok {
utils.Fail(c, http.StatusNotFound, "دسته‌ی نامعتبر است", nil)
return
}
cat, err := h.adminService.GetCategory(key)
if err != nil {
utils.Fail(c, http.StatusNotFound, "دسته یافت نشد", nil)
return
}
utils.Success(c, http.StatusOK, "", cat)
}
// UpdateCategory PATCH /admin/categories/:key
func (h *AdminHandler) UpdateCategory(c *gin.Context) {
key, ok := validCategoryKey(c.Param("key"))
if !ok {
utils.Fail(c, http.StatusNotFound, "دسته‌ی نامعتبر است", nil)
return
}
var req dto.UpdateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
cat, err := h.adminService.UpdateCategory(key, req)
if err != nil {
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
return
}
utils.Success(c, http.StatusOK, "دسته به‌روزرسانی شد", cat)
}
// ---------------- گزینه‌ها ----------------
// ListOptions GET /admin/categories/:key/options
func (h *AdminHandler) ListOptions(c *gin.Context) {
key, ok := validCategoryKey(c.Param("key"))
if !ok {
utils.Fail(c, http.StatusNotFound, "دسته‌ی نامعتبر است", nil)
return
}
options, err := h.adminService.ListOptions(key)
if err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزینه‌ها", nil)
return
}
utils.Success(c, http.StatusOK, "", options)
}
// CreateOption POST /admin/categories/:key/options
func (h *AdminHandler) CreateOption(c *gin.Context) {
key, ok := validCategoryKey(c.Param("key"))
if !ok {
utils.Fail(c, http.StatusNotFound, "دسته‌ی نامعتبر است", nil)
return
}
var req dto.CreateOptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
opt, err := h.adminService.CreateOption(key, req)
if err != nil {
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
return
}
utils.Success(c, http.StatusCreated, "گزینه ساخته شد", opt)
}
// UpdateOption PATCH /admin/options/:id
func (h *AdminHandler) UpdateOption(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
var req dto.UpdateOptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
opt, err := h.adminService.UpdateOption(uint(id), req)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.Fail(c, http.StatusNotFound, "گزینه یافت نشد", nil)
return
}
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
return
}
utils.Success(c, http.StatusOK, "گزینه به‌روزرسانی شد", opt)
}
// DeleteOption DELETE /admin/options/:id
func (h *AdminHandler) DeleteOption(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
if err := h.adminService.DeleteOption(uint(id)); err != nil {
utils.Fail(c, http.StatusBadRequest, "حذف گزینه با خطا مواجه شد", nil)
return
}
utils.Success(c, http.StatusOK, "گزینه حذف شد", nil)
}
// ---------------- بررسی و تایید کاربران ----------------
// ListPendingUsers GET /admin/users/pending
func (h *AdminHandler) ListPendingUsers(c *gin.Context) {
users, photosByUser, err := h.adminService.ListPendingUsers()
if err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست کاربران", nil)
return
}
result := make([]dto.UserProfileWithPhotos, 0, len(users))
for i := range users {
photos := photosByUser[users[i].ID]
if photos == nil {
photos = []string{}
}
result = append(result, dto.UserProfileWithPhotos{
UserProfile: toUserProfile(&users[i]),
Photos: photos,
})
}
utils.Success(c, http.StatusOK, "", result)
}
// GetUserDetail GET /admin/users/:id — پروفایل کامل یک کاربر برای بررسی ادمین
func (h *AdminHandler) GetUserDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
user, err := h.adminService.GetUser(uint(id))
if err != nil {
utils.Fail(c, http.StatusNotFound, "کاربر یافت نشد", nil)
return
}
utils.Success(c, http.StatusOK, "", fullProfilePayload(user, h.optionRepo, h.questionRepo, h.onboardingService, h.photoRepo))
}
// ApproveUser POST /admin/users/:id/approve
func (h *AdminHandler) ApproveUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
user, err := h.adminService.ApproveUser(uint(id))
if err != nil {
h.handleUserReviewError(c, err)
return
}
utils.Success(c, http.StatusOK, "کاربر تایید شد", toUserProfile(user))
}
// RejectUser POST /admin/users/:id/reject body: {reason}
func (h *AdminHandler) RejectUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
var req dto.RejectUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل رد الزامی است)", err.Error())
return
}
user, err := h.adminService.RejectUser(uint(id), req.Reason)
if err != nil {
h.handleUserReviewError(c, err)
return
}
utils.Success(c, http.StatusOK, "کاربر رد شد", toUserProfile(user))
}
func (h *AdminHandler) handleUserReviewError(c *gin.Context, err error) {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
utils.Fail(c, http.StatusNotFound, "کاربر یافت نشد", nil)
case errors.Is(err, service.ErrUserNotOnboarded):
utils.Fail(c, http.StatusConflict, err.Error(), nil)
default:
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
}
}
// paginationParams صفحه/تعداد رو از کوئری استرینگ می‌خونه (پیش‌فرض‌های معقول اگه نبود/نامعتبر بود)
func paginationParams(c *gin.Context, defaultSize int) (page, pageSize int) {
page = 1
if p, err := strconv.Atoi(c.Query("page")); err == nil && p > 0 {
page = p
}
pageSize = defaultSize
if ps, err := strconv.Atoi(c.Query("page_size")); err == nil && ps > 0 {
pageSize = ps
}
return
}
// ---------------- همه‌ی کاربران، بلاک/آنبلاک، گزارش ----------------
// ListAllUsers GET /admin/users?search=&page=&page_size=
func (h *AdminHandler) ListAllUsers(c *gin.Context) {
page, pageSize := paginationParams(c, 25)
search := c.Query("search")
items, total, err := h.adminService.ListAllUsers(search, page, pageSize)
if err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست کاربران", nil)
return
}
utils.Success(c, http.StatusOK, "", gin.H{
"items": items, "total": total, "page": page, "page_size": pageSize,
})
}
// BlockUser POST /admin/users/:id/block body: {reason}
func (h *AdminHandler) BlockUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
var req dto.BlockUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل بلاک الزامی است)", err.Error())
return
}
user, err := h.adminService.BlockUser(uint(id), req.Reason)
if err != nil {
h.handleUserReviewError(c, err)
return
}
utils.Success(c, http.StatusOK, "کاربر بلاک شد", toUserProfile(user))
}
// UnblockUser POST /admin/users/:id/unblock
func (h *AdminHandler) UnblockUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
user, err := h.adminService.UnblockUser(uint(id))
if err != nil {
h.handleUserReviewError(c, err)
return
}
utils.Success(c, http.StatusOK, "کاربر آنبلاک شد", toUserProfile(user))
}
// ReportUser POST /admin/users/:id/report body: {reason}
func (h *AdminHandler) ReportUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
var req dto.ReportUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل گزارش الزامی است)", err.Error())
return
}
report, err := h.adminService.ReportUser(uint(id), req.Reason)
if err != nil {
h.handleUserReviewError(c, err)
return
}
utils.Success(c, http.StatusCreated, "گزارش ثبت شد", report)
}
// ListUserReports GET /admin/users/:id/reports
func (h *AdminHandler) ListUserReports(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
reports, err := h.adminService.ListReports(uint(id))
if err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزارش‌ها", nil)
return
}
utils.Success(c, http.StatusOK, "", reports)
}
// ---------------- مچ‌ها (رفقا) ----------------
// ListAllMatches GET /admin/matches?page=&page_size=
func (h *AdminHandler) ListAllMatches(c *gin.Context) {
page, pageSize := paginationParams(c, 25)
items, total, err := h.adminService.ListAllMatches(page, pageSize)
if err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست مچ‌ها", nil)
return
}
utils.Success(c, http.StatusOK, "", gin.H{
"items": items, "total": total, "page": page, "page_size": pageSize,
})
}
// ---------------- نظارت بر عکس‌ها ----------------
// ListAllPhotos GET /admin/photos?page=&page_size=
func (h *AdminHandler) ListAllPhotos(c *gin.Context) {
page, pageSize := paginationParams(c, 50)
items, total, err := h.adminService.ListAllPhotos(page, pageSize)
if err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت عکس‌ها", nil)
return
}
utils.Success(c, http.StatusOK, "", gin.H{
"items": items, "total": total, "page": page, "page_size": pageSize,
})
}
// DeletePhoto DELETE /admin/photos/:id
func (h *AdminHandler) DeletePhoto(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
if err := h.adminService.DeletePhotoAdmin(uint(id)); err != nil {
utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil)
return
}
utils.Success(c, http.StatusOK, "عکس حذف شد", nil)
}
// ReportPhoto POST /admin/photos/:id/report body: {reason}
func (h *AdminHandler) ReportPhoto(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
var req dto.ReportUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل گزارش الزامی است)", err.Error())
return
}
report, err := h.adminService.ReportPhoto(uint(id), req.Reason)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil)
return
}
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
return
}
utils.Success(c, http.StatusCreated, "گزارش ثبت شد", report)
}
// ---------------- سوالات پروفایل ----------------
// ListQuestions GET /admin/profile-questions
func (h *AdminHandler) ListQuestions(c *gin.Context) {
qs, err := h.adminService.ListQuestions()
if err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت سوالات", nil)
return
}
utils.Success(c, http.StatusOK, "", qs)
}
// CreateQuestion POST /admin/profile-questions
func (h *AdminHandler) CreateQuestion(c *gin.Context) {
var req dto.CreateProfileQuestionRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
q, err := h.adminService.CreateQuestion(req)
if err != nil {
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
return
}
utils.Success(c, http.StatusCreated, "سوال ساخته شد", q)
}
// UpdateQuestion PATCH /admin/profile-questions/:id
func (h *AdminHandler) UpdateQuestion(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
var req dto.UpdateProfileQuestionRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
q, err := h.adminService.UpdateQuestion(uint(id), req)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.Fail(c, http.StatusNotFound, "سوال یافت نشد", nil)
return
}
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
return
}
utils.Success(c, http.StatusOK, "سوال به‌روزرسانی شد", q)
}
// DeleteQuestion DELETE /admin/profile-questions/:id
func (h *AdminHandler) DeleteQuestion(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
if err := h.adminService.DeleteQuestion(uint(id)); err != nil {
utils.Fail(c, http.StatusBadRequest, "حذف سوال با خطا مواجه شد", nil)
return
}
utils.Success(c, http.StatusOK, "سوال حذف شد", nil)
}
+100
View File
@@ -0,0 +1,100 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"dating-app/internal/dto"
"dating-app/internal/service"
"dating-app/internal/utils"
)
type AuthHandler struct {
authService *service.AuthService
}
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
// RequestOTP POST /auth/request-otp
func (h *AuthHandler) RequestOTP(c *gin.Context) {
var req dto.RequestOTPRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
err := h.authService.RequestOTP(req.Phone)
if err != nil {
var stillValid *service.OTPStillValidError
switch {
case errors.As(err, &stillValid):
utils.Fail(c, http.StatusTooManyRequests,
"کد تایید قبلی هنوز معتبر است", gin.H{"remaining_seconds": stillValid.RemainingSeconds})
case errors.Is(err, service.ErrPhoneBlocked):
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
case errors.Is(err, service.ErrSMSSendFailed):
utils.Fail(c, http.StatusServiceUnavailable, err.Error(), nil)
default:
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
}
return
}
utils.Success(c, http.StatusOK, "کد تایید با موفقیت ارسال شد", nil)
}
// VerifyOTP POST /auth/verify-otp
func (h *AuthHandler) VerifyOTP(c *gin.Context) {
var req dto.VerifyOTPRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
user, access, refresh, isNew, err := h.authService.VerifyOTP(req.Phone, req.OTP)
if err != nil {
switch {
case errors.Is(err, service.ErrUserNotFound):
utils.Fail(c, http.StatusNotFound, err.Error(), nil)
case errors.Is(err, service.ErrOTPExpired), errors.Is(err, service.ErrOTPInvalid), errors.Is(err, service.ErrOTPNotRequested):
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
default:
utils.Fail(c, http.StatusInternalServerError, "خطای داخلی سرور", nil)
}
return
}
utils.Success(c, http.StatusOK, "ورود با موفقیت انجام شد", dto.AuthResponse{
Tokens: dto.TokenPair{
AccessToken: access,
RefreshToken: refresh,
},
User: toUserProfile(user),
IsNewUser: isNew,
OnboardingDone: user.IsOnboarded,
})
}
// RefreshToken POST /auth/token/refresh
func (h *AuthHandler) RefreshToken(c *gin.Context) {
var req dto.RefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
access, refresh, err := h.authService.RefreshTokens(req.RefreshToken)
if err != nil {
utils.Fail(c, http.StatusUnauthorized, "رفرش توکن نامعتبر یا منقضی شده است", nil)
return
}
utils.Success(c, http.StatusOK, "توکن جدید صادر شد", dto.TokenPair{
AccessToken: access,
RefreshToken: refresh,
})
}
@@ -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)
}
+91
View File
@@ -0,0 +1,91 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"dating-app/internal/dto"
"dating-app/internal/service"
"dating-app/internal/utils"
)
type ChatHandler struct {
chatService *service.ChatService
}
func NewChatHandler(chatService *service.ChatService) *ChatHandler {
return &ChatHandler{chatService: chatService}
}
// ListMatches GET /matches
func (h *ChatHandler) ListMatches(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
matches, err := h.chatService.ListMatches(user.ID)
if err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت مچ‌ها", nil)
return
}
utils.Success(c, http.StatusOK, "", matches)
}
// ListMessages GET /matches/:id/messages
func (h *ChatHandler) ListMessages(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
matchID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
messages, err := h.chatService.ListMessages(user.ID, uint(matchID))
if err != nil {
h.handleError(c, err)
return
}
utils.Success(c, http.StatusOK, "", messages)
}
// SendMessage POST /matches/:id/messages body: {cipher_text, nonce}
// متن خام پیام هیچ‌وقت اینجا نمی‌رسه؛ کلاینت قبل از ارسال با کلید عمومی
// طرف مقابل (NaCl box / X25519) رمزنگاریش کرده.
func (h *ChatHandler) SendMessage(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
matchID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
var req dto.SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "پیام رمزنگاری‌شده نامعتبر است", err.Error())
return
}
msg, err := h.chatService.SendMessage(user.ID, uint(matchID), req.CipherText, req.Nonce)
if err != nil {
h.handleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "", msg)
}
func (h *ChatHandler) handleError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrNotMatchMember):
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
case errors.Is(err, service.ErrInvalidMessageEncoding):
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
default:
utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil)
}
}
+118
View File
@@ -0,0 +1,118 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"dating-app/internal/dto"
"dating-app/internal/middleware"
"dating-app/internal/models"
"dating-app/internal/repository"
"dating-app/internal/service"
"dating-app/internal/utils"
)
// currentUser کاربر جاری رو از context (که توسط AuthRequired ست شده) استخراج می‌کنه
func currentUser(c *gin.Context) (*models.User, bool) {
val, exists := c.Get(middleware.ContextUserKey)
if !exists {
utils.Fail(c, http.StatusUnauthorized, "احراز هویت لازم است", nil)
return nil, false
}
user, ok := val.(*models.User)
if !ok {
utils.Fail(c, http.StatusInternalServerError, "خطای داخلی سرور", nil)
return nil, false
}
return user, true
}
func toUserProfile(u *models.User) dto.UserProfile {
profile := dto.UserProfile{
ID: u.ID,
Phone: u.Phone,
FirstName: u.FirstName,
CurrentStep: int(u.CurrentStep),
IsOnboarded: u.IsOnboarded,
ApprovalStatus: string(u.ApprovalStatus),
RejectionReason: u.RejectionReason,
IsAdmin: u.IsAdmin,
HeightCM: u.HeightCM,
WeightKG: u.WeightKG,
State: string(u.State),
BlockedReason: u.BlockedReason,
BlockedAt: u.BlockedAt,
Bio: u.Bio,
StreakCount: u.StreakCount,
NotifyMatches: u.NotifyMatches,
NotifyMessages: u.NotifyMessages,
NotifyLikes: u.NotifyLikes,
}
if u.Gender != nil {
g := string(*u.Gender)
profile.Gender = &g
}
if u.BirthDate != nil {
bd := u.BirthDate.Format("2006-01-02")
profile.BirthDate = &bd
}
return profile
}
// selectionSummary یک دسته‌ی انتخابی همراه با عنوان گزینه‌های انتخاب‌شده
type selectionSummary struct {
CategoryKey string `json:"category_key"`
OptionIDs []uint `json:"option_ids"`
Titles []string `json:"titles"`
}
// fullProfilePayload پروفایل کامل یک کاربر (اطلاعات پایه + همه‌ی انتخاب‌ها/پاسخ‌ها)
// رو می‌سازه. هم توسط خود کاربر (GET /profile/me) و هم توسط ادمین موقع بررسی
// درخواست تایید (GET /admin/users/:id) استفاده می‌شه.
func fullProfilePayload(
user *models.User,
optionRepo *repository.OptionRepository,
questionRepo *repository.QuestionRepository,
onboardingService *service.OnboardingService,
photoRepo *repository.PhotoRepository,
) gin.H {
// دسته‌ها از کش خونده می‌شن (تغییر به‌ندرت اتفاق می‌افته)، ولی خودِ
// انتخاب‌های کاربر توی همه‌ی دسته‌ها با یک کوئری واحد گرفته می‌شه؛ قبلاً
// اینجا به‌ازای هر ۶ دسته یک کوئری جدا زده می‌شد.
keyToCategoryID := make(map[models.CategoryKey]uint, len(models.AllCategoryKeys))
categoryIDs := make([]uint, 0, len(models.AllCategoryKeys))
for _, key := range models.AllCategoryKeys {
cat, err := optionRepo.GetCategoryByKey(key)
if err != nil {
continue
}
keyToCategoryID[key] = cat.ID
categoryIDs = append(categoryIDs, cat.ID)
}
selectionsByCategoryID, _ := optionRepo.GetUserSelectionsForCategories(user.ID, categoryIDs)
selections := make([]selectionSummary, 0, len(models.AllCategoryKeys))
for _, key := range models.AllCategoryKeys {
summary := selectionSummary{CategoryKey: string(key)}
for _, s := range selectionsByCategoryID[keyToCategoryID[key]] {
summary.OptionIDs = append(summary.OptionIDs, s.OptionID)
if s.Option != nil {
summary.Titles = append(summary.Titles, s.Option.Title)
}
}
selections = append(selections, summary)
}
answers, _ := questionRepo.GetUserAnswers(user.ID)
photos, _ := photoRepo.ListByUser(user.ID)
return gin.H{
"user": toUserProfile(user),
"onboarding_status": onboardingService.GetStatus(user),
"selections": selections,
"profile_answers": answers,
"photos": photos,
}
}
@@ -0,0 +1,101 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/service"
"dating-app/internal/utils"
)
type DiscoveryHandler struct {
discoveryService *service.DiscoveryService
}
func NewDiscoveryHandler(discoveryService *service.DiscoveryService) *DiscoveryHandler {
return &DiscoveryHandler{discoveryService: discoveryService}
}
// Feed GET /discover?max_distance_km=&min_age=&max_age=
func (h *DiscoveryHandler) Feed(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var maxDistance *float64
if raw := c.Query("max_distance_km"); raw != "" {
if v, err := strconv.ParseFloat(raw, 64); err == nil {
maxDistance = &v
}
}
minAge, _ := strconv.Atoi(c.Query("min_age"))
maxAge, _ := strconv.Atoi(c.Query("max_age"))
candidates, err := h.discoveryService.GetFeed(user, maxDistance, minAge, maxAge)
if err != nil {
h.handleError(c, err)
return
}
utils.Success(c, http.StatusOK, "", candidates)
}
// CandidateDetail GET /discover/:id
func (h *DiscoveryHandler) CandidateDetail(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
targetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
detail, err := h.discoveryService.GetCandidateDetail(user, uint(targetID))
if err != nil {
utils.Fail(c, http.StatusNotFound, "پروفایل یافت نشد", nil)
return
}
utils.Success(c, http.StatusOK, "", detail)
}
// Swipe POST /discover/:id/swipe body: {action: "like"|"pass"|"superlike"}
func (h *DiscoveryHandler) Swipe(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
targetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
var req dto.SwipeRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
result, err := h.discoveryService.Swipe(user, uint(targetID), models.SwipeAction(req.Action))
if err != nil {
h.handleError(c, err)
return
}
utils.Success(c, http.StatusOK, "", result)
}
func (h *DiscoveryHandler) handleError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrGenderNotSet):
utils.Fail(c, http.StatusConflict, err.Error(), nil)
case errors.Is(err, service.ErrCannotSwipeSelf), errors.Is(err, service.ErrAlreadySwiped):
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
default:
utils.Fail(c, http.StatusInternalServerError, "خطای داخلی سرور", nil)
}
}
@@ -0,0 +1,45 @@
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&user_id=42
// اگه user_id داده بشه، فقط فعالیت اخیر همون کاربر برمی‌گرده (برای صفحه‌ی
// جزئیات کاربر توی پنل ادمین)؛ وگرنه آخرین درخواست‌های کل سرور.
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
}
}
if raw := c.Query("user_id"); raw != "" {
if uid, err := strconv.ParseUint(raw, 10, 64); err == nil {
utils.Success(c, http.StatusOK, "", h.store.RecentByUser(uint(uid), limit))
return
}
}
utils.Success(c, http.StatusOK, "", h.store.Recent(limit))
}
@@ -0,0 +1,241 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
"dating-app/internal/service"
"dating-app/internal/utils"
)
type OnboardingHandler struct {
onboardingService *service.OnboardingService
}
func NewOnboardingHandler(onboardingService *service.OnboardingService) *OnboardingHandler {
return &OnboardingHandler{onboardingService: onboardingService}
}
func (h *OnboardingHandler) handleStepError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrStepOutOfOrder):
utils.Fail(c, http.StatusConflict, err.Error(), nil)
case errors.Is(err, service.ErrInvalidBirthDate),
errors.Is(err, service.ErrOptionsNotFound),
errors.Is(err, service.ErrQuestionsInvalid),
errors.Is(err, service.ErrDuplicateQuestion):
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
case errors.Is(err, repository.ErrCategoryNotFound):
utils.Fail(c, http.StatusInternalServerError, "دسته‌ی موردنیاز در سیستم تعریف نشده است", nil)
case errors.Is(err, service.ErrSelectionCount):
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
default:
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
}
}
// Status GET /onboarding/status
func (h *OnboardingHandler) Status(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
utils.Success(c, http.StatusOK, "", h.onboardingService.GetStatus(user))
}
// BasicInfo POST /onboarding/basic-info
func (h *OnboardingHandler) BasicInfo(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.BasicInfoRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
if err := h.onboardingService.SetBasicInfo(user, req); err != nil {
h.handleStepError(c, err)
return
}
utils.Success(c, http.StatusOK, "اطلاعات پایه ثبت شد", h.onboardingService.GetStatus(user))
}
// Photos POST /onboarding/photos — تایید مرحله‌ی عکس‌ها (خودِ آپلود از /profile/photos انجام می‌شه)
func (h *OnboardingHandler) Photos(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
if err := h.onboardingService.ConfirmPhotos(user); err != nil {
h.handleStepError(c, err)
return
}
utils.Success(c, http.StatusOK, "عکس‌ها ثبت شد", h.onboardingService.GetStatus(user))
}
// BodyInfo POST /onboarding/body-info
func (h *OnboardingHandler) BodyInfo(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.BodyInfoRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
if err := h.onboardingService.SetBodyInfo(user, req); err != nil {
h.handleStepError(c, err)
return
}
utils.Success(c, http.StatusOK, "قد و وزن ثبت شد", h.onboardingService.GetStatus(user))
}
// AvailableQuestions GET /onboarding/profile-questions
func (h *OnboardingHandler) AvailableQuestions(c *gin.Context) {
questions, err := h.onboardingService.GetAvailableQuestions()
if err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت سوالات", nil)
return
}
utils.Success(c, http.StatusOK, "", questions)
}
// SetProfileQuestions POST /onboarding/profile-questions
func (h *OnboardingHandler) SetProfileQuestions(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.ProfileQuestionsRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (باید دقیقاً ۳ سوال با پاسخ ارسال شود)", err.Error())
return
}
if err := h.onboardingService.SetProfileQuestions(user, req); err != nil {
h.handleStepError(c, err)
return
}
utils.Success(c, http.StatusOK, "پاسخ‌ها ثبت شد", h.onboardingService.GetStatus(user))
}
// Education POST /onboarding/education
func (h *OnboardingHandler) Education(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.EducationRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
if err := h.onboardingService.SetEducation(user, req); err != nil {
h.handleStepError(c, err)
return
}
utils.Success(c, http.StatusOK, "اطلاعات تحصیلی ثبت شد", h.onboardingService.GetStatus(user))
}
// Occupations POST /onboarding/occupations
func (h *OnboardingHandler) Occupations(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.OptionSelectionRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
if err := h.onboardingService.SetOccupations(user, req.OptionIDs); err != nil {
h.handleStepError(c, err)
return
}
utils.Success(c, http.StatusOK, "فعالیت‌ها ثبت شد", h.onboardingService.GetStatus(user))
}
// RelationshipImportance POST /onboarding/relationship-importance
func (h *OnboardingHandler) RelationshipImportance(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.OptionSelectionRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
if err := h.onboardingService.SetRelationshipImportance(user, req.OptionIDs); err != nil {
h.handleStepError(c, err)
return
}
utils.Success(c, http.StatusOK, "موارد مهم در رابطه ثبت شد", h.onboardingService.GetStatus(user))
}
// Interests POST /onboarding/interests
func (h *OnboardingHandler) Interests(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.OptionSelectionRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
if err := h.onboardingService.SetInterests(user, req.OptionIDs); err != nil {
h.handleStepError(c, err)
return
}
utils.Success(c, http.StatusOK, "علاقه‌مندی‌ها ثبت شد", h.onboardingService.GetStatus(user))
}
// LookingFor POST /onboarding/looking-for
func (h *OnboardingHandler) LookingFor(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.OptionSelectionRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
if err := h.onboardingService.SetLookingFor(user, req.OptionIDs); err != nil {
h.handleStepError(c, err)
return
}
utils.Success(c, http.StatusOK, "هدف شما ثبت شد", h.onboardingService.GetStatus(user))
}
// Options GET /onboarding/options/:category
// category یکی از: education_level, field_of_study, occupation,
// important_in_relationship, interest, looking_for
func (h *OnboardingHandler) Options(c *gin.Context) {
key := models.CategoryKey(c.Param("category"))
valid := false
for _, k := range models.AllCategoryKeys {
if k == key {
valid = true
break
}
}
if !valid {
utils.Fail(c, http.StatusNotFound, "دسته‌ی نامعتبر است", nil)
return
}
options, err := h.onboardingService.GetActiveOptions(key)
if err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزینه‌ها", nil)
return
}
utils.Success(c, http.StatusOK, "", options)
}
+227
View File
@@ -0,0 +1,227 @@
package handler
import (
"errors"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"dating-app/internal/dto"
"dating-app/internal/repository"
"dating-app/internal/service"
"dating-app/internal/utils"
)
type ProfileHandler struct {
optionRepo *repository.OptionRepository
questionRepo *repository.QuestionRepository
photoRepo *repository.PhotoRepository
onboardingService *service.OnboardingService
profileService *service.ProfileService
}
func NewProfileHandler(
optionRepo *repository.OptionRepository,
questionRepo *repository.QuestionRepository,
photoRepo *repository.PhotoRepository,
onboardingService *service.OnboardingService,
profileService *service.ProfileService,
) *ProfileHandler {
return &ProfileHandler{
optionRepo: optionRepo,
questionRepo: questionRepo,
photoRepo: photoRepo,
onboardingService: onboardingService,
profileService: profileService,
}
}
// Me GET /profile/me — پروفایل کامل کاربر شامل تمام انتخاب‌ها و پاسخ‌ها
func (h *ProfileHandler) Me(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
payload := fullProfilePayload(user, h.optionRepo, h.questionRepo, h.onboardingService, h.photoRepo)
utils.Success(c, http.StatusOK, "اطلاعات پروفایل با موفقیت دریافت شد", payload)
}
// UploadPhoto POST /profile/photos (multipart/form-data, فیلد "photo")
func (h *ProfileHandler) UploadPhoto(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
fileHeader, err := c.FormFile("photo")
if err != nil {
utils.Fail(c, http.StatusBadRequest, "فایل عکس ارسال نشده", nil)
return
}
ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
file, err := fileHeader.Open()
if err != nil {
utils.Fail(c, http.StatusBadRequest, "خطا در خواندن فایل", nil)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "خطا در خواندن فایل", nil)
return
}
photo, err := h.profileService.UploadPhoto(user.ID, ext, data)
if err != nil {
h.handlePhotoError(c, err)
return
}
utils.Success(c, http.StatusCreated, "عکس اضافه شد", photo)
}
// DeletePhoto DELETE /profile/photos/:id
func (h *ProfileHandler) DeletePhoto(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
return
}
if err := h.profileService.DeletePhoto(user.ID, uint(id)); err != nil {
h.handlePhotoError(c, err)
return
}
utils.Success(c, http.StatusOK, "عکس حذف شد", nil)
}
func (h *ProfileHandler) handlePhotoError(c *gin.Context, err error) {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil)
case errors.Is(err, service.ErrPhotoNotOwned):
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
case errors.Is(err, service.ErrTooManyPhotos),
errors.Is(err, service.ErrInvalidImage),
errors.Is(err, service.ErrImageTooLarge):
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
default:
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیره‌ی عکس", nil)
}
}
// UpdateLocation PATCH /profile/location
func (h *ProfileHandler) UpdateLocation(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.UpdateLocationRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
if err := h.profileService.UpdateLocation(user, req.Latitude, req.Longitude); err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیره‌ی موقعیت مکانی", nil)
return
}
utils.Success(c, http.StatusOK, "موقعیت مکانی ثبت شد", nil)
}
// UpdateBio PATCH /profile/bio body: {bio}
func (h *ProfileHandler) UpdateBio(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.UpdateBioRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "بیوگرافی نباید بیشتر از ۳۰۰ کاراکتر باشه", err.Error())
return
}
if err := h.profileService.UpdateBio(user, req.Bio); err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیره‌ی بیوگرافی", nil)
return
}
utils.Success(c, http.StatusOK, "بیوگرافی ذخیره شد", nil)
}
// CheckIn POST /profile/checkin — ثبت ورود روزانه برای استریک گیمیفیکیشن
func (h *ProfileHandler) CheckIn(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
result, err := h.profileService.CheckIn(user)
if err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در ثبت ورود روزانه", nil)
return
}
utils.Success(c, http.StatusOK, "", result)
}
// UpdateNotificationSettings PATCH /profile/notifications
func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.UpdateNotificationSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
if err := h.profileService.UpdateNotificationSettings(user, req); err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیره‌ی تنظیمات اعلان", nil)
return
}
utils.Success(c, http.StatusOK, "تنظیمات اعلان ذخیره شد", nil)
}
// DeleteAccount DELETE /profile/me — غیرفعال کردن (soft delete) حساب کاربر
func (h *ProfileHandler) DeleteAccount(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
if err := h.profileService.DeleteAccount(user); err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در حذف حساب", nil)
return
}
utils.Success(c, http.StatusOK, "حساب کاربری غیرفعال شد", nil)
}
// UpdatePublicKey PUT /profile/public-key — ثبت/جایگزینی کلید عمومی X25519
// برای رمزنگاری سرتاسری پیام‌ها. کلاینت این کلید رو خودش تولید می‌کنه؛ کلید
// خصوصی متناظرش هیچ‌وقت به این endpoint (یا هیچ endpoint دیگه‌ای) فرستاده نمی‌شه.
func (h *ProfileHandler) UpdatePublicKey(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.UpdatePublicKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "کلید عمومی نامعتبر است", err.Error())
return
}
if err := h.profileService.UpdatePublicKey(user, req.PublicKey); err != nil {
if errors.Is(err, service.ErrInvalidPublicKey) {
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
return
}
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیره‌ی کلید عمومی", nil)
return
}
utils.Success(c, http.StatusOK, "کلید عمومی ذخیره شد", nil)
}
@@ -0,0 +1,30 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"dating-app/internal/models"
"dating-app/internal/utils"
)
// AdminRequired باید بعد از AuthRequired استفاده بشه. فقط کاربرانی که IsAdmin=true
// دارن اجازه‌ی عبور دارن.
func AdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
userVal, exists := c.Get(ContextUserKey)
if !exists {
utils.Fail(c, http.StatusUnauthorized, "احراز هویت لازم است", nil)
c.Abort()
return
}
user, ok := userVal.(*models.User)
if !ok || !user.IsAdmin {
utils.Fail(c, http.StatusForbidden, "دسترسی فقط برای ادمین است", nil)
c.Abort()
return
}
c.Next()
}
}
@@ -0,0 +1,59 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"dating-app/internal/models"
"dating-app/internal/repository"
"dating-app/internal/utils"
)
const (
ContextUserKey = "current_user"
)
// AuthRequired توکن access رو از هدر Authorization می‌خونه، اعتبارسنجی می‌کنه
// و کاربر مربوطه رو توی context قرار می‌ده.
func AuthRequired(jwtManager *utils.JWTManager, userRepo *repository.UserRepository) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if header == "" || !strings.HasPrefix(header, "Bearer ") {
utils.Fail(c, http.StatusUnauthorized, "توکن احراز هویت ارسال نشده است", nil)
c.Abort()
return
}
tokenStr := strings.TrimPrefix(header, "Bearer ")
claims, err := jwtManager.ParseAccessToken(tokenStr)
if err != nil {
utils.Fail(c, http.StatusUnauthorized, "توکن نامعتبر یا منقضی شده است", nil)
c.Abort()
return
}
user, err := userRepo.FindByID(claims.UserID)
if err != nil {
utils.Fail(c, http.StatusUnauthorized, "کاربر یافت نشد", nil)
c.Abort()
return
}
if !user.IsActive {
utils.Fail(c, http.StatusForbidden, "حساب کاربری غیرفعال است", nil)
c.Abort()
return
}
// اگه کاربر بعد از صادر شدن access token فعلی بلاک شده باشه، نباید تا
// انقضای طبیعی همون توکن بتونه درخواست بزنه؛ این چک همون لحظه قطعش می‌کنه.
if user.State == models.UserStateBlocked {
utils.Fail(c, http.StatusForbidden, "حساب کاربری مسدود شده است", nil)
c.Abort()
return
}
c.Set(ContextUserKey, user)
c.Next()
}
}
+23
View File
@@ -0,0 +1,23 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// CORS یک میان‌افزار ساده برای اجازه دادن به درخواست‌های Cross-Origin
// (مناسب برای توسعه؛ در پروداکشن origin ها رو محدود کن)
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
@@ -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()
}
}
+76
View File
@@ -0,0 +1,76 @@
package models
import "time"
// Photo یکی از عکس‌های پروفایل کاربر
type Photo struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
URL string `gorm:"size:500;not null" json:"url"`
SortOrder int `gorm:"default:0;not null" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
}
func (Photo) TableName() string {
return "photos"
}
// SwipeAction نوع تصمیمی که کاربر روی یک پروفایل دیگه گرفته
type SwipeAction string
const (
SwipeLike SwipeAction = "like"
SwipeSuperlike SwipeAction = "superlike"
SwipePass SwipeAction = "pass"
)
// Swipe ثبت تصمیم یک کاربر (لایک/ردّ) روی یک کاربر دیگه.
// هر جفت (SwiperID, SwipedID) فقط یک بار می‌تونه ثبت بشه؛ توی discovery feed
// کسایی که قبلاً روشون تصمیم گرفته شده دیگه نشون داده نمی‌شن.
type Swipe struct {
ID uint `gorm:"primaryKey" json:"id"`
SwiperID uint `gorm:"not null;uniqueIndex:idx_swiper_swiped" json:"swiper_id"`
SwipedID uint `gorm:"not null;uniqueIndex:idx_swiper_swiped;index" json:"swiped_id"`
Action SwipeAction `gorm:"size:10;not null" json:"action"`
CreatedAt time.Time `json:"created_at"`
}
func (Swipe) TableName() string {
return "swipes"
}
// Match وقتی ساخته می‌شه که دو کاربر متقابلاً همدیگه رو لایک کرده باشن.
// UserOneID همیشه شناسه‌ی کوچیک‌تره (برای جلوگیری از رکورد تکراری در دو جهت).
type Match struct {
ID uint `gorm:"primaryKey" json:"id"`
UserOneID uint `gorm:"not null;uniqueIndex:idx_match_pair" json:"user_one_id"`
UserTwoID uint `gorm:"not null;uniqueIndex:idx_match_pair" json:"user_two_id"`
CreatedAt time.Time `json:"created_at"`
UserOne *User `gorm:"foreignKey:UserOneID" json:"-"`
UserTwo *User `gorm:"foreignKey:UserTwoID" json:"-"`
}
func (Match) TableName() string {
return "matches"
}
// Message یک پیام داخل یک مچ. از رمزنگاری سرتاسری (E2EE) استفاده می‌شه:
// سرور فقط CipherText+Nonce (خروجی رمزنگاری‌شده‌ی سمت کلاینت با NaCl box،
// X25519-XSalsa20-Poly1305) رو ذخیره می‌کنه و هیچ‌وقت متن خام پیام رو نمی‌بینه.
// Text قدیمی (قبل از اضافه شدن E2EE) برای نمایش پیام‌های تاریخی نگه داشته می‌شه؛
// پیام‌های جدید Text خالی دارن و به‌جاش CipherText/Nonce پر می‌شه.
type Message struct {
ID uint `gorm:"primaryKey" json:"id"`
MatchID uint `gorm:"not null;index" json:"match_id"`
SenderID uint `gorm:"not null" json:"sender_id"`
Text string `gorm:"size:2000;not null" json:"text"`
CipherText *string `gorm:"size:12000" json:"cipher_text,omitempty"`
Nonce *string `gorm:"size:64" json:"nonce,omitempty"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func (Message) TableName() string {
return "messages"
}
+82
View File
@@ -0,0 +1,82 @@
package models
import "time"
// CategoryKey شناسه‌ی ثابت هر دسته‌ی لیست انتخابی.
// این کلیدها توی کد استفاده می‌شن (برای هر مرحله از آنبوردینگ)
// ولی گزینه‌های داخل هر دسته کاملاً از پنل ادمین قابل مدیریت هستن.
type CategoryKey string
const (
CategoryEducationLevel CategoryKey = "education_level"
CategoryFieldOfStudy CategoryKey = "field_of_study"
CategoryOccupation CategoryKey = "occupation"
CategoryImportantInRelationship CategoryKey = "important_in_relationship"
CategoryInterest CategoryKey = "interest"
CategoryLookingFor CategoryKey = "looking_for"
)
// AllCategoryKeys برای seed کردن و اعتبارسنجی
var AllCategoryKeys = []CategoryKey{
CategoryEducationLevel,
CategoryFieldOfStudy,
CategoryOccupation,
CategoryImportantInRelationship,
CategoryInterest,
CategoryLookingFor,
}
// Category یک دسته‌ی لیست انتخابی (مثلاً «علاقه‌مندی‌ها»).
// MinSelect/MaxSelect تعداد مجاز انتخاب رو مشخص می‌کنه (MaxSelect=0 یعنی نامحدود).
type Category struct {
ID uint `gorm:"primaryKey" json:"id"`
Key CategoryKey `gorm:"size:50;uniqueIndex;not null" json:"key"`
Title string `gorm:"size:150;not null" json:"title"`
// توجه: عمداً تگ gorm:"default:..." روی این دو فیلد گذاشته نشده.
// GORM مقادیر صفر (zero value) رو برای ستون‌هایی که تگ default دارن از
// INSERT حذف می‌کنه (چون نمی‌تونه "صفر عمدی" رو از "ست نشده" تشخیص بده)
// و این یعنی MaxSelect=0 (به‌معنی نامحدود) به‌جای ذخیره‌شدن، با دیفالت
// دیتابیس جایگزین می‌شد. چون همیشه این دو مقدار رو صریح ست می‌کنیم
// (در seed و در آپدیت ادمین)، نیازی به دیفالت دیتابیسی نیست.
MinSelect int `gorm:"not null" json:"min_select"`
MaxSelect int `gorm:"not null" json:"max_select"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Options []Option `gorm:"foreignKey:CategoryID" json:"options,omitempty"`
}
func (Category) TableName() string {
return "categories"
}
// Option یک آیتم قابل‌انتخاب داخل یک دسته (مدیریت‌شده توسط ادمین)
type Option struct {
ID uint `gorm:"primaryKey" json:"id"`
CategoryID uint `gorm:"not null;index" json:"category_id"`
Title string `gorm:"size:150;not null" json:"title"`
SortOrder int `gorm:"default:0;not null" json:"sort_order"`
IsActive bool `gorm:"default:true;not null" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Option) TableName() string {
return "options"
}
// UserOptionSelection انتخاب‌های کاربر از یک دسته‌ی مشخص.
// برای هر مرحله (مثل علاقه‌مندی‌ها) چند ردیف از این جدول ساخته می‌شه.
type UserOptionSelection struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;uniqueIndex:idx_user_category_option" json:"user_id"`
CategoryID uint `gorm:"not null;uniqueIndex:idx_user_category_option;index" json:"category_id"`
OptionID uint `gorm:"not null;uniqueIndex:idx_user_category_option" json:"option_id"`
CreatedAt time.Time `json:"created_at"`
Option *Option `gorm:"foreignKey:OptionID" json:"option,omitempty"`
}
func (UserOptionSelection) TableName() string {
return "user_option_selections"
}
@@ -0,0 +1,35 @@
package models
import "time"
// ProfileQuestion سوالاتی که ادمین از پیش در پنل وارد می‌کند
// (مثلاً «بزرگ‌ترین درسی که از زندگی گرفتی چیه؟») و کاربر باید
// دقیقاً ۳ تا از آن‌ها را انتخاب و برایش پاسخ بنویسد.
type ProfileQuestion struct {
ID uint `gorm:"primaryKey" json:"id"`
Text string `gorm:"size:255;not null" json:"text"`
SortOrder int `gorm:"default:0;not null" json:"sort_order"`
IsActive bool `gorm:"default:true;not null" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ProfileQuestion) TableName() string {
return "profile_questions"
}
// UserProfileAnswer پاسخ کاربر به یکی از سوالات انتخابی
type UserProfileAnswer struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;uniqueIndex:idx_user_question" json:"user_id"`
QuestionID uint `gorm:"not null;uniqueIndex:idx_user_question" json:"question_id"`
Answer string `gorm:"type:text;not null" json:"answer"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Question *ProfileQuestion `gorm:"foreignKey:QuestionID" json:"question,omitempty"`
}
func (UserProfileAnswer) TableName() string {
return "user_profile_answers"
}
+17
View File
@@ -0,0 +1,17 @@
package models
import "time"
// Report یک یادداشت/پرچم نظارتی که ادمین روی یک کاربر (یا یک عکس مشخص از
// اون کاربر) ثبت می‌کنه؛ برای پیگیری سابقه‌ی نظارتی، مستقل از بلاک/حذف کردن.
type Report struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
PhotoID *uint `json:"photo_id,omitempty"`
Reason string `gorm:"type:text;not null" json:"reason"`
CreatedAt time.Time `json:"created_at"`
}
func (Report) TableName() string {
return "reports"
}
+134
View File
@@ -0,0 +1,134 @@
package models
import "time"
// Gender جنسیت کاربر
type Gender string
const (
GenderMale Gender = "male"
GenderFemale Gender = "female"
)
// UserState وضعیت کلی کاربر
type UserState string
const (
UserStateInactive UserState = "inactive"
UserStateActive UserState = "active"
UserStateBlocked UserState = "blocked"
)
// OnboardingStep مرحله‌ی فعلی کاربر در فرآیند تکمیل پروفایل.
// هر مقدار یعنی «این مرحله هنوز باید تکمیل بشه». وقتی به StepCompleted
// می‌رسه یعنی کاربر تمام مراحل رو طی کرده.
type OnboardingStep int
const (
StepBasicInfo OnboardingStep = iota + 1
StepPhotos
StepBodyInfo
StepProfileQuestions
StepEducation
StepOccupations
StepRelationshipImportance
StepInterests
StepLookingFor
StepCompleted
)
// ApprovalStatus وضعیت بررسی پروفایل توسط ادمین (بعد از تکمیل آنبوردینگ)
type ApprovalStatus string
const (
ApprovalPending ApprovalStatus = "pending"
ApprovalApproved ApprovalStatus = "approved"
ApprovalRejected ApprovalStatus = "rejected"
)
// StepInfo برای پاسخ‌های API که وضعیت هر مرحله رو توضیح می‌ده
type StepInfo struct {
Step OnboardingStep `json:"step"`
Key string `json:"key"`
Done bool `json:"done"`
}
// ordered لیست ترتیب مراحل آنبوردینگ (برای ساخت وضعیت کامل)
var OnboardingStepOrder = []struct {
Step OnboardingStep
Key string
}{
{StepBasicInfo, "basic_info"},
{StepPhotos, "photos"},
{StepBodyInfo, "body_info"},
{StepProfileQuestions, "profile_questions"},
{StepEducation, "education"},
{StepOccupations, "occupations"},
{StepRelationshipImportance, "relationship_importance"},
{StepInterests, "interests"},
{StepLookingFor, "looking_for"},
}
// User کاربر اصلی سیستم. احراز هویت فقط با شماره موبایل + OTP انجام می‌شود.
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Phone string `gorm:"size:11;uniqueIndex;not null" json:"phone"`
State UserState `gorm:"size:10;default:inactive;not null" json:"state"`
IsAdmin bool `gorm:"default:false;not null" json:"is_admin"`
IsActive bool `gorm:"default:true;not null" json:"is_active"`
// -------- بلاک شدن توسط ادمین --------
BlockedReason *string `json:"blocked_reason,omitempty"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
// فیلدهای OTP - هش می‌شوند و هرگز در پاسخ API برنمی‌گردند
OTPCode *string `gorm:"size:128" json:"-"`
OTPExpiry *time.Time `json:"-"`
// -------- اطلاعات پروفایل (پر می‌شوند طی فرآیند آنبوردینگ) --------
Gender *Gender `gorm:"size:10" json:"gender,omitempty"`
FirstName string `gorm:"size:100" json:"first_name,omitempty"`
BirthDate *time.Time `json:"birth_date,omitempty"`
HeightCM *int `json:"height_cm,omitempty"`
WeightKG *int `json:"weight_kg,omitempty"`
Bio *string `gorm:"size:300" json:"bio,omitempty"`
// -------- گیمیفیکیشن: استریک روزانه (چند روز پشت‌سرهم اپ باز شده) --------
StreakCount int `gorm:"default:0;not null" json:"streak_count"`
LastActiveDate *time.Time `json:"last_active_date,omitempty"`
// -------- ترجیحات اعلان --------
// فعلاً فقط ذخیره می‌شن (زیرساخت ارسال واقعی اعلان هنوز پیاده نشده)؛ وقتی
// اون سیستم اضافه شد، همین مقادیر رو برای تصمیم به ارسال یا نه می‌خونه.
NotifyMatches bool `gorm:"default:true;not null" json:"notify_matches"`
NotifyMessages bool `gorm:"default:true;not null" json:"notify_messages"`
NotifyLikes bool `gorm:"default:true;not null" json:"notify_likes"`
// -------- موقعیت مکانی (برای محاسبه‌ی فاصله‌ی تقریبی در دوستیابی) --------
Latitude *float64 `json:"-"`
Longitude *float64 `json:"-"`
// -------- کلید عمومی رمزنگاری سرتاسری پیام‌ها (E2EE) --------
// کلید خصوصی هیچ‌وقت به سرور نمی‌رسه و فقط روی دستگاه کاربر (localStorage)
// ذخیره می‌شه؛ سرور فقط این کلید عمومی رو نگه می‌داره تا طرف مقابل بتونه
// باهاش پیام رمزنگاری کنه. کلید X25519 هست، base64-encoded (۳۲ بایت خام).
PublicKey *string `gorm:"size:64" json:"public_key,omitempty"`
// -------- وضعیت آنبوردینگ --------
CurrentStep OnboardingStep `gorm:"default:1;not null" json:"current_step"`
IsOnboarded bool `gorm:"default:false;not null" json:"is_onboarded"`
// -------- بررسی ادمین (بعد از تکمیل آنبوردینگ) --------
// تا وقتی approved نشه، کاربر اجازه‌ی ورود به صفحه‌ی دوستیابی رو نداره.
ApprovalStatus ApprovalStatus `gorm:"size:10;default:pending;not null" json:"approval_status"`
RejectionReason *string `json:"rejection_reason,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
LastLogin *time.Time `json:"last_login,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (User) TableName() string {
return "users"
}
+157
View File
@@ -0,0 +1,157 @@
// 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
}
// RecentByUser مثل Recent ولی فقط درخواست‌های یک کاربر مشخص رو برمی‌گردونه
// (برای دیدن فعالیت اخیر یک کاربر خاص از صفحه‌ی جزئیاتش توی پنل ادمین).
// کل بافر رو می‌گرده (نه فقط آخرین n)، چون فیلتر شده و ممکنه درخواست‌های آن
// کاربر پراکنده باشن.
func (s *Store) RecentByUser(userID uint, n int) []RequestRecord {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]RequestRecord, 0, n)
idx := s.next - 1
for i := 0; i < s.count && len(out) < n; i++ {
if idx < 0 {
idx = bufferSize - 1
}
rec := s.buf[idx]
if rec.UserID != nil && *rec.UserID == userID {
out = append(out, rec)
}
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(),
}
}
+93
View File
@@ -0,0 +1,93 @@
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))
}
}
func TestStore_RecentByUser(t *testing.T) {
s := NewStore()
uidA := uint(1)
uidB := uint(2)
s.Record(RequestRecord{Time: time.Now(), Path: "/a1", UserID: &uidA})
s.Record(RequestRecord{Time: time.Now(), Path: "/b1", UserID: &uidB})
s.Record(RequestRecord{Time: time.Now(), Path: "/a2", UserID: &uidA})
s.Record(RequestRecord{Time: time.Now(), Path: "/none"}) // بدون کاربر (مثل OTP)
got := s.RecentByUser(uidA, 10)
if len(got) != 2 {
t.Fatalf("expected 2 records for user A, got %d", len(got))
}
if got[0].Path != "/a2" || got[1].Path != "/a1" {
t.Fatalf("expected newest-first order for user A, got %+v", got)
}
}
@@ -0,0 +1,88 @@
package repository
import (
"errors"
"gorm.io/gorm"
"dating-app/internal/models"
)
type MatchRepository struct {
db *gorm.DB
}
func NewMatchRepository(db *gorm.DB) *MatchRepository {
return &MatchRepository{db: db}
}
// normalizePair همیشه شناسه‌ی کوچیک‌تر رو اول می‌ذاره تا هر جفت فقط یک رکورد داشته باشه
func normalizePair(a, b uint) (uint, uint) {
if a < b {
return a, b
}
return b, a
}
// FindOrCreate اگه مچ بین این دو کاربر از قبل وجود داشته باشه همونو برمی‌گردونه،
// وگرنه می‌سازتش. خروجی دوم true یعنی همین الان تازه ساخته شده.
func (r *MatchRepository) FindOrCreate(userA, userB uint) (*models.Match, bool, error) {
one, two := normalizePair(userA, userB)
var match models.Match
err := r.db.Where("user_one_id = ? AND user_two_id = ?", one, two).First(&match).Error
if err == nil {
return &match, false, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
match = models.Match{UserOneID: one, UserTwoID: two}
if err := r.db.Create(&match).Error; err != nil {
return nil, false, err
}
return &match, true, nil
}
func (r *MatchRepository) FindByID(id uint) (*models.Match, error) {
var match models.Match
if err := r.db.First(&match, id).Error; err != nil {
return nil, err
}
return &match, nil
}
// ListByUser مچ‌های کاربر رو از جدیدترین به قدیمی‌ترین برمی‌گردونه
func (r *MatchRepository) ListByUser(userID uint) ([]models.Match, error) {
var matches []models.Match
err := r.db.
Where("user_one_id = ? OR user_two_id = ?", userID, userID).
Order("created_at desc").
Find(&matches).Error
return matches, err
}
// ListAllPaginated همه‌ی مچ‌های اپ رو صفحه‌بندی‌شده برمی‌گردونه (برای پنل ادمین)
func (r *MatchRepository) ListAllPaginated(limit, offset int) ([]models.Match, int64, error) {
var total int64
if err := r.db.Model(&models.Match{}).Count(&total).Error; err != nil {
return nil, 0, err
}
var matches []models.Match
err := r.db.Order("created_at desc").Limit(limit).Offset(offset).Find(&matches).Error
return matches, total, err
}
// OtherUserID شناسه‌ی طرف مقابل کاربر جاری رو توی یک مچ برمی‌گردونه
func OtherUserID(match *models.Match, currentUserID uint) uint {
if match.UserOneID == currentUserID {
return match.UserTwoID
}
return match.UserOneID
}
// IsMember یعنی این کاربر عضو این مچه
func IsMember(match *models.Match, userID uint) bool {
return match.UserOneID == userID || match.UserTwoID == userID
}
@@ -0,0 +1,61 @@
package repository
import (
"gorm.io/gorm"
"dating-app/internal/models"
)
type MessageRepository struct {
db *gorm.DB
}
func NewMessageRepository(db *gorm.DB) *MessageRepository {
return &MessageRepository{db: db}
}
func (r *MessageRepository) Create(msg *models.Message) error {
return r.db.Create(msg).Error
}
func (r *MessageRepository) ListByMatch(matchID uint) ([]models.Message, error) {
var messages []models.Message
err := r.db.Where("match_id = ?", matchID).Order("created_at asc").Find(&messages).Error
return messages, err
}
// LastMessage آخرین پیام یک مچ رو برمی‌گردونه (برای پیش‌نمایش لیست گفتگوها)
func (r *MessageRepository) LastMessage(matchID uint) (*models.Message, error) {
var msg models.Message
err := r.db.Where("match_id = ?", matchID).Order("created_at desc").First(&msg).Error
if err != nil {
return nil, err
}
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
}
@@ -0,0 +1,225 @@
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
activeOptionsByKey *cache.TTLCache[models.CategoryKey, []models.Option]
categoryByKey *cache.TTLCache[models.CategoryKey, models.Category]
}
func NewOptionRepository(db *gorm.DB) *OptionRepository {
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 {
return nil, ErrCategoryNotFound
}
if err != nil {
return nil, err
}
r.categoryByKey.Set(key, cat)
return &cat, nil
}
func (r *OptionRepository) UpdateCategory(cat *models.Category) 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
}
var options []models.Option
err = r.db.Where("category_id = ? AND is_active = ?", cat.ID, true).
Order("sort_order asc, id asc").
Find(&options).Error
if err != nil {
return nil, err
}
r.activeOptionsByKey.Set(key, options)
return options, nil
}
// ListOptionsByCategoryKeyAdmin تمام گزینه‌ها (فعال و غیرفعال) رو برمی‌گردونه - برای پنل ادمین
func (r *OptionRepository) ListOptionsByCategoryKeyAdmin(key models.CategoryKey) ([]models.Option, error) {
cat, err := r.GetCategoryByKey(key)
if err != nil {
return nil, err
}
var options []models.Option
err = r.db.Where("category_id = ?", cat.ID).
Order("sort_order asc, id asc").
Find(&options).Error
return options, err
}
func (r *OptionRepository) CreateOption(opt *models.Option) 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) {
var opt models.Option
err := r.db.First(&opt, id).Error
if err != nil {
return nil, err
}
return &opt, nil
}
func (r *OptionRepository) UpdateOption(opt *models.Option) error {
if err := r.db.Save(opt).Error; err != nil {
return err
}
r.activeOptionsByKey.Clear()
return nil
}
func (r *OptionRepository) DeleteOption(id uint) error {
if err := r.db.Delete(&models.Option{}, id).Error; err != nil {
return err
}
r.activeOptionsByKey.Clear()
return nil
}
// FindActiveOptionsByIDsInCategory فقط گزینه‌های فعالی که در این دسته هستند و id شون
// داخل لیست داده‌شده است رو برمی‌گردونه (برای اعتبارسنجی انتخاب کاربر)
func (r *OptionRepository) FindActiveOptionsByIDsInCategory(categoryID uint, ids []uint) ([]models.Option, error) {
var options []models.Option
err := r.db.Where("category_id = ? AND is_active = ? AND id IN ?", categoryID, true, ids).
Find(&options).Error
return options, err
}
// ReplaceUserSelections تمام انتخاب‌های قبلی کاربر در یک دسته را با لیست جدید جایگزین می‌کند (تراکنشی)
func (r *OptionRepository) ReplaceUserSelections(userID, categoryID uint, optionIDs []uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ? AND category_id = ?", userID, categoryID).
Delete(&models.UserOptionSelection{}).Error; err != nil {
return err
}
for _, optID := range optionIDs {
sel := models.UserOptionSelection{
UserID: userID,
CategoryID: categoryID,
OptionID: optID,
}
if err := tx.Create(&sel).Error; err != nil {
return err
}
}
return nil
})
}
// GetUserSelections انتخاب‌های کاربر در یک دسته را همراه با اطلاعات گزینه برمی‌گرداند
func (r *OptionRepository) GetUserSelections(userID, categoryID uint) ([]models.UserOptionSelection, error) {
var selections []models.UserOptionSelection
err := r.db.Preload("Option").
Where("user_id = ? AND category_id = ?", userID, categoryID).
Find(&selections).Error
return selections, err
}
// CountUserSelections تعداد انتخاب‌های ثبت‌شده‌ی کاربر در یک دسته را برمی‌گرداند
// (برای بررسی این‌که مرحله تکمیل شده یا نه)
func (r *OptionRepository) CountUserSelections(userID, categoryID uint) (int64, error) {
var count int64
err := r.db.Model(&models.UserOptionSelection{}).
Where("user_id = ? AND category_id = ?", userID, categoryID).
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
}
@@ -0,0 +1,64 @@
package repository
import (
"gorm.io/gorm"
"dating-app/internal/models"
)
type PhotoRepository struct {
db *gorm.DB
}
func NewPhotoRepository(db *gorm.DB) *PhotoRepository {
return &PhotoRepository{db: db}
}
func (r *PhotoRepository) ListByUser(userID uint) ([]models.Photo, error) {
var photos []models.Photo
err := r.db.Where("user_id = ?", userID).Order("sort_order asc, id asc").Find(&photos).Error
return photos, err
}
func (r *PhotoRepository) ListByUserIDs(userIDs []uint) ([]models.Photo, error) {
var photos []models.Photo
if len(userIDs) == 0 {
return photos, nil
}
err := r.db.Where("user_id IN ?", userIDs).Order("sort_order asc, id asc").Find(&photos).Error
return photos, err
}
// ListAllPaginated همه‌ی عکس‌های آپلودشده‌ی اپ رو صفحه‌بندی‌شده، جدیدترین اول
// برمی‌گردونه (برای نظارت محتوایی پنل ادمین).
func (r *PhotoRepository) ListAllPaginated(limit, offset int) ([]models.Photo, int64, error) {
var total int64
if err := r.db.Model(&models.Photo{}).Count(&total).Error; err != nil {
return nil, 0, err
}
var photos []models.Photo
err := r.db.Order("created_at desc").Limit(limit).Offset(offset).Find(&photos).Error
return photos, total, err
}
func (r *PhotoRepository) CountByUser(userID uint) (int64, error) {
var count int64
err := r.db.Model(&models.Photo{}).Where("user_id = ?", userID).Count(&count).Error
return count, err
}
func (r *PhotoRepository) Create(photo *models.Photo) error {
return r.db.Create(photo).Error
}
func (r *PhotoRepository) FindByID(id uint) (*models.Photo, error) {
var photo models.Photo
if err := r.db.First(&photo, id).Error; err != nil {
return nil, err
}
return &photo, nil
}
func (r *PhotoRepository) Delete(id uint) error {
return r.db.Delete(&models.Photo{}, id).Error
}
@@ -0,0 +1,111 @@
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
activeCache *cache.TTLCache[string, []models.ProfileQuestion]
}
func NewQuestionRepository(db *gorm.DB) *QuestionRepository {
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
if err != nil {
return nil, err
}
r.activeCache.Set(activeQuestionsCacheKey, qs)
return qs, nil
}
func (r *QuestionRepository) ListAllAdmin() ([]models.ProfileQuestion, error) {
var qs []models.ProfileQuestion
err := r.db.Order("sort_order asc, id asc").Find(&qs).Error
return qs, err
}
func (r *QuestionRepository) FindByID(id uint) (*models.ProfileQuestion, error) {
var q models.ProfileQuestion
err := r.db.First(&q, id).Error
if err != nil {
return nil, err
}
return &q, nil
}
func (r *QuestionRepository) FindActiveByIDs(ids []uint) ([]models.ProfileQuestion, error) {
var qs []models.ProfileQuestion
err := r.db.Where("is_active = ? AND id IN ?", true, ids).Find(&qs).Error
return qs, err
}
func (r *QuestionRepository) Create(q *models.ProfileQuestion) 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 {
if err := r.db.Save(q).Error; err != nil {
return err
}
r.activeCache.Clear()
return nil
}
func (r *QuestionRepository) Delete(id uint) error {
if err := r.db.Delete(&models.ProfileQuestion{}, id).Error; err != nil {
return err
}
r.activeCache.Clear()
return nil
}
// ReplaceUserAnswers پاسخ‌های قبلی کاربر رو با پاسخ‌های جدید جایگزین می‌کنه (تراکنشی)
func (r *QuestionRepository) ReplaceUserAnswers(userID uint, answers []models.UserProfileAnswer) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&models.UserProfileAnswer{}).Error; err != nil {
return err
}
for i := range answers {
answers[i].UserID = userID
if err := tx.Create(&answers[i]).Error; err != nil {
return err
}
}
return nil
})
}
func (r *QuestionRepository) GetUserAnswers(userID uint) ([]models.UserProfileAnswer, error) {
var answers []models.UserProfileAnswer
err := r.db.Preload("Question").Where("user_id = ?", userID).Find(&answers).Error
return answers, err
}
func (r *QuestionRepository) CountUserAnswers(userID uint) (int64, error) {
var count int64
err := r.db.Model(&models.UserProfileAnswer{}).Where("user_id = ?", userID).Count(&count).Error
return count, err
}
@@ -0,0 +1,53 @@
package repository
import (
"gorm.io/gorm"
"dating-app/internal/models"
)
type ReportRepository struct {
db *gorm.DB
}
func NewReportRepository(db *gorm.DB) *ReportRepository {
return &ReportRepository{db: db}
}
func (r *ReportRepository) Create(report *models.Report) error {
return r.db.Create(report).Error
}
func (r *ReportRepository) ListByUser(userID uint) ([]models.Report, error) {
var reports []models.Report
err := r.db.Where("user_id = ?", userID).Order("created_at desc").Find(&reports).Error
return reports, err
}
// CountsByUserIDs تعداد گزارش‌های ثبت‌شده روی چند کاربر رو با هم، در یک
// کوئری برمی‌گردونه (برای نشون دادن نشان تعداد گزارش روی لیست کاربران، بدون
// یک کوئری جدا به‌ازای هر کاربر).
func (r *ReportRepository) CountsByUserIDs(userIDs []uint) (map[uint]int64, error) {
result := make(map[uint]int64, len(userIDs))
if len(userIDs) == 0 {
return result, nil
}
type row struct {
UserID uint
Count int64
}
var rows []row
err := r.db.Model(&models.Report{}).
Select("user_id, count(*) as count").
Where("user_id IN ?", userIDs).
Group("user_id").
Find(&rows).Error
if err != nil {
return nil, err
}
for _, rr := range rows {
result[rr.UserID] = rr.Count
}
return result, nil
}
@@ -0,0 +1,42 @@
package repository
import (
"errors"
"gorm.io/gorm"
"dating-app/internal/models"
)
type SwipeRepository struct {
db *gorm.DB
}
func NewSwipeRepository(db *gorm.DB) *SwipeRepository {
return &SwipeRepository{db: db}
}
func (r *SwipeRepository) Create(swipe *models.Swipe) error {
return r.db.Create(swipe).Error
}
// HasLiked یعنی swiperID قبلاً swipedID رو لایک/سوپرلایک کرده
func (r *SwipeRepository) HasLiked(swiperID, swipedID uint) (bool, error) {
var swipe models.Swipe
err := r.db.
Where("swiper_id = ? AND swiped_id = ? AND action IN ?", swiperID, swipedID, []models.SwipeAction{models.SwipeLike, models.SwipeSuperlike}).
First(&swipe).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (r *SwipeRepository) Exists(swiperID, swipedID uint) (bool, error) {
var count int64
err := r.db.Model(&models.Swipe{}).Where("swiper_id = ? AND swiped_id = ?", swiperID, swipedID).Count(&count).Error
return count > 0, err
}
@@ -0,0 +1,135 @@
package repository
import (
"time"
"gorm.io/gorm"
"dating-app/internal/models"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) FindByPhone(phone string) (*models.User, error) {
var user models.User
err := r.db.Where("phone = ?", phone).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) FindByID(id uint) (*models.User, error) {
var user models.User
err := r.db.First(&user, id).Error
if err != nil {
return nil, err
}
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
err := r.db.Where("phone = ?", phone).First(&user).Error
if err == nil {
return &user, false, nil
}
if err != gorm.ErrRecordNotFound {
return nil, false, err
}
user = models.User{
Phone: phone,
State: models.UserStateInactive,
IsActive: true,
CurrentStep: models.StepBasicInfo,
}
if err := r.db.Create(&user).Error; err != nil {
return nil, false, err
}
return &user, true, nil
}
// ListPendingApproval کاربرانی که آنبوردینگ رو تکمیل کردن و منتظر بررسی ادمین‌اند
// رو به ترتیب قدیمی‌ترین اول برمی‌گردونه.
func (r *UserRepository) ListPendingApproval() ([]models.User, error) {
var users []models.User
err := r.db.
Where("is_onboarded = ? AND approval_status = ?", true, models.ApprovalPending).
Order("updated_at asc").
Find(&users).Error
return users, err
}
// ListAll همه‌ی کاربران رو صفحه‌بندی‌شده برمی‌گردونه (برای صفحه‌ی «همه‌ی
// کاربران» پنل ادمین)؛ search روی شماره موبایل و اسم کوچیک می‌گرده.
func (r *UserRepository) ListAll(search string, limit, offset int) ([]models.User, int64, error) {
query := r.db.Model(&models.User{})
if search != "" {
like := "%" + search + "%"
query = query.Where("phone ILIKE ? OR first_name ILIKE ?", like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var users []models.User
err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&users).Error
return users, total, err
}
// ListDiscoverable کاندیداهای بخش دوستیابی رو برمی‌گردونه: تاییدشده، فعال،
// جنسیت مخالف کاربر (به‌عنوان ساده‌ترین فرض پیش‌فرض؛ فیلد ترجیح جنسیتی/گرایش
// هنوز توی سیستم وجود نداره) و کسایی که قبلاً روشون سواپ نزده.
// چون فاصله باید توی Go محاسبه بشه (haversine)، یک batch بزرگ‌تر از حد
// نهایی برمی‌گردونه تا بعد از فیلتر فاصله همچنان گزینه‌ی کافی بمونه.
func (r *UserRepository) ListDiscoverable(currentUserID uint, oppositeGender models.Gender, minAge, maxAge int, batchSize int) ([]models.User, error) {
var users []models.User
now := time.Now()
minBirth := now.AddDate(-maxAge-1, 0, 1)
maxBirth := now.AddDate(-minAge, 0, 0)
err := r.db.
Where("id != ?", currentUserID).
Where("is_onboarded = ? AND approval_status = ? AND is_active = ?", true, models.ApprovalApproved, true).
Where("gender = ?", oppositeGender).
Where("birth_date BETWEEN ? AND ?", minBirth, maxBirth).
Where("NOT EXISTS (SELECT 1 FROM swipes s WHERE s.swiper_id = ? AND s.swiped_id = users.id)", currentUserID).
Order("id asc").
Limit(batchSize).
Find(&users).Error
return users, err
}
func (r *UserRepository) Save(user *models.User) error {
return r.db.Save(user).Error
}
func (r *UserRepository) UpdateFields(user *models.User, fields map[string]interface{}) error {
return r.db.Model(user).Updates(fields).Error
}
func (r *UserRepository) DB() *gorm.DB {
return r.db
}
+144
View File
@@ -0,0 +1,144 @@
package router
import (
"github.com/gin-gonic/gin"
"dating-app/internal/handler"
"dating-app/internal/middleware"
"dating-app/internal/repository"
"dating-app/internal/utils"
)
type Handlers struct {
Auth *handler.AuthHandler
Onboarding *handler.OnboardingHandler
Profile *handler.ProfileHandler
Admin *handler.AdminHandler
Discovery *handler.DiscoveryHandler
Chat *handler.ChatHandler
Monitoring *handler.MonitoringHandler
Backup *handler.BackupHandler
}
func Setup(
engine *gin.Engine,
h Handlers,
jwtManager *utils.JWTManager,
userRepo *repository.UserRepository,
) {
engine.Use(middleware.CORS())
engine.Use(middleware.Gzip())
engine.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// فایل‌های عکس آپلودشده (خارج از /api/v1، چون فایل استاتیکه نه API)
engine.Static("/uploads", "./uploads")
api := engine.Group("/api/v1")
// -------- احراز هویت (عمومی) --------
// درخواست OTP هزینه‌ی پیامک داره و هدف مناسبی برای اسپم/brute-force هست؛
// حداکثر ۵ درخواست، بعد هر ثانیه یک توکن شارژ می‌شه (یعنی حدود ۵ تا در دقیقه‌ی اول و بعدش پیوسته).
otpRateLimit := middleware.RateLimiter(5, 1.0/12.0)
auth := api.Group("/auth")
{
auth.POST("/request-otp", otpRateLimit, h.Auth.RequestOTP)
auth.POST("/verify-otp", otpRateLimit, h.Auth.VerifyOTP)
auth.POST("/token/refresh", h.Auth.RefreshToken)
}
authRequired := middleware.AuthRequired(jwtManager, userRepo)
// -------- پروفایل --------
profile := api.Group("/profile", authRequired)
{
profile.GET("/me", h.Profile.Me)
profile.POST("/photos", h.Profile.UploadPhoto)
profile.DELETE("/photos/:id", h.Profile.DeletePhoto)
profile.PATCH("/location", h.Profile.UpdateLocation)
profile.PATCH("/bio", h.Profile.UpdateBio)
// استریک روزانه (گیمیفیکیشن): هر بار اپ باز می‌شه یه بار صدا زده می‌شه
profile.POST("/checkin", h.Profile.CheckIn)
profile.PATCH("/notifications", h.Profile.UpdateNotificationSettings)
profile.DELETE("/me", h.Profile.DeleteAccount)
profile.PUT("/public-key", h.Profile.UpdatePublicKey)
}
// -------- آنبوردینگ (نیاز به ورود) --------
onboarding := api.Group("/onboarding", authRequired)
{
onboarding.GET("/status", h.Onboarding.Status)
onboarding.POST("/basic-info", h.Onboarding.BasicInfo)
onboarding.POST("/photos", h.Onboarding.Photos)
onboarding.POST("/body-info", h.Onboarding.BodyInfo)
onboarding.GET("/profile-questions", h.Onboarding.AvailableQuestions)
onboarding.POST("/profile-questions", h.Onboarding.SetProfileQuestions)
onboarding.POST("/education", h.Onboarding.Education)
onboarding.POST("/occupations", h.Onboarding.Occupations)
onboarding.POST("/relationship-importance", h.Onboarding.RelationshipImportance)
onboarding.POST("/interests", h.Onboarding.Interests)
onboarding.POST("/looking-for", h.Onboarding.LookingFor)
// لیست گزینه‌های فعال هر دسته، برای نمایش در فرم هر مرحله
// category یکی از: education_level, field_of_study, occupation,
// important_in_relationship, interest, looking_for
onboarding.GET("/options/:category", h.Onboarding.Options)
}
// -------- دوستیابی (نیاز به ورود) --------
api.GET("/discover", authRequired, h.Discovery.Feed)
api.GET("/discover/:id", authRequired, h.Discovery.CandidateDetail)
api.POST("/discover/:id/swipe", authRequired, h.Discovery.Swipe)
// -------- مچ‌ها و گفتگو (نیاز به ورود) --------
matches := api.Group("/matches", authRequired)
{
matches.GET("", h.Chat.ListMatches)
matches.GET("/:id/messages", h.Chat.ListMessages)
matches.POST("/:id/messages", h.Chat.SendMessage)
}
// -------- پنل ادمین (نیاز به ورود + نقش ادمین) --------
admin := api.Group("/admin", authRequired, middleware.AdminRequired())
{
admin.GET("/users", h.Admin.ListAllUsers)
admin.GET("/users/pending", h.Admin.ListPendingUsers)
admin.GET("/users/:id", h.Admin.GetUserDetail)
admin.POST("/users/:id/approve", h.Admin.ApproveUser)
admin.POST("/users/:id/reject", h.Admin.RejectUser)
admin.POST("/users/:id/block", h.Admin.BlockUser)
admin.POST("/users/:id/unblock", h.Admin.UnblockUser)
admin.POST("/users/:id/report", h.Admin.ReportUser)
admin.GET("/users/:id/reports", h.Admin.ListUserReports)
// رفقا (مچ‌ها)
admin.GET("/matches", h.Admin.ListAllMatches)
// نظارت بر عکس‌های آپلودشده
admin.GET("/photos", h.Admin.ListAllPhotos)
admin.DELETE("/photos/:id", h.Admin.DeletePhoto)
admin.POST("/photos/:id/report", h.Admin.ReportPhoto)
admin.GET("/categories/:key", h.Admin.GetCategory)
admin.PATCH("/categories/:key", h.Admin.UpdateCategory)
admin.GET("/categories/:key/options", h.Admin.ListOptions)
admin.POST("/categories/:key/options", h.Admin.CreateOption)
admin.PATCH("/options/:id", h.Admin.UpdateOption)
admin.DELETE("/options/:id", h.Admin.DeleteOption)
admin.GET("/profile-questions", h.Admin.ListQuestions)
admin.POST("/profile-questions", h.Admin.CreateQuestion)
admin.PATCH("/profile-questions/:id", h.Admin.UpdateQuestion)
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)
}
}
+468
View File
@@ -0,0 +1,468 @@
package service
import (
"errors"
"time"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
)
var (
ErrUserNotOnboarded = errors.New("کاربر هنوز آنبوردینگ را تکمیل نکرده است")
ErrBlockReasonEmpty = errors.New("دلیل بلاک کردن نمی‌تواند خالی باشد")
ErrReportReasonEmpty = errors.New("دلیل گزارش نمی‌تواند خالی باشد")
)
type AdminService struct {
optionRepo *repository.OptionRepository
questionRepo *repository.QuestionRepository
userRepo *repository.UserRepository
reportRepo *repository.ReportRepository
matchRepo *repository.MatchRepository
photoRepo *repository.PhotoRepository
profileService *ProfileService
}
func NewAdminService(
optionRepo *repository.OptionRepository,
questionRepo *repository.QuestionRepository,
userRepo *repository.UserRepository,
reportRepo *repository.ReportRepository,
matchRepo *repository.MatchRepository,
photoRepo *repository.PhotoRepository,
profileService *ProfileService,
) *AdminService {
return &AdminService{
optionRepo: optionRepo,
questionRepo: questionRepo,
userRepo: userRepo,
reportRepo: reportRepo,
matchRepo: matchRepo,
photoRepo: photoRepo,
profileService: profileService,
}
}
// ---------------- بررسی و تایید کاربران ----------------
// GetUser یک کاربر رو برای نمایش به ادمین (بررسی درخواست) برمی‌گردونه
func (s *AdminService) GetUser(id uint) (*models.User, error) {
return s.userRepo.FindByID(id)
}
// ListPendingUsers کاربرانی که آنبوردینگ رو تکمیل کردن و منتظر بررسی‌اند، به
// همراه عکس‌هاشون (batch گرفته می‌شه، نه یک کوئری جدا به‌ازای هر کاربر) تا
// ادمین بدون کلیک روی هرکدوم بتونه یه نگاه سریع به عکس‌ها بندازه.
func (s *AdminService) ListPendingUsers() ([]models.User, map[uint][]string, error) {
users, err := s.userRepo.ListPendingApproval()
if err != nil {
return nil, nil, err
}
ids := make([]uint, len(users))
for i, u := range users {
ids[i] = u.ID
}
photos, err := s.photoRepo.ListByUserIDs(ids)
if err != nil {
return nil, nil, err
}
photosByUser := make(map[uint][]string, len(ids))
for _, p := range photos {
photosByUser[p.UserID] = append(photosByUser[p.UserID], p.URL)
}
return users, photosByUser, nil
}
// ApproveUser کاربر رو تایید می‌کنه تا بتونه وارد صفحه‌ی دوستیابی بشه
func (s *AdminService) ApproveUser(id uint) (*models.User, error) {
user, err := s.userRepo.FindByID(id)
if err != nil {
return nil, err
}
if !user.IsOnboarded {
return nil, ErrUserNotOnboarded
}
now := time.Now()
updates := map[string]interface{}{
"approval_status": models.ApprovalApproved,
"rejection_reason": nil,
"reviewed_at": now,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, err
}
user.ApprovalStatus = models.ApprovalApproved
user.RejectionReason = nil
user.ReviewedAt = &now
return user, nil
}
// RejectUser درخواست عضویت کاربر رو با یک دلیل مشخص رد می‌کنه
func (s *AdminService) RejectUser(id uint, reason string) (*models.User, error) {
user, err := s.userRepo.FindByID(id)
if err != nil {
return nil, err
}
if !user.IsOnboarded {
return nil, ErrUserNotOnboarded
}
now := time.Now()
updates := map[string]interface{}{
"approval_status": models.ApprovalRejected,
"rejection_reason": reason,
"reviewed_at": now,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, err
}
user.ApprovalStatus = models.ApprovalRejected
user.RejectionReason = &reason
user.ReviewedAt = &now
return user, nil
}
// ---------------- همه‌ی کاربران، بلاک/آنبلاک، گزارش ----------------
// AdminUserListItem یک کاربر به‌همراه تعداد گزارش‌هاش، برای لیست کامل کاربران
type AdminUserListItem struct {
models.User
ReportCount int64 `json:"report_count"`
}
// ListAllUsers همه‌ی کاربران رو صفحه‌بندی‌شده و قابل‌جستجو (شماره/اسم) برمی‌گردونه.
func (s *AdminService) ListAllUsers(search string, page, pageSize int) ([]AdminUserListItem, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 25
}
users, total, err := s.userRepo.ListAll(search, pageSize, (page-1)*pageSize)
if err != nil {
return nil, 0, err
}
ids := make([]uint, len(users))
for i, u := range users {
ids[i] = u.ID
}
counts, err := s.reportRepo.CountsByUserIDs(ids)
if err != nil {
return nil, 0, err
}
items := make([]AdminUserListItem, len(users))
for i, u := range users {
items[i] = AdminUserListItem{User: u, ReportCount: counts[u.ID]}
}
return items, total, nil
}
// BlockUser از ورود کاربر به اپ جلوگیری می‌کنه (چه توکن معتبر داشته باشه چه نه).
func (s *AdminService) BlockUser(id uint, reason string) (*models.User, error) {
if reason == "" {
return nil, ErrBlockReasonEmpty
}
user, err := s.userRepo.FindByID(id)
if err != nil {
return nil, err
}
now := time.Now()
updates := map[string]interface{}{
"state": models.UserStateBlocked,
"blocked_reason": reason,
"blocked_at": now,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, err
}
user.State = models.UserStateBlocked
user.BlockedReason = &reason
user.BlockedAt = &now
return user, nil
}
// UnblockUser دوباره اجازه‌ی ورود به کاربر می‌ده.
func (s *AdminService) UnblockUser(id uint) (*models.User, error) {
user, err := s.userRepo.FindByID(id)
if err != nil {
return nil, err
}
updates := map[string]interface{}{
"state": models.UserStateActive,
"blocked_reason": nil,
"blocked_at": nil,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, err
}
user.State = models.UserStateActive
user.BlockedReason = nil
user.BlockedAt = nil
return user, nil
}
// ReportUser یک یادداشت نظارتی روی کاربر ثبت می‌کنه (مستقل از بلاک کردن).
func (s *AdminService) ReportUser(id uint, reason string) (*models.Report, error) {
if reason == "" {
return nil, ErrReportReasonEmpty
}
if _, err := s.userRepo.FindByID(id); err != nil {
return nil, err
}
report := &models.Report{UserID: id, Reason: reason}
if err := s.reportRepo.Create(report); err != nil {
return nil, err
}
return report, nil
}
// ReportPhoto یک عکس مشخص رو گزارش می‌کنه (روی صاحب عکس هم توی سابقه‌ی
// گزارش‌ها ثبت می‌شه، با ارجاع به همون عکس).
func (s *AdminService) ReportPhoto(photoID uint, reason string) (*models.Report, error) {
if reason == "" {
return nil, ErrReportReasonEmpty
}
photo, err := s.photoRepo.FindByID(photoID)
if err != nil {
return nil, err
}
report := &models.Report{UserID: photo.UserID, PhotoID: &photo.ID, Reason: reason}
if err := s.reportRepo.Create(report); err != nil {
return nil, err
}
return report, nil
}
// ListReports سابقه‌ی گزارش‌های ثبت‌شده روی یک کاربر رو برمی‌گردونه.
func (s *AdminService) ListReports(userID uint) ([]models.Report, error) {
return s.reportRepo.ListByUser(userID)
}
// ---------------- مچ‌ها (رفقا) ----------------
// AdminMatchView یک مچ به‌همراه اطلاعات خلاصه‌ی هر دو طرف، برای نمایش به ادمین.
type AdminMatchView struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UserOneID uint `json:"user_one_id"`
UserOneName string `json:"user_one_name"`
UserOnePhone string `json:"user_one_phone"`
UserTwoID uint `json:"user_two_id"`
UserTwoName string `json:"user_two_name"`
UserTwoPhone string `json:"user_two_phone"`
}
// ListAllMatches همه‌ی مچ‌های اپ رو صفحه‌بندی‌شده برمی‌گردونه («رفقا»یی که با
// هم مچ شدن)، جدیدترین اول.
func (s *AdminService) ListAllMatches(page, pageSize int) ([]AdminMatchView, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 25
}
matches, total, err := s.matchRepo.ListAllPaginated(pageSize, (page-1)*pageSize)
if err != nil {
return nil, 0, err
}
userIDs := make([]uint, 0, len(matches)*2)
for _, m := range matches {
userIDs = append(userIDs, m.UserOneID, m.UserTwoID)
}
users, err := s.userRepo.FindByIDs(userIDs)
if err != nil {
return nil, 0, err
}
userByID := make(map[uint]models.User, len(users))
for _, u := range users {
userByID[u.ID] = u
}
views := make([]AdminMatchView, 0, len(matches))
for _, m := range matches {
one := userByID[m.UserOneID]
two := userByID[m.UserTwoID]
views = append(views, AdminMatchView{
ID: m.ID,
CreatedAt: m.CreatedAt,
UserOneID: one.ID,
UserOneName: one.FirstName,
UserOnePhone: one.Phone,
UserTwoID: two.ID,
UserTwoName: two.FirstName,
UserTwoPhone: two.Phone,
})
}
return views, total, nil
}
// ---------------- نظارت بر عکس‌ها ----------------
// AdminPhotoView یک عکس آپلودشده به‌همراه اطلاعات صاحبش، برای نظارت ادمین.
type AdminPhotoView struct {
models.Photo
OwnerName string `json:"owner_name"`
OwnerPhone string `json:"owner_phone"`
}
// ListAllPhotos همه‌ی عکس‌های آپلودشده‌ی اپ رو صفحه‌بندی‌شده، جدیدترین اول برمی‌گردونه.
func (s *AdminService) ListAllPhotos(page, pageSize int) ([]AdminPhotoView, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 50
}
photos, total, err := s.photoRepo.ListAllPaginated(pageSize, (page-1)*pageSize)
if err != nil {
return nil, 0, err
}
ids := make([]uint, len(photos))
for i, p := range photos {
ids[i] = p.UserID
}
users, err := s.userRepo.FindByIDs(ids)
if err != nil {
return nil, 0, err
}
userByID := make(map[uint]models.User, len(users))
for _, u := range users {
userByID[u.ID] = u
}
views := make([]AdminPhotoView, len(photos))
for i, p := range photos {
owner := userByID[p.UserID]
views[i] = AdminPhotoView{Photo: p, OwnerName: owner.FirstName, OwnerPhone: owner.Phone}
}
return views, total, nil
}
// DeletePhotoAdmin یک عکس رو صرف‌نظر از مالکش حذف می‌کنه (برای نظارت محتوایی).
func (s *AdminService) DeletePhotoAdmin(photoID uint) error {
return s.profileService.DeletePhotoAdmin(photoID)
}
// ---------------- دسته‌ها و گزینه‌ها ----------------
func (s *AdminService) GetCategory(key models.CategoryKey) (*models.Category, error) {
return s.optionRepo.GetCategoryByKey(key)
}
func (s *AdminService) UpdateCategory(key models.CategoryKey, req dto.UpdateCategoryRequest) (*models.Category, error) {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
return nil, err
}
if req.Title != nil {
cat.Title = *req.Title
}
if req.MinSelect != nil {
cat.MinSelect = *req.MinSelect
}
if req.MaxSelect != nil {
cat.MaxSelect = *req.MaxSelect
}
if err := s.optionRepo.UpdateCategory(cat); err != nil {
return nil, err
}
return cat, nil
}
func (s *AdminService) ListOptions(key models.CategoryKey) ([]models.Option, error) {
return s.optionRepo.ListOptionsByCategoryKeyAdmin(key)
}
func (s *AdminService) CreateOption(key models.CategoryKey, req dto.CreateOptionRequest) (*models.Option, error) {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
return nil, err
}
opt := &models.Option{
CategoryID: cat.ID,
Title: req.Title,
SortOrder: req.SortOrder,
IsActive: true,
}
if err := s.optionRepo.CreateOption(opt); err != nil {
return nil, err
}
return opt, nil
}
func (s *AdminService) UpdateOption(id uint, req dto.UpdateOptionRequest) (*models.Option, error) {
opt, err := s.optionRepo.FindOptionByID(id)
if err != nil {
return nil, err
}
if req.Title != nil {
opt.Title = *req.Title
}
if req.SortOrder != nil {
opt.SortOrder = *req.SortOrder
}
if req.IsActive != nil {
opt.IsActive = *req.IsActive
}
if err := s.optionRepo.UpdateOption(opt); err != nil {
return nil, err
}
return opt, nil
}
func (s *AdminService) DeleteOption(id uint) error {
return s.optionRepo.DeleteOption(id)
}
// ---------------- سوالات پروفایل ----------------
func (s *AdminService) ListQuestions() ([]models.ProfileQuestion, error) {
return s.questionRepo.ListAllAdmin()
}
func (s *AdminService) CreateQuestion(req dto.CreateProfileQuestionRequest) (*models.ProfileQuestion, error) {
q := &models.ProfileQuestion{
Text: req.Text,
SortOrder: req.SortOrder,
IsActive: true,
}
if err := s.questionRepo.Create(q); err != nil {
return nil, err
}
return q, nil
}
func (s *AdminService) UpdateQuestion(id uint, req dto.UpdateProfileQuestionRequest) (*models.ProfileQuestion, error) {
q, err := s.questionRepo.FindByID(id)
if err != nil {
return nil, err
}
if req.Text != nil {
q.Text = *req.Text
}
if req.SortOrder != nil {
q.SortOrder = *req.SortOrder
}
if req.IsActive != nil {
q.IsActive = *req.IsActive
}
if err := s.questionRepo.Update(q); err != nil {
return nil, err
}
return q, nil
}
func (s *AdminService) DeleteQuestion(id uint) error {
return s.questionRepo.Delete(id)
}
+187
View File
@@ -0,0 +1,187 @@
package service
import (
"errors"
"time"
"gorm.io/gorm"
"dating-app/internal/models"
"dating-app/internal/repository"
"dating-app/internal/utils"
)
var (
ErrPhoneBlocked = errors.New("حساب کاربری مسدود شده است")
ErrOTPStillValid = errors.New("کد تایید قبلی هنوز معتبر است")
ErrOTPNotRequested = errors.New("ابتدا باید کد تایید را درخواست کنید")
ErrOTPExpired = errors.New("کد تایید منقضی شده است")
ErrOTPInvalid = errors.New("کد وارد شده نامعتبر است")
ErrSMSSendFailed = errors.New("ارسال پیامک با خطا مواجه شد؛ لطفاً چند لحظه دیگر دوباره تلاش کنید")
ErrUserNotFound = errors.New("کاربر یافت نشد")
)
// OTPStillValidError برای برگردوندن تعداد ثانیه‌ی باقی‌مانده تا انقضای کد قبلی
type OTPStillValidError struct {
RemainingSeconds int
}
func (e *OTPStillValidError) Error() string {
return "کد تایید قبلی هنوز معتبر است"
}
type AuthService struct {
userRepo *repository.UserRepository
otpSender OTPSender
jwtManager *utils.JWTManager
otpExpiry time.Duration
otpLength int
otpHashSecret string
}
func NewAuthService(
userRepo *repository.UserRepository,
otpSender OTPSender,
jwtManager *utils.JWTManager,
otpExpirySeconds int,
otpLength int,
otpHashSecret string,
) *AuthService {
return &AuthService{
userRepo: userRepo,
otpSender: otpSender,
jwtManager: jwtManager,
otpExpiry: time.Duration(otpExpirySeconds) * time.Second,
otpLength: otpLength,
otpHashSecret: otpHashSecret,
}
}
// RequestOTP یک کد تایید جدید تولید، هش و ذخیره می‌کند و از طریق OTPSender ارسال می‌کند.
func (s *AuthService) RequestOTP(phone string) error {
if err := utils.ValidatePhone(phone); err != nil {
return err
}
user, _, err := s.userRepo.GetOrCreateByPhone(phone)
if err != nil {
return err
}
if user.State == models.UserStateBlocked {
return ErrPhoneBlocked
}
// اگر کد قبلی هنوز معتبره، اجازه‌ی ارسال دوباره نده
if user.OTPCode != nil && user.OTPExpiry != nil && time.Now().Before(*user.OTPExpiry) {
remaining := int(time.Until(*user.OTPExpiry).Seconds())
return &OTPStillValidError{RemainingSeconds: remaining}
}
otp := utils.GenerateOTP(s.otpLength)
hashed := utils.HashOTP(otp, s.otpHashSecret)
expiry := time.Now().Add(s.otpExpiry)
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"otp_code": hashed,
"otp_expiry": expiry,
}); err != nil {
return err
}
// ارسال پیامک به‌صورت sync؛ اگه واقعاً شکست خورد، صادقانه به کاربر خبر بده
// و کد ذخیره‌شده رو پاک کن تا کاربر بلافاصله بتونه دوباره تلاش کنه
// (وگرنه تا پایان OTPExpiry با پیام «کد قبلی هنوز معتبره» قفل می‌مونه).
if ok := s.otpSender.Send(phone, otp); !ok {
_ = s.userRepo.UpdateFields(user, map[string]interface{}{
"otp_code": nil,
"otp_expiry": nil,
})
return ErrSMSSendFailed
}
return nil
}
// VerifyOTP کد را بررسی می‌کند و در صورت صحت، کاربر را فعال کرده و توکن صادر می‌کند.
func (s *AuthService) VerifyOTP(phone, otp string) (*models.User, string, string, bool, error) {
user, err := s.userRepo.FindByPhone(phone)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", "", false, ErrUserNotFound
}
return nil, "", "", false, err
}
if user.OTPCode == nil || user.OTPExpiry == nil {
return nil, "", "", false, ErrOTPNotRequested
}
if time.Now().After(*user.OTPExpiry) {
_ = s.userRepo.UpdateFields(user, map[string]interface{}{
"otp_code": nil,
"otp_expiry": nil,
})
return nil, "", "", false, ErrOTPExpired
}
if *user.OTPCode != utils.HashOTP(otp, s.otpHashSecret) {
return nil, "", "", false, ErrOTPInvalid
}
isNewUser := user.State == models.UserStateInactive
now := time.Now()
updates := map[string]interface{}{
"otp_code": nil,
"otp_expiry": nil,
"state": models.UserStateActive,
"last_login": now,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, "", "", false, err
}
// رفرش شیء user در حافظه برای برگردوندن مقادیر به‌روز
user.State = models.UserStateActive
user.LastLogin = &now
user.OTPCode = nil
user.OTPExpiry = nil
access, err := s.jwtManager.GenerateAccessToken(user.ID, user.IsAdmin)
if err != nil {
return nil, "", "", false, err
}
refresh, err := s.jwtManager.GenerateRefreshToken(user.ID, user.IsAdmin)
if err != nil {
return nil, "", "", false, err
}
return user, access, refresh, isNewUser, nil
}
// RefreshTokens با یک refresh token معتبر، یک جفت توکن جدید صادر می‌کند.
func (s *AuthService) RefreshTokens(refreshToken string) (string, string, error) {
claims, err := s.jwtManager.ParseRefreshToken(refreshToken)
if err != nil {
return "", "", err
}
user, err := s.userRepo.FindByID(claims.UserID)
if err != nil {
return "", "", ErrUserNotFound
}
if user.State == models.UserStateBlocked || !user.IsActive {
return "", "", ErrPhoneBlocked
}
access, err := s.jwtManager.GenerateAccessToken(user.ID, user.IsAdmin)
if err != nil {
return "", "", err
}
newRefresh, err := s.jwtManager.GenerateRefreshToken(user.ID, user.IsAdmin)
if err != nil {
return "", "", err
}
return access, newRefresh, nil
}
+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
}
+179
View File
@@ -0,0 +1,179 @@
package service
import (
"encoding/base64"
"errors"
"time"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
)
var (
ErrNotMatchMember = errors.New("این گفتگو مال تو نیست")
ErrInvalidMessageEncoding = errors.New("پیام رمزنگاری‌شده نامعتبر است (کدگذاری اشتباه)")
)
type ChatService struct {
matchRepo *repository.MatchRepository
messageRepo *repository.MessageRepository
userRepo *repository.UserRepository
photoRepo *repository.PhotoRepository
}
func NewChatService(
matchRepo *repository.MatchRepository,
messageRepo *repository.MessageRepository,
userRepo *repository.UserRepository,
photoRepo *repository.PhotoRepository,
) *ChatService {
return &ChatService{
matchRepo: matchRepo,
messageRepo: messageRepo,
userRepo: userRepo,
photoRepo: photoRepo,
}
}
// 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, ok := userByID[otherID]
if !ok {
continue
}
view := dto.MatchView{
ID: m.ID,
UserID: other.ID,
FirstName: other.FirstName,
Photos: orEmptyStrings(photosByUser[otherID]),
CreatedAt: m.CreatedAt.Format(time.RFC3339),
PartnerPublicKey: other.PublicKey,
}
if last, ok := lastMessages[m.ID]; ok {
at := last.CreatedAt.Format(time.RFC3339)
view.LastAt = &at
if last.CipherText != nil {
view.LastMessageCipherText = last.CipherText
view.LastMessageNonce = last.Nonce
} else {
text := last.Text
view.LastMessage = &text
}
}
views = append(views, view)
}
return views, nil
}
func (s *ChatService) getAuthorizedMatch(userID, matchID uint) (uint, error) {
match, err := s.matchRepo.FindByID(matchID)
if err != nil {
return 0, err
}
if !repository.IsMember(match, userID) {
return 0, ErrNotMatchMember
}
return match.ID, nil
}
// ListMessages پیام‌های یک مچ رو برمی‌گردونه؛ فقط اگه کاربر عضو اون مچ باشه.
func (s *ChatService) ListMessages(userID, matchID uint) ([]dto.MessageView, error) {
if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
return nil, err
}
messages, err := s.messageRepo.ListByMatch(matchID)
if err != nil {
return nil, err
}
views := make([]dto.MessageView, 0, len(messages))
for _, m := range messages {
views = append(views, dto.MessageView{
ID: m.ID,
MatchID: m.MatchID,
SenderID: m.SenderID,
Text: m.Text,
CipherText: m.CipherText,
Nonce: m.Nonce,
CreatedAt: m.CreatedAt.Format(time.RFC3339),
IsMine: m.SenderID == userID,
})
}
return views, nil
}
// SendMessage یک پیام رمزنگاری‌شده توی یک مچ ثبت می‌کنه؛ فقط اگه کاربر عضو
// اون مچ باشه. سرور محتوای پیام رو نمی‌بینه و نمی‌تونه — CipherText/Nonce
// قبلاً روی دستگاه فرستنده با کلید عمومی طرف مقابل رمزنگاری شدن؛ اینجا فقط
// اندازه‌ی base64 اونا (از طریق binding توی SendMessageRequest) چک می‌شه.
func (s *ChatService) SendMessage(userID, matchID uint, cipherText, nonce string) (*dto.MessageView, error) {
if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
return nil, err
}
if _, err := base64.StdEncoding.DecodeString(cipherText); err != nil {
return nil, ErrInvalidMessageEncoding
}
if _, err := base64.StdEncoding.DecodeString(nonce); err != nil {
return nil, ErrInvalidMessageEncoding
}
msg := &models.Message{MatchID: matchID, SenderID: userID, CipherText: &cipherText, Nonce: &nonce}
if err := s.messageRepo.Create(msg); err != nil {
return nil, err
}
return &dto.MessageView{
ID: msg.ID,
MatchID: msg.MatchID,
SenderID: msg.SenderID,
CipherText: msg.CipherText,
Nonce: msg.Nonce,
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
IsMine: true,
}, nil
}
@@ -0,0 +1,339 @@
package service
import (
"errors"
"math"
"sort"
"time"
"gorm.io/gorm"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
"dating-app/internal/utils"
)
const (
DefaultMinAge = 18
DefaultMaxAge = 60
DiscoverBatchSize = 60
DiscoverResultSize = 20
)
var (
ErrGenderNotSet = errors.New("برای دیدن پیشنهادها اول باید جنسیتت مشخص شده باشه")
ErrCannotSwipeSelf = errors.New("نمی‌تونی روی خودت سواپ بزنی")
ErrAlreadySwiped = errors.New("قبلاً روی این پروفایل تصمیم گرفتی")
)
// جنسیت مخالف کاربر رو برمی‌گردونه. این ساده‌ترین فرض ممکنه (هترو دیفالت)؛
// سیستم فعلاً فیلد جداگانه‌ای برای «ترجیح جنسیتی/گرایش» نداره.
func oppositeGender(g models.Gender) models.Gender {
if g == models.GenderFemale {
return models.GenderMale
}
return models.GenderFemale
}
type DiscoveryService struct {
userRepo *repository.UserRepository
photoRepo *repository.PhotoRepository
swipeRepo *repository.SwipeRepository
matchRepo *repository.MatchRepository
optionRepo *repository.OptionRepository
questionRepo *repository.QuestionRepository
}
func NewDiscoveryService(
userRepo *repository.UserRepository,
photoRepo *repository.PhotoRepository,
swipeRepo *repository.SwipeRepository,
matchRepo *repository.MatchRepository,
optionRepo *repository.OptionRepository,
questionRepo *repository.QuestionRepository,
) *DiscoveryService {
return &DiscoveryService{
userRepo: userRepo,
photoRepo: photoRepo,
swipeRepo: swipeRepo,
matchRepo: matchRepo,
optionRepo: optionRepo,
questionRepo: questionRepo,
}
}
type candidateWithDistance struct {
user models.User
distance *float64
}
// GetFeed لیست پیشنهادهای صفحه‌ی دوستیابی رو برمی‌گردونه؛ نزدیک‌ترین‌ها اول.
func (s *DiscoveryService) GetFeed(user *models.User, maxDistanceKM *float64, minAge, maxAge int) ([]dto.DiscoverCandidate, error) {
if user.Gender == nil {
return nil, ErrGenderNotSet
}
if minAge <= 0 {
minAge = DefaultMinAge
}
if maxAge <= 0 || maxAge < minAge {
maxAge = DefaultMaxAge
}
candidates, err := s.userRepo.ListDiscoverable(user.ID, oppositeGender(*user.Gender), minAge, maxAge, DiscoverBatchSize)
if err != nil {
return nil, err
}
if len(candidates) == 0 {
return []dto.DiscoverCandidate{}, nil
}
ids := make([]uint, len(candidates))
for i, c := range candidates {
ids[i] = c.ID
}
photos, err := s.photoRepo.ListByUserIDs(ids)
if err != nil {
return nil, err
}
photosByUser := map[uint][]string{}
for _, p := range photos {
photosByUser[p.UserID] = append(photosByUser[p.UserID], p.URL)
}
interestsByUser := s.batchInterests(ids)
withDist := make([]candidateWithDistance, 0, len(candidates))
for _, cand := range candidates {
var dist *float64
if user.Latitude != nil && user.Longitude != nil && cand.Latitude != nil && cand.Longitude != nil {
km := math.Round(utils.HaversineKM(*user.Latitude, *user.Longitude, *cand.Latitude, *cand.Longitude))
dist = &km
}
if maxDistanceKM != nil && dist != nil && *dist > *maxDistanceKM {
continue
}
withDist = append(withDist, candidateWithDistance{user: cand, distance: dist})
}
sort.SliceStable(withDist, func(i, j int) bool {
if withDist[i].distance == nil {
return false
}
if withDist[j].distance == nil {
return true
}
return *withDist[i].distance < *withDist[j].distance
})
if len(withDist) > DiscoverResultSize {
withDist = withDist[:DiscoverResultSize]
}
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),
Bio: derefStr(wd.user.Bio),
// نبود عکس/علاقه‌مندی نباید توی JSON به‌صورت null دربیاد؛ کلاینت‌ها
// همیشه یک آرایه (حتی خالی) انتظار دارن، نه null.
Photos: orEmptyStrings(photosByUser[wd.user.ID]),
DistanceKM: wd.distance,
Interests: orEmptyStrings(interestsByUser[wd.user.ID]),
})
}
return result, nil
}
func orEmptyStrings(s []string) []string {
if s == nil {
return []string{}
}
return s
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// batchInterests علاقه‌مندی‌های تمام کاندیداهای فید رو در یک کوئری واحد
// می‌گیره (به‌جای یک کوئری جدا به‌ازای هر کاندیدا)؛ روی فید دیسکاوری که تا ۶۰
// کاربر با هم پردازش می‌شن، این تفاوت بین ۱ کوئری و تا ۶۰ کوئری رو ایجاد می‌کنه.
func (s *DiscoveryService) batchInterests(userIDs []uint) map[uint][]string {
cat, err := s.optionRepo.GetCategoryByKey(models.CategoryInterest)
if err != nil {
return map[uint][]string{}
}
result, err := s.optionRepo.GetSelectionsForUsersInCategory(userIDs, cat.ID)
if err != nil {
return map[uint][]string{}
}
return result
}
// Swipe یک تصمیم (لایک/ردّ/سوپرلایک) ثبت می‌کنه و در صورت لایک متقابل، مچ می‌سازه.
func (s *DiscoveryService) Swipe(user *models.User, targetID uint, action models.SwipeAction) (*dto.SwipeResponse, error) {
if targetID == user.ID {
return nil, ErrCannotSwipeSelf
}
target, err := s.userRepo.FindByID(targetID)
if err != nil {
return nil, err
}
alreadySwiped, err := s.swipeRepo.Exists(user.ID, targetID)
if err != nil {
return nil, err
}
if alreadySwiped {
return nil, ErrAlreadySwiped
}
if err := s.swipeRepo.Create(&models.Swipe{SwiperID: user.ID, SwipedID: targetID, Action: action}); err != nil {
return nil, err
}
if action == models.SwipePass {
return &dto.SwipeResponse{Matched: false}, nil
}
theyLikedMe, err := s.swipeRepo.HasLiked(targetID, user.ID)
if err != nil {
return nil, err
}
if !theyLikedMe {
return &dto.SwipeResponse{Matched: false}, nil
}
match, _, err := s.matchRepo.FindOrCreate(user.ID, targetID)
if err != nil {
return nil, err
}
photos, _ := s.photoRepo.ListByUser(target.ID)
urls := make([]string, len(photos))
for i, p := range photos {
urls[i] = p.URL
}
return &dto.SwipeResponse{
Matched: true,
Match: &dto.MatchView{
ID: match.ID,
UserID: target.ID,
FirstName: target.FirstName,
Photos: urls,
CreatedAt: match.CreatedAt.Format(time.RFC3339),
},
}, nil
}
// GetCandidateDetail پروفایل کامل یک کاربر دیگه رو برای نمایش صفحه‌ی جزئیات برمی‌گردونه
func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint) (*dto.CandidateDetail, error) {
target, err := s.userRepo.FindByID(targetID)
if err != nil {
return nil, err
}
if !target.IsOnboarded || target.ApprovalStatus != models.ApprovalApproved || !target.IsActive {
return nil, gorm.ErrRecordNotFound
}
photos, _ := s.photoRepo.ListByUser(target.ID)
urls := make([]string, len(photos))
for i, p := range photos {
urls[i] = p.URL
}
var distance *float64
if viewer.Latitude != nil && viewer.Longitude != nil && target.Latitude != nil && target.Longitude != nil {
km := math.Round(utils.HaversineKM(*viewer.Latitude, *viewer.Longitude, *target.Latitude, *target.Longitude))
distance = &km
}
detail := &dto.CandidateDetail{
ID: target.ID,
FirstName: target.FirstName,
Age: utils.CalcAge(target.BirthDate),
Bio: derefStr(target.Bio),
Photos: urls,
DistanceKM: distance,
HeightCM: target.HeightCM,
WeightKG: target.WeightKG,
}
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 {
if a.Question == nil {
continue
}
detail.Answers = append(detail.Answers, dto.CandidateAnswer{
Question: a.Question.Text,
Answer: a.Answer,
})
}
}
return detail, 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)
}
selectionsByCategoryID, err := s.optionRepo.GetUserSelectionsForCategories(userID, categoryIDs)
if err != nil {
return result
}
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 result
}
@@ -0,0 +1,328 @@
package service
import (
"errors"
"fmt"
"time"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
)
var (
ErrStepOutOfOrder = errors.New("لطفاً ابتدا مراحل قبلی را تکمیل کنید")
ErrInvalidBirthDate = errors.New("فرمت تاریخ تولد نامعتبر است (YYYY-MM-DD)")
ErrOptionsNotFound = errors.New("برخی از گزینه‌های انتخابی معتبر نیستند")
ErrSelectionCount = errors.New("تعداد گزینه‌های انتخابی مجاز نیست")
ErrQuestionsInvalid = errors.New("سوالات انتخابی معتبر نیستند")
ErrDuplicateQuestion = errors.New("هر سوال فقط یک‌بار می‌تواند انتخاب شود")
ErrNoPhotos = errors.New("برای ادامه حداقل باید یک عکس اضافه کنی")
)
type OnboardingService struct {
userRepo *repository.UserRepository
optionRepo *repository.OptionRepository
questionRepo *repository.QuestionRepository
photoRepo *repository.PhotoRepository
}
func NewOnboardingService(
userRepo *repository.UserRepository,
optionRepo *repository.OptionRepository,
questionRepo *repository.QuestionRepository,
photoRepo *repository.PhotoRepository,
) *OnboardingService {
return &OnboardingService{
userRepo: userRepo,
optionRepo: optionRepo,
questionRepo: questionRepo,
photoRepo: photoRepo,
}
}
// canModifyStep بررسی می‌کند که آیا کاربر مجاز به تکمیل/ویرایش این مرحله هست یا نه.
// اگر کاربر قبلاً کل آنبوردینگ رو تموم کرده، اجازه‌ی ویرایش هر مرحله‌ای رو داره.
// در غیر این صورت فقط می‌تونه مرحله‌ی فعلی یا مراحل قبلی (که قبلاً تکمیل شدن) رو بزنه؛
// نمی‌تونه از روی یک مرحله بپره جلو.
func (s *OnboardingService) canModifyStep(user *models.User, step models.OnboardingStep) error {
if user.IsOnboarded {
return nil
}
if step > user.CurrentStep {
return ErrStepOutOfOrder
}
return nil
}
// advanceIfCurrent اگه مرحله‌ی تکمیل‌شده همون مرحله‌ی فعلی کاربر بود، به مرحله‌ی بعد می‌ره
func (s *OnboardingService) advanceIfCurrent(user *models.User, completedStep models.OnboardingStep) error {
if user.CurrentStep != completedStep {
// یا مرحله‌ی قبلی داشت ویرایش می‌شد، یا از قبل onboarded بود.
// اگر قبلاً توسط ادمین رد شده بود، ویرایش پروفایل یعنی درخواست بررسی مجدد.
return s.resubmitIfRejected(user)
}
next := completedStep + 1
updates := map[string]interface{}{"current_step": next}
if next >= models.StepCompleted {
updates["current_step"] = models.StepCompleted
updates["is_onboarded"] = true
updates["approval_status"] = models.ApprovalPending
updates["rejection_reason"] = nil
}
return s.userRepo.UpdateFields(user, updates)
}
// resubmitIfRejected اگه کاربر قبلاً رد شده و بعد از onboarded شدن پروفایلش رو
// ویرایش کنه، وضعیت رو به pending برمی‌گردونه تا ادمین دوباره بررسی کنه.
func (s *OnboardingService) resubmitIfRejected(user *models.User) error {
if !user.IsOnboarded || user.ApprovalStatus != models.ApprovalRejected {
return nil
}
updates := map[string]interface{}{
"approval_status": models.ApprovalPending,
"rejection_reason": nil,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return err
}
user.ApprovalStatus = models.ApprovalPending
user.RejectionReason = nil
return nil
}
// ---------------- مرحله ۱: اطلاعات پایه ----------------
func (s *OnboardingService) SetBasicInfo(user *models.User, req dto.BasicInfoRequest) error {
if err := s.canModifyStep(user, models.StepBasicInfo); err != nil {
return err
}
birthDate, err := time.Parse("2006-01-02", req.BirthDate)
if err != nil {
return ErrInvalidBirthDate
}
gender := models.Gender(req.Gender)
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"gender": gender,
"first_name": req.FirstName,
"birth_date": birthDate,
}); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepBasicInfo)
}
// ---------------- مرحله ۲: عکس‌ها (حداقل ۱ عکس) ----------------
// ConfirmPhotos عکس واقعی رو از طریق endpoint عمومی /profile/photos آپلود می‌کنیم؛
// این متد فقط تایید می‌کنه که حداقل یک عکس هست و مرحله رو جلو می‌بره.
func (s *OnboardingService) ConfirmPhotos(user *models.User) error {
if err := s.canModifyStep(user, models.StepPhotos); err != nil {
return err
}
count, err := s.photoRepo.CountByUser(user.ID)
if err != nil {
return err
}
if count < 1 {
return ErrNoPhotos
}
return s.advanceIfCurrent(user, models.StepPhotos)
}
// ---------------- مرحله ۳: قد و وزن ----------------
func (s *OnboardingService) SetBodyInfo(user *models.User, req dto.BodyInfoRequest) error {
if err := s.canModifyStep(user, models.StepBodyInfo); err != nil {
return err
}
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"height_cm": req.HeightCM,
"weight_kg": req.WeightKG,
}); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepBodyInfo)
}
// ---------------- مرحله ۳: سوالات پروفایل (دقیقاً ۳ سوال) ----------------
func (s *OnboardingService) GetAvailableQuestions() ([]models.ProfileQuestion, error) {
return s.questionRepo.ListActive()
}
func (s *OnboardingService) SetProfileQuestions(user *models.User, req dto.ProfileQuestionsRequest) error {
if err := s.canModifyStep(user, models.StepProfileQuestions); err != nil {
return err
}
ids := make([]uint, 0, len(req.Answers))
seen := map[uint]bool{}
for _, a := range req.Answers {
if seen[a.QuestionID] {
return ErrDuplicateQuestion
}
seen[a.QuestionID] = true
ids = append(ids, a.QuestionID)
}
questions, err := s.questionRepo.FindActiveByIDs(ids)
if err != nil {
return err
}
if len(questions) != len(ids) {
return ErrQuestionsInvalid
}
answers := make([]models.UserProfileAnswer, 0, len(req.Answers))
for _, a := range req.Answers {
answers = append(answers, models.UserProfileAnswer{
QuestionID: a.QuestionID,
Answer: a.Answer,
})
}
if err := s.questionRepo.ReplaceUserAnswers(user.ID, answers); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepProfileQuestions)
}
// ---------------- مرحله ۴: تحصیلات (سطح تحصیلات + رشته) ----------------
func (s *OnboardingService) SetEducation(user *models.User, req dto.EducationRequest) error {
if err := s.canModifyStep(user, models.StepEducation); err != nil {
return err
}
if err := s.selectSingleOption(user.ID, models.CategoryEducationLevel, req.EducationLevelOptionID); err != nil {
return err
}
if err := s.selectSingleOption(user.ID, models.CategoryFieldOfStudy, req.FieldOfStudyOptionID); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepEducation)
}
func (s *OnboardingService) selectSingleOption(userID uint, key models.CategoryKey, optionID uint) error {
return s.selectOptions(userID, key, []uint{optionID})
}
// ---------------- مراحل مبتنی بر لیست چندانتخابی عمومی ----------------
// SetOccupations مرحله ۵: کارهایی که کاربر انجام می‌دهد (چندانتخابی، بدون تعداد ثابت)
func (s *OnboardingService) SetOccupations(user *models.User, optionIDs []uint) error {
if err := s.canModifyStep(user, models.StepOccupations); err != nil {
return err
}
if err := s.selectOptions(user.ID, models.CategoryOccupation, optionIDs); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepOccupations)
}
// SetRelationshipImportance مرحله ۶: دقیقاً ۳ مورد از چیزهای مهم در رابطه
func (s *OnboardingService) SetRelationshipImportance(user *models.User, optionIDs []uint) error {
if err := s.canModifyStep(user, models.StepRelationshipImportance); err != nil {
return err
}
if err := s.selectOptions(user.ID, models.CategoryImportantInRelationship, optionIDs); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepRelationshipImportance)
}
// SetInterests مرحله ۷: دقیقاً ۵ علاقه‌مندی
func (s *OnboardingService) SetInterests(user *models.User, optionIDs []uint) error {
if err := s.canModifyStep(user, models.StepInterests); err != nil {
return err
}
if err := s.selectOptions(user.ID, models.CategoryInterest, optionIDs); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepInterests)
}
// SetLookingFor مرحله ۸: دنبال چی می‌گردی
func (s *OnboardingService) SetLookingFor(user *models.User, optionIDs []uint) error {
if err := s.canModifyStep(user, models.StepLookingFor); err != nil {
return err
}
if err := s.selectOptions(user.ID, models.CategoryLookingFor, optionIDs); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepLookingFor)
}
// selectOptions منطق مشترک اعتبارسنجی + ذخیره‌ی انتخاب‌های یک دسته
func (s *OnboardingService) selectOptions(userID uint, key models.CategoryKey, optionIDs []uint) error {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
return err
}
count := len(optionIDs)
if count < cat.MinSelect {
return fmt.Errorf("%w: حداقل باید %d مورد انتخاب کنید", ErrSelectionCount, cat.MinSelect)
}
if cat.MaxSelect > 0 && count > cat.MaxSelect {
return fmt.Errorf("%w: حداکثر می‌توانید %d مورد انتخاب کنید", ErrSelectionCount, cat.MaxSelect)
}
// جلوگیری از تکراری بودن id ها
uniqueIDs := make([]uint, 0, len(optionIDs))
seen := map[uint]bool{}
for _, id := range optionIDs {
if !seen[id] {
seen[id] = true
uniqueIDs = append(uniqueIDs, id)
}
}
validOptions, err := s.optionRepo.FindActiveOptionsByIDsInCategory(cat.ID, uniqueIDs)
if err != nil {
return err
}
if len(validOptions) != len(uniqueIDs) {
return ErrOptionsNotFound
}
return s.optionRepo.ReplaceUserSelections(userID, cat.ID, uniqueIDs)
}
// GetActiveOptions لیست گزینه‌های فعال یک دسته را برمی‌گرداند (برای نمایش به کاربر در هر مرحله)
func (s *OnboardingService) GetActiveOptions(key models.CategoryKey) ([]models.Option, error) {
return s.optionRepo.ListActiveOptionsByCategoryKey(key)
}
// ---------------- وضعیت کلی آنبوردینگ ----------------
func (s *OnboardingService) GetStatus(user *models.User) dto.OnboardingStatusResponse {
steps := make([]dto.StepView, 0, len(models.OnboardingStepOrder))
for _, s2 := range models.OnboardingStepOrder {
done := user.IsOnboarded || user.CurrentStep > s2.Step
steps = append(steps, dto.StepView{
Step: int(s2.Step),
Key: s2.Key,
Done: done,
})
}
currentKey := "completed"
for _, s2 := range models.OnboardingStepOrder {
if s2.Step == user.CurrentStep {
currentKey = s2.Key
break
}
}
return dto.OnboardingStatusResponse{
CurrentStep: int(user.CurrentStep),
CurrentStepKey: currentKey,
IsOnboarded: user.IsOnboarded,
ApprovalStatus: string(user.ApprovalStatus),
RejectionReason: user.RejectionReason,
Steps: steps,
}
}
+41
View File
@@ -0,0 +1,41 @@
package service
import "log"
// OTPSender اینترفیس ارسال پیامک کد تایید.
// برای اتصال به یک گیت‌وی واقعی (کاوه‌نگار، ملی‌پیامک و ...) کافیه یک
// پیاده‌سازی جدید از این اینترفیس بسازی و در main.go جایگزین mock کنی؛
// بقیه‌ی کد (UserService و...) نیازی به تغییر ندارد.
//
// نکته‌ی مهم: پیاده‌سازی واقعی باید SYNC باشه (منتظر پاسخ گیت‌وی بمونه)
// و نتیجه‌ی واقعی ارسال رو برگردونه؛ اگه async/بی‌خیال از نتیجه باشه،
// کاربر حتی در صورت شکست واقعی ارسال، پیام «با موفقیت ارسال شد» می‌گیره
// و منتظر کدی می‌مونه که هیچ‌وقت نمی‌رسه.
type OTPSender interface {
// Send کد OTP را برای شماره‌ی داده‌شده ارسال می‌کند.
// خروجی true یعنی گیت‌وی ارسال را با موفقیت تایید کرده است.
Send(phone string, otp string) bool
}
// MockOTPSender پیاده‌سازی توسعه/تست: به‌جای ارسال واقعی پیامک،
// کد را در لاگ سرور چاپ می‌کند. برای پروداکشن باید با یک گیت‌وی واقعی
// جایگزین شود.
type MockOTPSender struct {
// Debug اگر true باشد، کد OTP در لاگ چاپ می‌شود (فقط برای dev/staging).
Debug bool
}
func NewMockOTPSender(debug bool) *MockOTPSender {
return &MockOTPSender{Debug: debug}
}
func (s *MockOTPSender) Send(phone string, otp string) bool {
if s.Debug {
log.Printf("[MOCK-SMS] کد OTP برای %s: %s", phone, otp)
} else {
log.Printf("[MOCK-SMS] یک کد OTP برای %s ارسال شد", phone)
}
// mock همیشه موفق فرض می‌شود؛ برای شبیه‌سازی خطای گیت‌وی می‌تونی
// این مقدار رو شرطی کنی (مثلاً بر اساس یک فلگ تستی).
return true
}
+61
View File
@@ -0,0 +1,61 @@
package service
import (
"fmt"
"os"
"path/filepath"
"github.com/google/uuid"
)
// PhotoStorage اینترفیس ذخیره‌سازی فایل عکس پروفایل.
// برای اتصال به یک storage واقعی (S3، Arvan Object Storage و...) کافیه یک
// پیاده‌سازی جدید از این اینترفیس بسازی و در main.go جایگزین نسخه‌ی local کنی؛
// بقیه‌ی کد (ProfileService و...) نیازی به تغییر ندارد.
type PhotoStorage interface {
// Save فایل رو ذخیره می‌کنه و URL قابل‌دسترسی (نسبی یا کامل) رو برمی‌گردونه.
Save(userID uint, ext string, data []byte) (url string, err error)
// Delete فایل رو با URL برگردونده‌شده از Save حذف می‌کنه.
Delete(url string) error
}
// LocalPhotoStorage فایل‌ها رو روی دیسک محلی سرور ذخیره می‌کنه و از طریق
// مسیر استاتیک /uploads سرو می‌شن. برای پروداکشن با تعداد کاربر بالا،
// بهتره با یک object storage واقعی جایگزین بشه (فایل‌ها روی دیسک سرور API
// می‌مونن و بین چند instance به اشتراک گذاشته نمی‌شن).
type LocalPhotoStorage struct {
rootDir string // مسیر روی دیسک، مثلاً "./uploads/photos"
urlPrefix string // پیشوند URL عمومی، مثلاً "/uploads/photos"
}
func NewLocalPhotoStorage(rootDir, urlPrefix string) *LocalPhotoStorage {
return &LocalPhotoStorage{rootDir: rootDir, urlPrefix: urlPrefix}
}
func (s *LocalPhotoStorage) Save(userID uint, ext string, data []byte) (string, error) {
userDir := filepath.Join(s.rootDir, fmt.Sprintf("%d", userID))
if err := os.MkdirAll(userDir, 0o755); err != nil {
return "", err
}
filename := uuid.NewString() + ext
fullPath := filepath.Join(userDir, filename)
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
return "", err
}
return fmt.Sprintf("%s/%d/%s", s.urlPrefix, userID, filename), nil
}
func (s *LocalPhotoStorage) Delete(url string) error {
rel, err := filepath.Rel(s.urlPrefix, url)
if err != nil {
return err
}
fullPath := filepath.Join(s.rootDir, rel)
err = os.Remove(fullPath)
if os.IsNotExist(err) {
return nil
}
return err
}
+182
View File
@@ -0,0 +1,182 @@
package service
import (
"encoding/base64"
"errors"
"strings"
"time"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
)
const MaxPhotosPerUser = 6
var (
ErrTooManyPhotos = errors.New("حداکثر تعداد عکس مجاز رو داری")
ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست")
ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه")
ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه")
ErrInvalidPublicKey = errors.New("کلید عمومی نامعتبر است")
)
const MaxPhotoSizeBytes = 5 * 1024 * 1024
type ProfileService struct {
photoRepo *repository.PhotoRepository
userRepo *repository.UserRepository
storage PhotoStorage
}
func NewProfileService(photoRepo *repository.PhotoRepository, userRepo *repository.UserRepository, storage PhotoStorage) *ProfileService {
return &ProfileService{photoRepo: photoRepo, userRepo: userRepo, storage: storage}
}
var allowedImageExts = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".webp": true,
}
func (s *ProfileService) UploadPhoto(userID uint, ext string, data []byte) (*models.Photo, error) {
if !allowedImageExts[ext] {
return nil, ErrInvalidImage
}
if len(data) > MaxPhotoSizeBytes {
return nil, ErrImageTooLarge
}
count, err := s.photoRepo.CountByUser(userID)
if err != nil {
return nil, err
}
if count >= MaxPhotosPerUser {
return nil, ErrTooManyPhotos
}
url, err := s.storage.Save(userID, ext, data)
if err != nil {
return nil, err
}
photo := &models.Photo{UserID: userID, URL: url, SortOrder: int(count)}
if err := s.photoRepo.Create(photo); err != nil {
return nil, err
}
return photo, nil
}
func (s *ProfileService) DeletePhoto(userID, photoID uint) error {
photo, err := s.photoRepo.FindByID(photoID)
if err != nil {
return err
}
if photo.UserID != userID {
return ErrPhotoNotOwned
}
if err := s.photoRepo.Delete(photoID); err != nil {
return err
}
return s.storage.Delete(photo.URL)
}
// DeletePhotoAdmin مثل DeletePhoto ولی بدون چک مالکیت؛ برای نظارت محتوایی
// ادمین که باید بتونه هر عکسی رو صرف‌نظر از صاحبش حذف کنه.
func (s *ProfileService) DeletePhotoAdmin(photoID uint) error {
photo, err := s.photoRepo.FindByID(photoID)
if err != nil {
return err
}
if err := s.photoRepo.Delete(photoID); err != nil {
return err
}
return s.storage.Delete(photo.URL)
}
func (s *ProfileService) ListPhotos(userID uint) ([]models.Photo, error) {
return s.photoRepo.ListByUser(userID)
}
func (s *ProfileService) UpdateLocation(user *models.User, lat, lng float64) error {
return s.userRepo.UpdateFields(user, map[string]interface{}{
"latitude": lat,
"longitude": lng,
})
}
// UpdateBio بیوگرافی کوتاه پروفایل رو ذخیره می‌کنه (طول توسط binding در DTO چک شده)
func (s *ProfileService) UpdateBio(user *models.User, bio string) error {
bio = strings.TrimSpace(bio)
if err := s.userRepo.UpdateFields(user, map[string]interface{}{"bio": bio}); err != nil {
return err
}
user.Bio = &bio
return nil
}
// CheckIn ورود روزانه‌ی کاربر رو ثبت می‌کنه و استریک (چند روز پشت‌سرهم) رو
// به‌روز می‌کنه: اگه دیروز هم اومده بوده، استریک +۱ می‌شه؛ اگه امروز قبلاً
// چک‌این کرده، چیزی تغییر نمی‌کنه؛ وگرنه (وقفه افتاده) استریک از ۱ شروع می‌شه.
func (s *ProfileService) CheckIn(user *models.User) (*dto.CheckInResult, error) {
now := time.Now()
today := now.Format("2006-01-02")
if user.LastActiveDate != nil && user.LastActiveDate.Format("2006-01-02") == today {
return &dto.CheckInResult{StreakCount: user.StreakCount, IsNewToday: false}, nil
}
newStreak := 1
if user.LastActiveDate != nil {
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
if user.LastActiveDate.Format("2006-01-02") == yesterday {
newStreak = user.StreakCount + 1
}
}
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"streak_count": newStreak,
"last_active_date": now,
}); err != nil {
return nil, err
}
user.StreakCount = newStreak
user.LastActiveDate = &now
return &dto.CheckInResult{StreakCount: newStreak, IsNewToday: true}, nil
}
// UpdateNotificationSettings ترجیحات اعلان کاربر رو ذخیره می‌کنه.
func (s *ProfileService) UpdateNotificationSettings(user *models.User, req dto.UpdateNotificationSettingsRequest) error {
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"notify_matches": req.NotifyMatches,
"notify_messages": req.NotifyMessages,
"notify_likes": req.NotifyLikes,
}); err != nil {
return err
}
user.NotifyMatches = req.NotifyMatches
user.NotifyMessages = req.NotifyMessages
user.NotifyLikes = req.NotifyLikes
return nil
}
// DeleteAccount حساب کاربر رو غیرفعال می‌کنه (soft) نه پاک‌سازی کامل رکورد؛
// چون مچ‌ها/پیام‌های طرف مقابل نباید یهو پاره بشن. کاربر غیرفعال دیگه اصلاً
// نمی‌تونه لاگین کنه (چک IsActive توی AuthRequired و AuthService).
func (s *ProfileService) DeleteAccount(user *models.User) error {
return s.userRepo.UpdateFields(user, map[string]interface{}{"is_active": false})
}
// UpdatePublicKey کلید عمومی X25519 کاربر رو برای رمزنگاری سرتاسری پیام‌ها
// ثبت/جایگزین می‌کنه. کلید خصوصی متناظرش هیچ‌وقت به سرور نمی‌رسه؛ اینجا فقط
// طول کلید خام (باید دقیقاً ۳۲ بایت باشه) بعد از decode چک می‌شه، نه محتواش.
func (s *ProfileService) UpdatePublicKey(user *models.User, publicKeyB64 string) error {
raw, err := base64.StdEncoding.DecodeString(publicKeyB64)
if err != nil || len(raw) != 32 {
return ErrInvalidPublicKey
}
if err := s.userRepo.UpdateFields(user, map[string]interface{}{"public_key": publicKeyB64}); err != nil {
return err
}
user.PublicKey = &publicKeyB64
return nil
}
+16
View File
@@ -0,0 +1,16 @@
package utils
import "time"
// CalcAge سن رو از تاریخ تولد حساب می‌کنه (نسبت به الان)
func CalcAge(birthDate *time.Time) int {
if birthDate == nil {
return 0
}
now := time.Now()
age := now.Year() - birthDate.Year()
if now.Month() < birthDate.Month() || (now.Month() == birthDate.Month() && now.Day() < birthDate.Day()) {
age--
}
return age
}
+26
View File
@@ -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)
}
}
+19
View File
@@ -0,0 +1,19 @@
package utils
import "math"
const earthRadiusKM = 6371.0
// HaversineKM فاصله‌ی تقریبی بین دو مختصات جغرافیایی رو به کیلومتر برمی‌گردونه.
func HaversineKM(lat1, lng1, lat2, lng2 float64) float64 {
toRad := func(deg float64) float64 { return deg * math.Pi / 180 }
dLat := toRad(lat2 - lat1)
dLng := toRad(lng2 - lng1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return earthRadiusKM * c
}
+18
View File
@@ -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)
}
}
+101
View File
@@ -0,0 +1,101 @@
package utils
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var (
ErrInvalidToken = errors.New("توکن نامعتبر است")
ErrExpiredToken = errors.New("توکن منقضی شده است")
)
// TokenType نوع توکن، برای جلوگیری از استفاده از refresh token به‌جای access و برعکس
type TokenType string
const (
AccessToken TokenType = "access"
RefreshToken TokenType = "refresh"
)
// Claims دیتای داخل JWT
type Claims struct {
UserID uint `json:"user_id"`
IsAdmin bool `json:"is_admin"`
Type TokenType `json:"type"`
jwt.RegisteredClaims
}
// JWTManager تولید و اعتبارسنجی توکن‌ها
type JWTManager struct {
accessSecret []byte
refreshSecret []byte
accessTTL time.Duration
refreshTTL time.Duration
}
func NewJWTManager(accessSecret, refreshSecret string, accessTTL, refreshTTL time.Duration) *JWTManager {
return &JWTManager{
accessSecret: []byte(accessSecret),
refreshSecret: []byte(refreshSecret),
accessTTL: accessTTL,
refreshTTL: refreshTTL,
}
}
func (m *JWTManager) GenerateAccessToken(userID uint, isAdmin bool) (string, error) {
return m.generate(userID, isAdmin, AccessToken, m.accessSecret, m.accessTTL)
}
func (m *JWTManager) GenerateRefreshToken(userID uint, isAdmin bool) (string, error) {
return m.generate(userID, isAdmin, RefreshToken, m.refreshSecret, m.refreshTTL)
}
func (m *JWTManager) generate(userID uint, isAdmin bool, tokenType TokenType, secret []byte, ttl time.Duration) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
IsAdmin: isAdmin,
Type: tokenType,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
func (m *JWTManager) ParseAccessToken(tokenStr string) (*Claims, error) {
return m.parse(tokenStr, m.accessSecret, AccessToken)
}
func (m *JWTManager) ParseRefreshToken(tokenStr string) (*Claims, error) {
return m.parse(tokenStr, m.refreshSecret, RefreshToken)
}
func (m *JWTManager) parse(tokenStr string, secret []byte, expectedType TokenType) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return secret, nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrExpiredToken
}
return nil, ErrInvalidToken
}
if !token.Valid {
return nil, ErrInvalidToken
}
if claims.Type != expectedType {
return nil, ErrInvalidToken
}
return claims, nil
}
+38
View File
@@ -0,0 +1,38 @@
package utils
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"math/rand"
"regexp"
)
var phoneRegex = regexp.MustCompile(`^09\d{9}$`)
// ValidatePhone فقط شماره موبایل ایرانی با فرمت 09XXXXXXXXX را قبول می‌کند
func ValidatePhone(phone string) error {
if !phoneRegex.MatchString(phone) {
return errors.New("شماره موبایل باید ۱۱ رقم باشد و با 09 شروع شود")
}
return nil
}
// GenerateOTP یک کد عددی تصادفی با طول مشخص می‌سازد
func GenerateOTP(length int) string {
digits := "0123456789"
b := make([]byte, length)
for i := range b {
b[i] = digits[rand.Intn(len(digits))]
}
//return string(b)
return "1111"
}
// HashOTP کد OTP را با HMAC-SHA256 هش می‌کند تا در دیتابیس به‌صورت خام ذخیره نشود
func HashOTP(otp, secretKey string) string {
h := hmac.New(sha256.New, []byte(secretKey))
h.Write([]byte(otp))
return hex.EncodeToString(h.Sum(nil))
}
+27
View File
@@ -0,0 +1,27 @@
package utils
import "github.com/gin-gonic/gin"
// Response قالب استاندارد تمام پاسخ‌های API
type Response struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
Errors interface{} `json:"errors,omitempty"`
}
func Success(c *gin.Context, code int, message string, data interface{}) {
c.JSON(code, Response{
Success: true,
Message: message,
Data: data,
})
}
func Fail(c *gin.Context, code int, message string, errs interface{}) {
c.JSON(code, Response{
Success: false,
Message: message,
Errors: errs,
})
}