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
+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
}
+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
}
+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
}
+37
View File
@@ -0,0 +1,37 @@
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)
}
// 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,
})
}