Files
chat_room/backend

اپلیکیشن دوستیابی — بک‌اند Go (Gin + GORM + PostgreSQL)

یک بک‌اند تمیز و لایه‌بندی‌شده برای یک اپ دوستیابی با:

  • احراز هویت فقط با شماره موبایل + کد OTP (بدون پسورد)
  • فرآیند آنبوردینگ مرحله‌به‌مرحله (۸ مرحله) برای تکمیل پروفایل
  • سیستم عمومی «دسته + گزینه» که کاملاً از طریق API ادمین قابل مدیریت است (بدون نیاز به تغییر کد برای اضافه/حذف گزینه‌های هر لیست)
  • سوالات پروفایل (Q&A) که ادمین تعریف می‌کند و کاربر ۳ تای آن را انتخاب می‌کند

ساختار پروژه (لایه‌بندی تمیز)

dating-app/
├── cmd/api/main.go              # نقطه ورود، wiring همه‌چیز
├── internal/
│   ├── config/                  # خواندن تنظیمات از .env
│   ├── database/                # اتصال دیتابیس، migrate، seed
│   ├── models/                  # مدل‌های GORM
│   ├── dto/                     # ساختار request/response ها
│   ├── repository/              # لایه دسترسی به داده (فقط query)
│   ├── service/                 # منطق تجاری (validation، state machine و...)
│   ├── handler/                 # هندلرهای HTTP (gin.Context)
│   ├── middleware/               # auth، admin، cors
│   └── router/                  # تعریف روت‌ها
├── go.mod / go.sum
└── .env.example

لایه‌بندی مشابه الگوی selector/service که در پروژه‌ی جنگویی قبلی‌تون بود: repository = selector (فقط query خام)، service = منطق و اعتبارسنجی، handler = فقط ترجمه‌ی HTTP request/response.

اجرا

۱) پیش‌نیاز: PostgreSQL

createdb dating_app

۲) تنظیمات

cp .env.example .env
# مقادیر رو مطابق محیط خودت ویرایش کن (خصوصاً JWT_*_SECRET و OTP_HASH_SECRET در پروداکشن)

ADMIN_BOOTSTRAP_PHONE رو به شماره‌ی موبایل خودت تنظیم کن؛ در اولین اجرای برنامه یک کاربر ادمین با اون شماره ساخته می‌شه (یا اگه از قبل وجود داشت، ادمین می‌شه). بعدش با همون شماره از مسیر عادی OTP وارد شو تا توکن ادمین بگیری.

۳) اجرا

go run ./cmd/api

سرور روی http://localhost:8080 بالا میاد. مسیر /health برای healthcheck هست.

اتصال گیت‌وی پیامک واقعی

فعلاً service.MockOTPSender استفاده می‌شه که کد OTP رو به‌جای ارسال واقعی توی لاگ چاپ می‌کنه (internal/service/otp_sender.go). برای وصل کردن یک گیت‌وی واقعی:

type KavenegarOTPSender struct{ apiKey string }

func (s *KavenegarOTPSender) Send(phone, otp string) bool {
    // درخواست HTTP به گیت‌وی، صبر برای پاسخ، و true/false واقعی برگردون.
    // این تابع باید SYNC باشه (نتیجه‌ی واقعی رو برگردونه)، وگرنه کاربر حتی
    // در صورت شکست واقعی ارسال، پیام موفقیت می‌گیره.
}

و در cmd/api/main.go خط otpSender := service.NewMockOTPSender(...) رو با otpSender := &KavenegarOTPSender{apiKey: cfg.KavenegarAPIKey} عوض کن. بقیه‌ی کد (AuthService و...) نیازی به تغییر نداره چون فقط با اینترفیس OTPSender کار می‌کنه.

فرآیند آنبوردینگ

۹ مرحله‌ی ثابت که باید به‌ترتیب طی بشن (نمی‌شه از روی مرحله پرید، ولی مراحل قبلی هر وقت بخوای قابل ویرایش‌اند؛ بعد از تکمیل کامل هم همه‌ی مراحل آزادانه قابل ویرایش می‌شن — حتی توی همون بازه‌ای که کاربر منتظر تایید ادمینه):

