اپلیکیشن دوستیابی — بکاند 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باشه توی لاگ چاپ میشه.