Initial commit: dating app backend (Go/Gin/GORM/Postgres)

Phone+OTP auth, staged onboarding, admin approval workflow, and a
full discovery/swipe/match/chat system with photo upload and
approximate distance (haversine) based on user location.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 23:23:47 +03:30
commit 2823fb45f5
49 changed files with 4582 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
# اپلیکیشن دوستیابی — بک‌اند 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
```bash
createdb dating_app
```
### ۲) تنظیمات
```bash
cp .env.example .env
# مقادیر رو مطابق محیط خودت ویرایش کن (خصوصاً JWT_*_SECRET و OTP_HASH_SECRET در پروداکشن)
```
`ADMIN_BOOTSTRAP_PHONE` رو به شماره‌ی موبایل خودت تنظیم کن؛ در اولین اجرای برنامه
یک کاربر ادمین با اون شماره ساخته می‌شه (یا اگه از قبل وجود داشت، ادمین می‌شه).
بعدش با همون شماره از مسیر عادی OTP وارد شو تا توکن ادمین بگیری.
### ۳) اجرا
```bash
go run ./cmd/api
```
سرور روی `http://localhost:8080` بالا میاد. مسیر `/health` برای healthcheck هست.
## اتصال گیت‌وی پیامک واقعی
فعلاً `service.MockOTPSender` استفاده می‌شه که کد OTP رو به‌جای ارسال واقعی توی
لاگ چاپ می‌کنه (`internal/service/otp_sender.go`). برای وصل کردن یک گیت‌وی واقعی:
```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` |
| ۲ | قد و وزن | `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`
### بعد از تکمیل آنبوردینگ: تایید ادمین
به محض تکمیل مرحله‌ی ۸، `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 (نیاز به توکن) — پروفایل کامل + همه‌ی انتخاب‌ها/پاسخ‌ها
```
همه‌ی پاسخ‌ها با این ساختار برمی‌گردن:
```json
{ "success": true, "message": "...", "data": { ... } }
{ "success": false, "message": "...", "errors": { ... } }
```
## نکاتی که از پروژه‌ی جنگویی قبلی‌تون رعایت شده
- ارسال OTP **sync** انجام می‌شه (نه توی یک goroutine بی‌خیال از نتیجه)،
چون قبلاً دیدیم نسخه‌ی async باعث می‌شد کاربر با اینکه ارسال واقعاً شکست
خورده، پیام موفقیت بگیره.
- اگه ارسال پیامک واقعاً شکست بخوره، OTP ذخیره‌شده پاک می‌شه تا کاربر
فوراً بتونه دوباره تلاش کنه (وگرنه تا پایان OTP_EXPIRY_SECONDS با خطای
«کد قبلی هنوز معتبره» قفل می‌مونه).
- OTP هیچ‌وقت خام ذخیره نمی‌شه؛ با HMAC-SHA256 هش می‌شه.
- کد OTP فقط وقتی `APP_ENV != production` باشه توی لاگ چاپ می‌شه.