# مرحله Endpoint
۱ جنسیت، اسم، تاریخ تولد POST /onboarding/basic-info
۲ عکس‌ها (حداقل ۱ عکس؛ خودِ آپلود از /profile/photos) POST /onboarding/photos
۳ قد و وزن POST /onboarding/body-info
۴ ۳ سوال از پیش‌تعریف‌شده + پاسخ POST /onboarding/profile-questions
۵ سطح تحصیلات + رشته POST /onboarding/education
۶ کارهایی که انجام می‌دهد (چندتایی) POST /onboarding/occupations
۷ ۳ چیز مهم در رابطه POST /onboarding/relationship-importance
۸ ۵ علاقه‌مندی POST /onboarding/interests
۹ دنبال چی می‌گردی POST /onboarding/looking-for

وضعیت فعلی: GET /onboarding/status

اگه کاربر قبلاً رد شده باشه و بعد از onboarded شدن هرکدوم از مراحل رو دوباره بزنه (حتی مرحله‌ی عکس‌ها)، approval_status خودکار به pending برمی‌گرده تا ادمین دوباره بررسی کنه.

بعد از تکمیل آنبوردینگ: تایید ادمین

به محض تکمیل مرحله‌ی ۸، approval_status کاربر روی pending قرار می‌گیرد. کاربر تا وقتی ادمین تاییدش نکنه (approved) اجازه‌ی ورود به صفحه‌ی دوستیابی رو نداره (این بخش در فاز بعدی پیاده می‌شه). اپ باید بعد از is_onboarded=true، مقدار approval_status رو از GET /onboarding/status یا GET /profile/me بخونه و بر اساسش یکی از این صفحات رو نشون بده:

approval_status صفحه‌ی مناسب در اپ
pending «در انتظار تایید ادمین»
approved ورود به صفحه‌ی دوستیابی
rejected نمایش rejection_reason + امکان ویرایش پروفایل

اگر کاربر رد شده باشه و بعداً هر کدوم از مراحل آنبوردینگ رو ویرایش کنه، approval_status خودکار به pending برمی‌گرده تا ادمین دوباره بررسی کنه.

گزینه‌های هر مرحله (که از پنل ادمین مدیریت می‌شن): GET /onboarding/options/:category با یکی از این category ها: education_level, field_of_study, occupation, important_in_relationship, interest, looking_for

سوالات قابل‌انتخاب برای مرحله‌ی ۳: GET /onboarding/profile-questions

چرا این معماری؟

به‌جای این‌که برای هر لیست (تحصیلات، علاقه‌مندی، اهداف رابطه و...) یک جدول جدا بسازیم، یک سیستم عمومی «دسته (Category) + گزینه (Option)» ساختیم. هر Category یک min_select/max_select داره (مثلاً علاقه‌مندی‌ها دقیقاً ۵ تا، تحصیلات دقیقاً ۱ تا، فعالیت‌ها بدون سقف). این یعنی:

  • ادمین می‌تونه از طریق API هر وقت خواست گزینه اضافه/ویرایش/غیرفعال کنه، بدون نیاز به دیپلوی کد جدید.
  • اضافه کردن یک دسته‌ی جدید در آینده فقط یک INSERT توی جدول categories می‌خواد، نه migration جدید.

دوستیابی (Discovery)، مچ و پیام‌رسانی

بعد از این‌که کاربر تایید شد (approval_status=approved) وارد این بخش می‌شه. تشخیص جنسیت مقابل فعلاً ساده و هترو-پیش‌فرض هست (زن↔مرد)؛ سیستم هنوز فیلد جداگانه‌ای برای «ترجیح جنسیتی/گرایش» نداره.

فاصله از روی latitude/longitude کاربر (که هیچ‌وقت خام برنمی‌گرده، فقط فاصله‌ی محاسبه‌شده) با فرمول haversine حساب و به نزدیک‌ترین کیلومتر گرد می‌شه — یعنی از همون ابتدا تقریبیه، نه GPS دقیق.

GET    /profile/photos                       (داخل /profile/me برمی‌گرده، endpoint جدا نداره)
POST   /profile/photos                       آپلود عکس   multipart/form-data، فیلد "photo" (jpg/png/webp، حداکثر ۵ مگابایت، حداکثر ۶ عکس)
DELETE /profile/photos/:id                   حذف عکس
PATCH  /profile/location                     body: {latitude, longitude}

