2823fb45f5
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>
221 lines
12 KiB
Markdown
221 lines
12 KiB
Markdown
# اپلیکیشن دوستیابی — بکاند 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` باشه توی لاگ چاپ میشه.
|