GET    /discover?max_distance_km=&min_age=&max_age=   لیست پیشنهادها (نزدیک‌ترین اول)
GET    /discover/:id                         پروفایل کامل یک کاندید
POST   /discover/:id/swipe                   body: {action: "like"|"pass"|"superlike"}
                                              اگه طرف مقابل هم قبلاً لایک کرده باشه، مچ ساخته می‌شه

GET    /matches                              لیست مچ‌ها (با آخرین پیام هرکدوم)
GET    /matches/:id/messages                 پیام‌های یک گفتگو
POST   /matches/:id/messages                 body: {text}

فایل‌های آپلودشده روی دیسک محلی سرور (./uploads/photos) ذخیره و از مسیر /uploads/... سرو می‌شن (پیاده‌سازی service.PhotoStorage؛ برای پروداکشن با یک object storage واقعی جایگزینش کن، دقیقاً مثل الگوی OTPSender). پیام‌رسانی فعلاً polling-based هست، نه realtime/websocket.

API پنل ادمین

احراز هویت ادمین از همون مسیر OTP هست؛ فقط باید is_admin=true روی کاربر ست شده باشه (یا با ADMIN_BOOTSTRAP_PHONE، یا مستقیم توی دیتابیس). همه‌ی مسیرهای زیر نیاز به هدر Authorization: Bearer <access_token> یک کاربر ادمین دارن.

GET    /admin/users/pending                  لیست کاربران آنبوردشده و منتظر بررسی (قدیمی‌ترین اول)
GET    /admin/users/:id                      پروفایل کامل یک کاربر برای بررسی
POST   /admin/users/:id/approve              تایید کاربر
POST   /admin/users/:id/reject               رد کاربر    body: {reason}

GET    /admin/categories/:key                دریافت تنظیمات یک دسته
PATCH  /admin/categories/:key                ویرایش min_select/max_select/title

GET    /admin/categories/:key/options        لیست همه گزینه‌های یک دسته (فعال/غیرفعال)
POST   /admin/categories/:key/options        ساخت گزینه جدید      body: {title, sort_order}
PATCH  /admin/options/:id                    ویرایش گزینه          body: {title?, sort_order?, is_active?}
DELETE /admin/options/:id                    حذف گزینه

GET    /admin/profile-questions              لیست همه سوالات (فعال/غیرفعال)
POST   /admin/profile-questions              ساخت سوال جدید        body: {text, sort_order}
PATCH  /admin/profile-questions/:id          ویرایش سوال           body: {text?, sort_order?, is_active?}
DELETE /admin/profile-questions/:id          حذف سوال

خلاصه‌ی کامل API

احراز هویت (عمومی)

POST /api/v1/auth/request-otp     body: {phone}
POST /api/v1/auth/verify-otp      body: {phone, otp}   -> access/refresh token
POST /api/v1/auth/token/refresh   body: {refresh_token} -> access/refresh جدید

پروفایل

GET /api/v1/profile/me   (نیاز به توکن) — پروفایل کامل + همه‌ی انتخاب‌ها/پاسخ‌ها

همه‌ی پاسخ‌ها با این ساختار برمی‌گردن:

{ "success": true, "message": "...", "data": { ... } }
{ "success": false, "message": "...", "errors": { ... } }

نکاتی که از پروژه‌ی جنگویی قبلی‌تون رعایت شده

  • ارسال OTP sync انجام می‌شه (نه توی یک goroutine بی‌خیال از نتیجه)، چون قبلاً دیدیم نسخه‌ی async باعث می‌شد کاربر با اینکه ارسال واقعاً شکست خورده، پیام موفقیت بگیره.
  • اگه ارسال پیامک واقعاً شکست بخوره، OTP ذخیره‌شده پاک می‌شه تا کاربر فوراً بتونه دوباره تلاش کنه (وگرنه تا پایان OTP_EXPIRY_SECONDS با خطای «کد قبلی هنوز معتبره» قفل می‌مونه).
  • OTP هیچ‌وقت خام ذخیره نمی‌شه؛ با HMAC-SHA256 هش می‌شه.
  • کد OTP فقط وقتی APP_ENV != production باشه توی لاگ چاپ می‌شه.