chore: merge backend repo history into monorepo at backend/

git-subtree-dir: backend
git-subtree-mainline: dc11190527
git-subtree-split: acae976622
This commit is contained in:
2026-09-27 10:30:16 +03:30
67 changed files with 6835 additions and 0 deletions
+468
View File
@@ -0,0 +1,468 @@
package service
import (
"errors"
"time"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
)
var (
ErrUserNotOnboarded = errors.New("کاربر هنوز آنبوردینگ را تکمیل نکرده است")
ErrBlockReasonEmpty = errors.New("دلیل بلاک کردن نمی‌تواند خالی باشد")
ErrReportReasonEmpty = errors.New("دلیل گزارش نمی‌تواند خالی باشد")
)
type AdminService struct {
optionRepo *repository.OptionRepository
questionRepo *repository.QuestionRepository
userRepo *repository.UserRepository
reportRepo *repository.ReportRepository
matchRepo *repository.MatchRepository
photoRepo *repository.PhotoRepository
profileService *ProfileService
}
func NewAdminService(
optionRepo *repository.OptionRepository,
questionRepo *repository.QuestionRepository,
userRepo *repository.UserRepository,
reportRepo *repository.ReportRepository,
matchRepo *repository.MatchRepository,
photoRepo *repository.PhotoRepository,
profileService *ProfileService,
) *AdminService {
return &AdminService{
optionRepo: optionRepo,
questionRepo: questionRepo,
userRepo: userRepo,
reportRepo: reportRepo,
matchRepo: matchRepo,
photoRepo: photoRepo,
profileService: profileService,
}
}
// ---------------- بررسی و تایید کاربران ----------------
// GetUser یک کاربر رو برای نمایش به ادمین (بررسی درخواست) برمی‌گردونه
func (s *AdminService) GetUser(id uint) (*models.User, error) {
return s.userRepo.FindByID(id)
}
// ListPendingUsers کاربرانی که آنبوردینگ رو تکمیل کردن و منتظر بررسی‌اند، به
// همراه عکس‌هاشون (batch گرفته می‌شه، نه یک کوئری جدا به‌ازای هر کاربر) تا
// ادمین بدون کلیک روی هرکدوم بتونه یه نگاه سریع به عکس‌ها بندازه.
func (s *AdminService) ListPendingUsers() ([]models.User, map[uint][]string, error) {
users, err := s.userRepo.ListPendingApproval()
if err != nil {
return nil, nil, err
}
ids := make([]uint, len(users))
for i, u := range users {
ids[i] = u.ID
}
photos, err := s.photoRepo.ListByUserIDs(ids)
if err != nil {
return nil, nil, err
}
photosByUser := make(map[uint][]string, len(ids))
for _, p := range photos {
photosByUser[p.UserID] = append(photosByUser[p.UserID], p.URL)
}
return users, photosByUser, nil
}
// ApproveUser کاربر رو تایید می‌کنه تا بتونه وارد صفحه‌ی دوستیابی بشه
func (s *AdminService) ApproveUser(id uint) (*models.User, error) {
user, err := s.userRepo.FindByID(id)
if err != nil {
return nil, err
}
if !user.IsOnboarded {
return nil, ErrUserNotOnboarded
}
now := time.Now()
updates := map[string]interface{}{
"approval_status": models.ApprovalApproved,
"rejection_reason": nil,
"reviewed_at": now,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, err
}
user.ApprovalStatus = models.ApprovalApproved
user.RejectionReason = nil
user.ReviewedAt = &now
return user, nil
}
// RejectUser درخواست عضویت کاربر رو با یک دلیل مشخص رد می‌کنه
func (s *AdminService) RejectUser(id uint, reason string) (*models.User, error) {
user, err := s.userRepo.FindByID(id)
if err != nil {
return nil, err
}
if !user.IsOnboarded {
return nil, ErrUserNotOnboarded
}
now := time.Now()
updates := map[string]interface{}{
"approval_status": models.ApprovalRejected,
"rejection_reason": reason,
"reviewed_at": now,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, err
}
user.ApprovalStatus = models.ApprovalRejected
user.RejectionReason = &reason
user.ReviewedAt = &now
return user, nil
}
// ---------------- همه‌ی کاربران، بلاک/آنبلاک، گزارش ----------------
// AdminUserListItem یک کاربر به‌همراه تعداد گزارش‌هاش، برای لیست کامل کاربران
type AdminUserListItem struct {
models.User
ReportCount int64 `json:"report_count"`
}
// ListAllUsers همه‌ی کاربران رو صفحه‌بندی‌شده و قابل‌جستجو (شماره/اسم) برمی‌گردونه.
func (s *AdminService) ListAllUsers(search string, page, pageSize int) ([]AdminUserListItem, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 25
}
users, total, err := s.userRepo.ListAll(search, pageSize, (page-1)*pageSize)
if err != nil {
return nil, 0, err
}
ids := make([]uint, len(users))
for i, u := range users {
ids[i] = u.ID
}
counts, err := s.reportRepo.CountsByUserIDs(ids)
if err != nil {
return nil, 0, err
}
items := make([]AdminUserListItem, len(users))
for i, u := range users {
items[i] = AdminUserListItem{User: u, ReportCount: counts[u.ID]}
}
return items, total, nil
}
// BlockUser از ورود کاربر به اپ جلوگیری می‌کنه (چه توکن معتبر داشته باشه چه نه).
func (s *AdminService) BlockUser(id uint, reason string) (*models.User, error) {
if reason == "" {
return nil, ErrBlockReasonEmpty
}
user, err := s.userRepo.FindByID(id)
if err != nil {
return nil, err
}
now := time.Now()
updates := map[string]interface{}{
"state": models.UserStateBlocked,
"blocked_reason": reason,
"blocked_at": now,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, err
}
user.State = models.UserStateBlocked
user.BlockedReason = &reason
user.BlockedAt = &now
return user, nil
}
// UnblockUser دوباره اجازه‌ی ورود به کاربر می‌ده.
func (s *AdminService) UnblockUser(id uint) (*models.User, error) {
user, err := s.userRepo.FindByID(id)
if err != nil {
return nil, err
}
updates := map[string]interface{}{
"state": models.UserStateActive,
"blocked_reason": nil,
"blocked_at": nil,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, err
}
user.State = models.UserStateActive
user.BlockedReason = nil
user.BlockedAt = nil
return user, nil
}
// ReportUser یک یادداشت نظارتی روی کاربر ثبت می‌کنه (مستقل از بلاک کردن).
func (s *AdminService) ReportUser(id uint, reason string) (*models.Report, error) {
if reason == "" {
return nil, ErrReportReasonEmpty
}
if _, err := s.userRepo.FindByID(id); err != nil {
return nil, err
}
report := &models.Report{UserID: id, Reason: reason}
if err := s.reportRepo.Create(report); err != nil {
return nil, err
}
return report, nil
}
// ReportPhoto یک عکس مشخص رو گزارش می‌کنه (روی صاحب عکس هم توی سابقه‌ی
// گزارش‌ها ثبت می‌شه، با ارجاع به همون عکس).
func (s *AdminService) ReportPhoto(photoID uint, reason string) (*models.Report, error) {
if reason == "" {
return nil, ErrReportReasonEmpty
}
photo, err := s.photoRepo.FindByID(photoID)
if err != nil {
return nil, err
}
report := &models.Report{UserID: photo.UserID, PhotoID: &photo.ID, Reason: reason}
if err := s.reportRepo.Create(report); err != nil {
return nil, err
}
return report, nil
}
// ListReports سابقه‌ی گزارش‌های ثبت‌شده روی یک کاربر رو برمی‌گردونه.
func (s *AdminService) ListReports(userID uint) ([]models.Report, error) {
return s.reportRepo.ListByUser(userID)
}
// ---------------- مچ‌ها (رفقا) ----------------
// AdminMatchView یک مچ به‌همراه اطلاعات خلاصه‌ی هر دو طرف، برای نمایش به ادمین.
type AdminMatchView struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UserOneID uint `json:"user_one_id"`
UserOneName string `json:"user_one_name"`
UserOnePhone string `json:"user_one_phone"`
UserTwoID uint `json:"user_two_id"`
UserTwoName string `json:"user_two_name"`
UserTwoPhone string `json:"user_two_phone"`
}
// ListAllMatches همه‌ی مچ‌های اپ رو صفحه‌بندی‌شده برمی‌گردونه («رفقا»یی که با
// هم مچ شدن)، جدیدترین اول.
func (s *AdminService) ListAllMatches(page, pageSize int) ([]AdminMatchView, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 25
}
matches, total, err := s.matchRepo.ListAllPaginated(pageSize, (page-1)*pageSize)
if err != nil {
return nil, 0, err
}
userIDs := make([]uint, 0, len(matches)*2)
for _, m := range matches {
userIDs = append(userIDs, m.UserOneID, m.UserTwoID)
}
users, err := s.userRepo.FindByIDs(userIDs)
if err != nil {
return nil, 0, err
}
userByID := make(map[uint]models.User, len(users))
for _, u := range users {
userByID[u.ID] = u
}
views := make([]AdminMatchView, 0, len(matches))
for _, m := range matches {
one := userByID[m.UserOneID]
two := userByID[m.UserTwoID]
views = append(views, AdminMatchView{
ID: m.ID,
CreatedAt: m.CreatedAt,
UserOneID: one.ID,
UserOneName: one.FirstName,
UserOnePhone: one.Phone,
UserTwoID: two.ID,
UserTwoName: two.FirstName,
UserTwoPhone: two.Phone,
})
}
return views, total, nil
}
// ---------------- نظارت بر عکس‌ها ----------------
// AdminPhotoView یک عکس آپلودشده به‌همراه اطلاعات صاحبش، برای نظارت ادمین.
type AdminPhotoView struct {
models.Photo
OwnerName string `json:"owner_name"`
OwnerPhone string `json:"owner_phone"`
}
// ListAllPhotos همه‌ی عکس‌های آپلودشده‌ی اپ رو صفحه‌بندی‌شده، جدیدترین اول برمی‌گردونه.
func (s *AdminService) ListAllPhotos(page, pageSize int) ([]AdminPhotoView, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 50
}
photos, total, err := s.photoRepo.ListAllPaginated(pageSize, (page-1)*pageSize)
if err != nil {
return nil, 0, err
}
ids := make([]uint, len(photos))
for i, p := range photos {
ids[i] = p.UserID
}
users, err := s.userRepo.FindByIDs(ids)
if err != nil {
return nil, 0, err
}
userByID := make(map[uint]models.User, len(users))
for _, u := range users {
userByID[u.ID] = u
}
views := make([]AdminPhotoView, len(photos))
for i, p := range photos {
owner := userByID[p.UserID]
views[i] = AdminPhotoView{Photo: p, OwnerName: owner.FirstName, OwnerPhone: owner.Phone}
}
return views, total, nil
}
// DeletePhotoAdmin یک عکس رو صرف‌نظر از مالکش حذف می‌کنه (برای نظارت محتوایی).
func (s *AdminService) DeletePhotoAdmin(photoID uint) error {
return s.profileService.DeletePhotoAdmin(photoID)
}
// ---------------- دسته‌ها و گزینه‌ها ----------------
func (s *AdminService) GetCategory(key models.CategoryKey) (*models.Category, error) {
return s.optionRepo.GetCategoryByKey(key)
}
func (s *AdminService) UpdateCategory(key models.CategoryKey, req dto.UpdateCategoryRequest) (*models.Category, error) {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
return nil, err
}
if req.Title != nil {
cat.Title = *req.Title
}
if req.MinSelect != nil {
cat.MinSelect = *req.MinSelect
}
if req.MaxSelect != nil {
cat.MaxSelect = *req.MaxSelect
}
if err := s.optionRepo.UpdateCategory(cat); err != nil {
return nil, err
}
return cat, nil
}
func (s *AdminService) ListOptions(key models.CategoryKey) ([]models.Option, error) {
return s.optionRepo.ListOptionsByCategoryKeyAdmin(key)
}
func (s *AdminService) CreateOption(key models.CategoryKey, req dto.CreateOptionRequest) (*models.Option, error) {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
return nil, err
}
opt := &models.Option{
CategoryID: cat.ID,
Title: req.Title,
SortOrder: req.SortOrder,
IsActive: true,
}
if err := s.optionRepo.CreateOption(opt); err != nil {
return nil, err
}
return opt, nil
}
func (s *AdminService) UpdateOption(id uint, req dto.UpdateOptionRequest) (*models.Option, error) {
opt, err := s.optionRepo.FindOptionByID(id)
if err != nil {
return nil, err
}
if req.Title != nil {
opt.Title = *req.Title
}
if req.SortOrder != nil {
opt.SortOrder = *req.SortOrder
}
if req.IsActive != nil {
opt.IsActive = *req.IsActive
}
if err := s.optionRepo.UpdateOption(opt); err != nil {
return nil, err
}
return opt, nil
}
func (s *AdminService) DeleteOption(id uint) error {
return s.optionRepo.DeleteOption(id)
}
// ---------------- سوالات پروفایل ----------------
func (s *AdminService) ListQuestions() ([]models.ProfileQuestion, error) {
return s.questionRepo.ListAllAdmin()
}
func (s *AdminService) CreateQuestion(req dto.CreateProfileQuestionRequest) (*models.ProfileQuestion, error) {
q := &models.ProfileQuestion{
Text: req.Text,
SortOrder: req.SortOrder,
IsActive: true,
}
if err := s.questionRepo.Create(q); err != nil {
return nil, err
}
return q, nil
}
func (s *AdminService) UpdateQuestion(id uint, req dto.UpdateProfileQuestionRequest) (*models.ProfileQuestion, error) {
q, err := s.questionRepo.FindByID(id)
if err != nil {
return nil, err
}
if req.Text != nil {
q.Text = *req.Text
}
if req.SortOrder != nil {
q.SortOrder = *req.SortOrder
}
if req.IsActive != nil {
q.IsActive = *req.IsActive
}
if err := s.questionRepo.Update(q); err != nil {
return nil, err
}
return q, nil
}
func (s *AdminService) DeleteQuestion(id uint) error {
return s.questionRepo.Delete(id)
}
+187
View File
@@ -0,0 +1,187 @@
package service
import (
"errors"
"time"
"gorm.io/gorm"
"dating-app/internal/models"
"dating-app/internal/repository"
"dating-app/internal/utils"
)
var (
ErrPhoneBlocked = errors.New("حساب کاربری مسدود شده است")
ErrOTPStillValid = errors.New("کد تایید قبلی هنوز معتبر است")
ErrOTPNotRequested = errors.New("ابتدا باید کد تایید را درخواست کنید")
ErrOTPExpired = errors.New("کد تایید منقضی شده است")
ErrOTPInvalid = errors.New("کد وارد شده نامعتبر است")
ErrSMSSendFailed = errors.New("ارسال پیامک با خطا مواجه شد؛ لطفاً چند لحظه دیگر دوباره تلاش کنید")
ErrUserNotFound = errors.New("کاربر یافت نشد")
)
// OTPStillValidError برای برگردوندن تعداد ثانیه‌ی باقی‌مانده تا انقضای کد قبلی
type OTPStillValidError struct {
RemainingSeconds int
}
func (e *OTPStillValidError) Error() string {
return "کد تایید قبلی هنوز معتبر است"
}
type AuthService struct {
userRepo *repository.UserRepository
otpSender OTPSender
jwtManager *utils.JWTManager
otpExpiry time.Duration
otpLength int
otpHashSecret string
}
func NewAuthService(
userRepo *repository.UserRepository,
otpSender OTPSender,
jwtManager *utils.JWTManager,
otpExpirySeconds int,
otpLength int,
otpHashSecret string,
) *AuthService {
return &AuthService{
userRepo: userRepo,
otpSender: otpSender,
jwtManager: jwtManager,
otpExpiry: time.Duration(otpExpirySeconds) * time.Second,
otpLength: otpLength,
otpHashSecret: otpHashSecret,
}
}
// RequestOTP یک کد تایید جدید تولید، هش و ذخیره می‌کند و از طریق OTPSender ارسال می‌کند.
func (s *AuthService) RequestOTP(phone string) error {
if err := utils.ValidatePhone(phone); err != nil {
return err
}
user, _, err := s.userRepo.GetOrCreateByPhone(phone)
if err != nil {
return err
}
if user.State == models.UserStateBlocked {
return ErrPhoneBlocked
}
// اگر کد قبلی هنوز معتبره، اجازه‌ی ارسال دوباره نده
if user.OTPCode != nil && user.OTPExpiry != nil && time.Now().Before(*user.OTPExpiry) {
remaining := int(time.Until(*user.OTPExpiry).Seconds())
return &OTPStillValidError{RemainingSeconds: remaining}
}
otp := utils.GenerateOTP(s.otpLength)
hashed := utils.HashOTP(otp, s.otpHashSecret)
expiry := time.Now().Add(s.otpExpiry)
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"otp_code": hashed,
"otp_expiry": expiry,
}); err != nil {
return err
}
// ارسال پیامک به‌صورت sync؛ اگه واقعاً شکست خورد، صادقانه به کاربر خبر بده
// و کد ذخیره‌شده رو پاک کن تا کاربر بلافاصله بتونه دوباره تلاش کنه
// (وگرنه تا پایان OTPExpiry با پیام «کد قبلی هنوز معتبره» قفل می‌مونه).
if ok := s.otpSender.Send(phone, otp); !ok {
_ = s.userRepo.UpdateFields(user, map[string]interface{}{
"otp_code": nil,
"otp_expiry": nil,
})
return ErrSMSSendFailed
}
return nil
}
// VerifyOTP کد را بررسی می‌کند و در صورت صحت، کاربر را فعال کرده و توکن صادر می‌کند.
func (s *AuthService) VerifyOTP(phone, otp string) (*models.User, string, string, bool, error) {
user, err := s.userRepo.FindByPhone(phone)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", "", false, ErrUserNotFound
}
return nil, "", "", false, err
}
if user.OTPCode == nil || user.OTPExpiry == nil {
return nil, "", "", false, ErrOTPNotRequested
}
if time.Now().After(*user.OTPExpiry) {
_ = s.userRepo.UpdateFields(user, map[string]interface{}{
"otp_code": nil,
"otp_expiry": nil,
})
return nil, "", "", false, ErrOTPExpired
}
if *user.OTPCode != utils.HashOTP(otp, s.otpHashSecret) {
return nil, "", "", false, ErrOTPInvalid
}
isNewUser := user.State == models.UserStateInactive
now := time.Now()
updates := map[string]interface{}{
"otp_code": nil,
"otp_expiry": nil,
"state": models.UserStateActive,
"last_login": now,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, "", "", false, err
}
// رفرش شیء user در حافظه برای برگردوندن مقادیر به‌روز
user.State = models.UserStateActive
user.LastLogin = &now
user.OTPCode = nil
user.OTPExpiry = nil
access, err := s.jwtManager.GenerateAccessToken(user.ID, user.IsAdmin)
if err != nil {
return nil, "", "", false, err
}
refresh, err := s.jwtManager.GenerateRefreshToken(user.ID, user.IsAdmin)
if err != nil {
return nil, "", "", false, err
}
return user, access, refresh, isNewUser, nil
}
// RefreshTokens با یک refresh token معتبر، یک جفت توکن جدید صادر می‌کند.
func (s *AuthService) RefreshTokens(refreshToken string) (string, string, error) {
claims, err := s.jwtManager.ParseRefreshToken(refreshToken)
if err != nil {
return "", "", err
}
user, err := s.userRepo.FindByID(claims.UserID)
if err != nil {
return "", "", ErrUserNotFound
}
if user.State == models.UserStateBlocked || !user.IsActive {
return "", "", ErrPhoneBlocked
}
access, err := s.jwtManager.GenerateAccessToken(user.ID, user.IsAdmin)
if err != nil {
return "", "", err
}
newRefresh, err := s.jwtManager.GenerateRefreshToken(user.ID, user.IsAdmin)
if err != nil {
return "", "", err
}
return access, newRefresh, nil
}
+136
View File
@@ -0,0 +1,136 @@
package service
import (
"bytes"
"compress/gzip"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"time"
"dating-app/internal/config"
)
var (
ErrBackupNotFound = errors.New("فایل بک‌آپ یافت نشد")
backupNamePattern = regexp.MustCompile(`^backup_\d{8}_\d{6}\.sql\.gz$`)
)
type BackupInfo struct {
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt time.Time `json:"created_at"`
}
// BackupService با اجرای pg_dump یک نسخه‌ی پشتیبان فشرده از کل دیتابیس
// می‌سازه. برای گرفتن بک‌آپ نیازی به هیچ ابزار بیرونی/سرویس ابری نیست؛
// فایل‌ها روی همون دیسک سرور (پوشه‌ی dir) نگه داشته می‌شن.
type BackupService struct {
cfg *config.Config
dir string
}
func NewBackupService(cfg *config.Config, dir string) *BackupService {
return &BackupService{cfg: cfg, dir: dir}
}
// Create یک بک‌آپ جدید می‌گیره: خروجی pg_dump به‌صورت stream به gzip داده
// می‌شه تا حافظه و فضای دیسک مصرفی کمتر باشه.
func (s *BackupService) Create() (*BackupInfo, error) {
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return nil, fmt.Errorf("ساخت پوشه‌ی بک‌آپ ناموفق بود: %w", err)
}
filename := fmt.Sprintf("backup_%s.sql.gz", time.Now().Format("20060102_150405"))
fullPath := filepath.Join(s.dir, filename)
out, err := os.Create(fullPath)
if err != nil {
return nil, fmt.Errorf("ساخت فایل بک‌آپ ناموفق بود: %w", err)
}
gz := gzip.NewWriter(out)
cmd := exec.Command("pg_dump",
"-h", s.cfg.DBHost,
"-p", s.cfg.DBPort,
"-U", s.cfg.DBUser,
"-d", s.cfg.DBName,
"--no-password",
"--format=plain",
)
cmd.Env = append(os.Environ(), "PGPASSWORD="+s.cfg.DBPassword)
cmd.Stdout = gz
var stderr bytes.Buffer
cmd.Stderr = &stderr
runErr := cmd.Run()
gzErr := gz.Close()
outErr := out.Close()
if runErr != nil {
os.Remove(fullPath)
return nil, fmt.Errorf("pg_dump ناموفق بود: %v - %s", runErr, stderr.String())
}
if gzErr != nil {
os.Remove(fullPath)
return nil, gzErr
}
if outErr != nil {
return nil, outErr
}
info, err := os.Stat(fullPath)
if err != nil {
return nil, err
}
return &BackupInfo{Filename: filename, SizeBytes: info.Size(), CreatedAt: info.ModTime()}, nil
}
// List بک‌آپ‌های موجود رو، جدیدترین اول، برمی‌گردونه.
func (s *BackupService) List() ([]BackupInfo, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
if os.IsNotExist(err) {
return []BackupInfo{}, nil
}
return nil, err
}
backups := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !backupNamePattern.MatchString(e.Name()) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
backups = append(backups, BackupInfo{
Filename: e.Name(),
SizeBytes: info.Size(),
CreatedAt: info.ModTime(),
})
}
sort.Slice(backups, func(i, j int) bool { return backups[i].CreatedAt.After(backups[j].CreatedAt) })
return backups, nil
}
// ResolvePath یک نام فایل بک‌آپ (که مستقیماً از پارامتر URL میاد) رو به مسیر
// کامل تبدیل می‌کنه. چون ورودی از کاربره، صراحتاً باید در برابر path traversal
// (مثلاً "../../etc/passwd") محافظت بشه؛ برای همین فقط اسم‌هایی که دقیقاً با
// الگوی خودمون ساخته شدن (backup_YYYYMMDD_HHMMSS.sql.gz) قبول می‌شن.
func (s *BackupService) ResolvePath(filename string) (string, error) {
if !backupNamePattern.MatchString(filename) {
return "", ErrBackupNotFound
}
full := filepath.Join(s.dir, filename)
if _, err := os.Stat(full); err != nil {
return "", ErrBackupNotFound
}
return full, nil
}
+179
View File
@@ -0,0 +1,179 @@
package service
import (
"encoding/base64"
"errors"
"time"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
)
var (
ErrNotMatchMember = errors.New("این گفتگو مال تو نیست")
ErrInvalidMessageEncoding = errors.New("پیام رمزنگاری‌شده نامعتبر است (کدگذاری اشتباه)")
)
type ChatService struct {
matchRepo *repository.MatchRepository
messageRepo *repository.MessageRepository
userRepo *repository.UserRepository
photoRepo *repository.PhotoRepository
}
func NewChatService(
matchRepo *repository.MatchRepository,
messageRepo *repository.MessageRepository,
userRepo *repository.UserRepository,
photoRepo *repository.PhotoRepository,
) *ChatService {
return &ChatService{
matchRepo: matchRepo,
messageRepo: messageRepo,
userRepo: userRepo,
photoRepo: photoRepo,
}
}
// ListMatches همه‌ی مچ‌های کاربر رو همراه با اطلاعات طرف مقابل و آخرین پیام
// برمی‌گردونه. قبلاً به‌ازای هر مچ ۳ کوئری جدا (کاربر، عکس‌ها، آخرین پیام)
// می‌زد؛ الان هر سه رو batch می‌کنه تا صرف‌نظر از تعداد مچ‌ها، تعداد کوئری‌ها
// ثابت (۴ تا) بمونه.
func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) {
matches, err := s.matchRepo.ListByUser(userID)
if err != nil {
return nil, err
}
if len(matches) == 0 {
return []dto.MatchView{}, nil
}
otherIDs := make([]uint, len(matches))
matchIDs := make([]uint, len(matches))
for i, m := range matches {
otherIDs[i] = repository.OtherUserID(&m, userID)
matchIDs[i] = m.ID
}
otherUsers, err := s.userRepo.FindByIDs(otherIDs)
if err != nil {
return nil, err
}
userByID := make(map[uint]models.User, len(otherUsers))
for _, u := range otherUsers {
userByID[u.ID] = u
}
photos, err := s.photoRepo.ListByUserIDs(otherIDs)
if err != nil {
return nil, err
}
photosByUser := make(map[uint][]string, len(otherIDs))
for _, p := range photos {
photosByUser[p.UserID] = append(photosByUser[p.UserID], p.URL)
}
lastMessages, err := s.messageRepo.LastMessagesByMatchIDs(matchIDs)
if err != nil {
return nil, err
}
views := make([]dto.MatchView, 0, len(matches))
for _, m := range matches {
otherID := repository.OtherUserID(&m, userID)
other, ok := userByID[otherID]
if !ok {
continue
}
view := dto.MatchView{
ID: m.ID,
UserID: other.ID,
FirstName: other.FirstName,
Photos: orEmptyStrings(photosByUser[otherID]),
CreatedAt: m.CreatedAt.Format(time.RFC3339),
PartnerPublicKey: other.PublicKey,
}
if last, ok := lastMessages[m.ID]; ok {
at := last.CreatedAt.Format(time.RFC3339)
view.LastAt = &at
if last.CipherText != nil {
view.LastMessageCipherText = last.CipherText
view.LastMessageNonce = last.Nonce
} else {
text := last.Text
view.LastMessage = &text
}
}
views = append(views, view)
}
return views, nil
}
func (s *ChatService) getAuthorizedMatch(userID, matchID uint) (uint, error) {
match, err := s.matchRepo.FindByID(matchID)
if err != nil {
return 0, err
}
if !repository.IsMember(match, userID) {
return 0, ErrNotMatchMember
}
return match.ID, nil
}
// ListMessages پیام‌های یک مچ رو برمی‌گردونه؛ فقط اگه کاربر عضو اون مچ باشه.
func (s *ChatService) ListMessages(userID, matchID uint) ([]dto.MessageView, error) {
if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
return nil, err
}
messages, err := s.messageRepo.ListByMatch(matchID)
if err != nil {
return nil, err
}
views := make([]dto.MessageView, 0, len(messages))
for _, m := range messages {
views = append(views, dto.MessageView{
ID: m.ID,
MatchID: m.MatchID,
SenderID: m.SenderID,
Text: m.Text,
CipherText: m.CipherText,
Nonce: m.Nonce,
CreatedAt: m.CreatedAt.Format(time.RFC3339),
IsMine: m.SenderID == userID,
})
}
return views, nil
}
// SendMessage یک پیام رمزنگاری‌شده توی یک مچ ثبت می‌کنه؛ فقط اگه کاربر عضو
// اون مچ باشه. سرور محتوای پیام رو نمی‌بینه و نمی‌تونه — CipherText/Nonce
// قبلاً روی دستگاه فرستنده با کلید عمومی طرف مقابل رمزنگاری شدن؛ اینجا فقط
// اندازه‌ی base64 اونا (از طریق binding توی SendMessageRequest) چک می‌شه.
func (s *ChatService) SendMessage(userID, matchID uint, cipherText, nonce string) (*dto.MessageView, error) {
if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
return nil, err
}
if _, err := base64.StdEncoding.DecodeString(cipherText); err != nil {
return nil, ErrInvalidMessageEncoding
}
if _, err := base64.StdEncoding.DecodeString(nonce); err != nil {
return nil, ErrInvalidMessageEncoding
}
msg := &models.Message{MatchID: matchID, SenderID: userID, CipherText: &cipherText, Nonce: &nonce}
if err := s.messageRepo.Create(msg); err != nil {
return nil, err
}
return &dto.MessageView{
ID: msg.ID,
MatchID: msg.MatchID,
SenderID: msg.SenderID,
CipherText: msg.CipherText,
Nonce: msg.Nonce,
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
IsMine: true,
}, nil
}
@@ -0,0 +1,339 @@
package service
import (
"errors"
"math"
"sort"
"time"
"gorm.io/gorm"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
"dating-app/internal/utils"
)
const (
DefaultMinAge = 18
DefaultMaxAge = 60
DiscoverBatchSize = 60
DiscoverResultSize = 20
)
var (
ErrGenderNotSet = errors.New("برای دیدن پیشنهادها اول باید جنسیتت مشخص شده باشه")
ErrCannotSwipeSelf = errors.New("نمی‌تونی روی خودت سواپ بزنی")
ErrAlreadySwiped = errors.New("قبلاً روی این پروفایل تصمیم گرفتی")
)
// جنسیت مخالف کاربر رو برمی‌گردونه. این ساده‌ترین فرض ممکنه (هترو دیفالت)؛
// سیستم فعلاً فیلد جداگانه‌ای برای «ترجیح جنسیتی/گرایش» نداره.
func oppositeGender(g models.Gender) models.Gender {
if g == models.GenderFemale {
return models.GenderMale
}
return models.GenderFemale
}
type DiscoveryService struct {
userRepo *repository.UserRepository
photoRepo *repository.PhotoRepository
swipeRepo *repository.SwipeRepository
matchRepo *repository.MatchRepository
optionRepo *repository.OptionRepository
questionRepo *repository.QuestionRepository
}
func NewDiscoveryService(
userRepo *repository.UserRepository,
photoRepo *repository.PhotoRepository,
swipeRepo *repository.SwipeRepository,
matchRepo *repository.MatchRepository,
optionRepo *repository.OptionRepository,
questionRepo *repository.QuestionRepository,
) *DiscoveryService {
return &DiscoveryService{
userRepo: userRepo,
photoRepo: photoRepo,
swipeRepo: swipeRepo,
matchRepo: matchRepo,
optionRepo: optionRepo,
questionRepo: questionRepo,
}
}
type candidateWithDistance struct {
user models.User
distance *float64
}
// GetFeed لیست پیشنهادهای صفحه‌ی دوستیابی رو برمی‌گردونه؛ نزدیک‌ترین‌ها اول.
func (s *DiscoveryService) GetFeed(user *models.User, maxDistanceKM *float64, minAge, maxAge int) ([]dto.DiscoverCandidate, error) {
if user.Gender == nil {
return nil, ErrGenderNotSet
}
if minAge <= 0 {
minAge = DefaultMinAge
}
if maxAge <= 0 || maxAge < minAge {
maxAge = DefaultMaxAge
}
candidates, err := s.userRepo.ListDiscoverable(user.ID, oppositeGender(*user.Gender), minAge, maxAge, DiscoverBatchSize)
if err != nil {
return nil, err
}
if len(candidates) == 0 {
return []dto.DiscoverCandidate{}, nil
}
ids := make([]uint, len(candidates))
for i, c := range candidates {
ids[i] = c.ID
}
photos, err := s.photoRepo.ListByUserIDs(ids)
if err != nil {
return nil, err
}
photosByUser := map[uint][]string{}
for _, p := range photos {
photosByUser[p.UserID] = append(photosByUser[p.UserID], p.URL)
}
interestsByUser := s.batchInterests(ids)
withDist := make([]candidateWithDistance, 0, len(candidates))
for _, cand := range candidates {
var dist *float64
if user.Latitude != nil && user.Longitude != nil && cand.Latitude != nil && cand.Longitude != nil {
km := math.Round(utils.HaversineKM(*user.Latitude, *user.Longitude, *cand.Latitude, *cand.Longitude))
dist = &km
}
if maxDistanceKM != nil && dist != nil && *dist > *maxDistanceKM {
continue
}
withDist = append(withDist, candidateWithDistance{user: cand, distance: dist})
}
sort.SliceStable(withDist, func(i, j int) bool {
if withDist[i].distance == nil {
return false
}
if withDist[j].distance == nil {
return true
}
return *withDist[i].distance < *withDist[j].distance
})
if len(withDist) > DiscoverResultSize {
withDist = withDist[:DiscoverResultSize]
}
result := make([]dto.DiscoverCandidate, 0, len(withDist))
for _, wd := range withDist {
result = append(result, dto.DiscoverCandidate{
ID: wd.user.ID,
FirstName: wd.user.FirstName,
Age: utils.CalcAge(wd.user.BirthDate),
Bio: derefStr(wd.user.Bio),
// نبود عکس/علاقه‌مندی نباید توی JSON به‌صورت null دربیاد؛ کلاینت‌ها
// همیشه یک آرایه (حتی خالی) انتظار دارن، نه null.
Photos: orEmptyStrings(photosByUser[wd.user.ID]),
DistanceKM: wd.distance,
Interests: orEmptyStrings(interestsByUser[wd.user.ID]),
})
}
return result, nil
}
func orEmptyStrings(s []string) []string {
if s == nil {
return []string{}
}
return s
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// batchInterests علاقه‌مندی‌های تمام کاندیداهای فید رو در یک کوئری واحد
// می‌گیره (به‌جای یک کوئری جدا به‌ازای هر کاندیدا)؛ روی فید دیسکاوری که تا ۶۰
// کاربر با هم پردازش می‌شن، این تفاوت بین ۱ کوئری و تا ۶۰ کوئری رو ایجاد می‌کنه.
func (s *DiscoveryService) batchInterests(userIDs []uint) map[uint][]string {
cat, err := s.optionRepo.GetCategoryByKey(models.CategoryInterest)
if err != nil {
return map[uint][]string{}
}
result, err := s.optionRepo.GetSelectionsForUsersInCategory(userIDs, cat.ID)
if err != nil {
return map[uint][]string{}
}
return result
}
// Swipe یک تصمیم (لایک/ردّ/سوپرلایک) ثبت می‌کنه و در صورت لایک متقابل، مچ می‌سازه.
func (s *DiscoveryService) Swipe(user *models.User, targetID uint, action models.SwipeAction) (*dto.SwipeResponse, error) {
if targetID == user.ID {
return nil, ErrCannotSwipeSelf
}
target, err := s.userRepo.FindByID(targetID)
if err != nil {
return nil, err
}
alreadySwiped, err := s.swipeRepo.Exists(user.ID, targetID)
if err != nil {
return nil, err
}
if alreadySwiped {
return nil, ErrAlreadySwiped
}
if err := s.swipeRepo.Create(&models.Swipe{SwiperID: user.ID, SwipedID: targetID, Action: action}); err != nil {
return nil, err
}
if action == models.SwipePass {
return &dto.SwipeResponse{Matched: false}, nil
}
theyLikedMe, err := s.swipeRepo.HasLiked(targetID, user.ID)
if err != nil {
return nil, err
}
if !theyLikedMe {
return &dto.SwipeResponse{Matched: false}, nil
}
match, _, err := s.matchRepo.FindOrCreate(user.ID, targetID)
if err != nil {
return nil, err
}
photos, _ := s.photoRepo.ListByUser(target.ID)
urls := make([]string, len(photos))
for i, p := range photos {
urls[i] = p.URL
}
return &dto.SwipeResponse{
Matched: true,
Match: &dto.MatchView{
ID: match.ID,
UserID: target.ID,
FirstName: target.FirstName,
Photos: urls,
CreatedAt: match.CreatedAt.Format(time.RFC3339),
},
}, nil
}
// GetCandidateDetail پروفایل کامل یک کاربر دیگه رو برای نمایش صفحه‌ی جزئیات برمی‌گردونه
func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint) (*dto.CandidateDetail, error) {
target, err := s.userRepo.FindByID(targetID)
if err != nil {
return nil, err
}
if !target.IsOnboarded || target.ApprovalStatus != models.ApprovalApproved || !target.IsActive {
return nil, gorm.ErrRecordNotFound
}
photos, _ := s.photoRepo.ListByUser(target.ID)
urls := make([]string, len(photos))
for i, p := range photos {
urls[i] = p.URL
}
var distance *float64
if viewer.Latitude != nil && viewer.Longitude != nil && target.Latitude != nil && target.Longitude != nil {
km := math.Round(utils.HaversineKM(*viewer.Latitude, *viewer.Longitude, *target.Latitude, *target.Longitude))
distance = &km
}
detail := &dto.CandidateDetail{
ID: target.ID,
FirstName: target.FirstName,
Age: utils.CalcAge(target.BirthDate),
Bio: derefStr(target.Bio),
Photos: urls,
DistanceKM: distance,
HeightCM: target.HeightCM,
WeightKG: target.WeightKG,
}
titlesByCategory := s.selectionTitlesByCategory(target.ID, []models.CategoryKey{
models.CategoryEducationLevel,
models.CategoryFieldOfStudy,
models.CategoryLookingFor,
models.CategoryOccupation,
models.CategoryInterest,
models.CategoryImportantInRelationship,
})
first := func(titles []string) string {
if len(titles) == 0 {
return ""
}
return titles[0]
}
detail.Education = first(titlesByCategory[models.CategoryEducationLevel])
detail.FieldOfStudy = first(titlesByCategory[models.CategoryFieldOfStudy])
detail.LookingFor = first(titlesByCategory[models.CategoryLookingFor])
detail.Occupations = titlesByCategory[models.CategoryOccupation]
detail.Interests = titlesByCategory[models.CategoryInterest]
detail.ImportantInRelationship = titlesByCategory[models.CategoryImportantInRelationship]
if answers, err := s.questionRepo.GetUserAnswers(target.ID); err == nil {
for _, a := range answers {
if a.Question == nil {
continue
}
detail.Answers = append(detail.Answers, dto.CandidateAnswer{
Question: a.Question.Text,
Answer: a.Answer,
})
}
}
return detail, nil
}
// selectionTitlesByCategory انتخاب‌های یک کاربر رو در چند دسته با هم، طی یک
// کوئری واحد به دیتابیس می‌گیره (به‌جای یک کوئری جدا به‌ازای هر دسته که قبلاً
// برای صفحه‌ی جزئیات پروفایل تا ۶ رفت‌وبرگشت جدا به دیتابیس می‌زد).
// GetCategoryByKey از کش می‌خونه (چون دسته‌ها به‌ندرت تغییر می‌کنن) پس عملاً
// فقط همین یک کوئری واقعی به دیتابیس زده می‌شه.
func (s *DiscoveryService) selectionTitlesByCategory(userID uint, keys []models.CategoryKey) map[models.CategoryKey][]string {
result := make(map[models.CategoryKey][]string, len(keys))
categoryIDToKey := make(map[uint]models.CategoryKey, len(keys))
categoryIDs := make([]uint, 0, len(keys))
for _, key := range keys {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
continue
}
categoryIDToKey[cat.ID] = key
categoryIDs = append(categoryIDs, cat.ID)
}
selectionsByCategoryID, err := s.optionRepo.GetUserSelectionsForCategories(userID, categoryIDs)
if err != nil {
return result
}
for categoryID, sels := range selectionsByCategoryID {
key := categoryIDToKey[categoryID]
for _, sel := range sels {
if sel.Option != nil {
result[key] = append(result[key], sel.Option.Title)
}
}
}
return result
}
@@ -0,0 +1,328 @@
package service
import (
"errors"
"fmt"
"time"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
)
var (
ErrStepOutOfOrder = errors.New("لطفاً ابتدا مراحل قبلی را تکمیل کنید")
ErrInvalidBirthDate = errors.New("فرمت تاریخ تولد نامعتبر است (YYYY-MM-DD)")
ErrOptionsNotFound = errors.New("برخی از گزینه‌های انتخابی معتبر نیستند")
ErrSelectionCount = errors.New("تعداد گزینه‌های انتخابی مجاز نیست")
ErrQuestionsInvalid = errors.New("سوالات انتخابی معتبر نیستند")
ErrDuplicateQuestion = errors.New("هر سوال فقط یک‌بار می‌تواند انتخاب شود")
ErrNoPhotos = errors.New("برای ادامه حداقل باید یک عکس اضافه کنی")
)
type OnboardingService struct {
userRepo *repository.UserRepository
optionRepo *repository.OptionRepository
questionRepo *repository.QuestionRepository
photoRepo *repository.PhotoRepository
}
func NewOnboardingService(
userRepo *repository.UserRepository,
optionRepo *repository.OptionRepository,
questionRepo *repository.QuestionRepository,
photoRepo *repository.PhotoRepository,
) *OnboardingService {
return &OnboardingService{
userRepo: userRepo,
optionRepo: optionRepo,
questionRepo: questionRepo,
photoRepo: photoRepo,
}
}
// canModifyStep بررسی می‌کند که آیا کاربر مجاز به تکمیل/ویرایش این مرحله هست یا نه.
// اگر کاربر قبلاً کل آنبوردینگ رو تموم کرده، اجازه‌ی ویرایش هر مرحله‌ای رو داره.
// در غیر این صورت فقط می‌تونه مرحله‌ی فعلی یا مراحل قبلی (که قبلاً تکمیل شدن) رو بزنه؛
// نمی‌تونه از روی یک مرحله بپره جلو.
func (s *OnboardingService) canModifyStep(user *models.User, step models.OnboardingStep) error {
if user.IsOnboarded {
return nil
}
if step > user.CurrentStep {
return ErrStepOutOfOrder
}
return nil
}
// advanceIfCurrent اگه مرحله‌ی تکمیل‌شده همون مرحله‌ی فعلی کاربر بود، به مرحله‌ی بعد می‌ره
func (s *OnboardingService) advanceIfCurrent(user *models.User, completedStep models.OnboardingStep) error {
if user.CurrentStep != completedStep {
// یا مرحله‌ی قبلی داشت ویرایش می‌شد، یا از قبل onboarded بود.
// اگر قبلاً توسط ادمین رد شده بود، ویرایش پروفایل یعنی درخواست بررسی مجدد.
return s.resubmitIfRejected(user)
}
next := completedStep + 1
updates := map[string]interface{}{"current_step": next}
if next >= models.StepCompleted {
updates["current_step"] = models.StepCompleted
updates["is_onboarded"] = true
updates["approval_status"] = models.ApprovalPending
updates["rejection_reason"] = nil
}
return s.userRepo.UpdateFields(user, updates)
}
// resubmitIfRejected اگه کاربر قبلاً رد شده و بعد از onboarded شدن پروفایلش رو
// ویرایش کنه، وضعیت رو به pending برمی‌گردونه تا ادمین دوباره بررسی کنه.
func (s *OnboardingService) resubmitIfRejected(user *models.User) error {
if !user.IsOnboarded || user.ApprovalStatus != models.ApprovalRejected {
return nil
}
updates := map[string]interface{}{
"approval_status": models.ApprovalPending,
"rejection_reason": nil,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return err
}
user.ApprovalStatus = models.ApprovalPending
user.RejectionReason = nil
return nil
}
// ---------------- مرحله ۱: اطلاعات پایه ----------------
func (s *OnboardingService) SetBasicInfo(user *models.User, req dto.BasicInfoRequest) error {
if err := s.canModifyStep(user, models.StepBasicInfo); err != nil {
return err
}
birthDate, err := time.Parse("2006-01-02", req.BirthDate)
if err != nil {
return ErrInvalidBirthDate
}
gender := models.Gender(req.Gender)
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"gender": gender,
"first_name": req.FirstName,
"birth_date": birthDate,
}); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepBasicInfo)
}
// ---------------- مرحله ۲: عکس‌ها (حداقل ۱ عکس) ----------------
// ConfirmPhotos عکس واقعی رو از طریق endpoint عمومی /profile/photos آپلود می‌کنیم؛
// این متد فقط تایید می‌کنه که حداقل یک عکس هست و مرحله رو جلو می‌بره.
func (s *OnboardingService) ConfirmPhotos(user *models.User) error {
if err := s.canModifyStep(user, models.StepPhotos); err != nil {
return err
}
count, err := s.photoRepo.CountByUser(user.ID)
if err != nil {
return err
}
if count < 1 {
return ErrNoPhotos
}
return s.advanceIfCurrent(user, models.StepPhotos)
}
// ---------------- مرحله ۳: قد و وزن ----------------
func (s *OnboardingService) SetBodyInfo(user *models.User, req dto.BodyInfoRequest) error {
if err := s.canModifyStep(user, models.StepBodyInfo); err != nil {
return err
}
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"height_cm": req.HeightCM,
"weight_kg": req.WeightKG,
}); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepBodyInfo)
}
// ---------------- مرحله ۳: سوالات پروفایل (دقیقاً ۳ سوال) ----------------
func (s *OnboardingService) GetAvailableQuestions() ([]models.ProfileQuestion, error) {
return s.questionRepo.ListActive()
}
func (s *OnboardingService) SetProfileQuestions(user *models.User, req dto.ProfileQuestionsRequest) error {
if err := s.canModifyStep(user, models.StepProfileQuestions); err != nil {
return err
}
ids := make([]uint, 0, len(req.Answers))
seen := map[uint]bool{}
for _, a := range req.Answers {
if seen[a.QuestionID] {
return ErrDuplicateQuestion
}
seen[a.QuestionID] = true
ids = append(ids, a.QuestionID)
}
questions, err := s.questionRepo.FindActiveByIDs(ids)
if err != nil {
return err
}
if len(questions) != len(ids) {
return ErrQuestionsInvalid
}
answers := make([]models.UserProfileAnswer, 0, len(req.Answers))
for _, a := range req.Answers {
answers = append(answers, models.UserProfileAnswer{
QuestionID: a.QuestionID,
Answer: a.Answer,
})
}
if err := s.questionRepo.ReplaceUserAnswers(user.ID, answers); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepProfileQuestions)
}
// ---------------- مرحله ۴: تحصیلات (سطح تحصیلات + رشته) ----------------
func (s *OnboardingService) SetEducation(user *models.User, req dto.EducationRequest) error {
if err := s.canModifyStep(user, models.StepEducation); err != nil {
return err
}
if err := s.selectSingleOption(user.ID, models.CategoryEducationLevel, req.EducationLevelOptionID); err != nil {
return err
}
if err := s.selectSingleOption(user.ID, models.CategoryFieldOfStudy, req.FieldOfStudyOptionID); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepEducation)
}
func (s *OnboardingService) selectSingleOption(userID uint, key models.CategoryKey, optionID uint) error {
return s.selectOptions(userID, key, []uint{optionID})
}
// ---------------- مراحل مبتنی بر لیست چندانتخابی عمومی ----------------
// SetOccupations مرحله ۵: کارهایی که کاربر انجام می‌دهد (چندانتخابی، بدون تعداد ثابت)
func (s *OnboardingService) SetOccupations(user *models.User, optionIDs []uint) error {
if err := s.canModifyStep(user, models.StepOccupations); err != nil {
return err
}
if err := s.selectOptions(user.ID, models.CategoryOccupation, optionIDs); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepOccupations)
}
// SetRelationshipImportance مرحله ۶: دقیقاً ۳ مورد از چیزهای مهم در رابطه
func (s *OnboardingService) SetRelationshipImportance(user *models.User, optionIDs []uint) error {
if err := s.canModifyStep(user, models.StepRelationshipImportance); err != nil {
return err
}
if err := s.selectOptions(user.ID, models.CategoryImportantInRelationship, optionIDs); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepRelationshipImportance)
}
// SetInterests مرحله ۷: دقیقاً ۵ علاقه‌مندی
func (s *OnboardingService) SetInterests(user *models.User, optionIDs []uint) error {
if err := s.canModifyStep(user, models.StepInterests); err != nil {
return err
}
if err := s.selectOptions(user.ID, models.CategoryInterest, optionIDs); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepInterests)
}
// SetLookingFor مرحله ۸: دنبال چی می‌گردی
func (s *OnboardingService) SetLookingFor(user *models.User, optionIDs []uint) error {
if err := s.canModifyStep(user, models.StepLookingFor); err != nil {
return err
}
if err := s.selectOptions(user.ID, models.CategoryLookingFor, optionIDs); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepLookingFor)
}
// selectOptions منطق مشترک اعتبارسنجی + ذخیره‌ی انتخاب‌های یک دسته
func (s *OnboardingService) selectOptions(userID uint, key models.CategoryKey, optionIDs []uint) error {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
return err
}
count := len(optionIDs)
if count < cat.MinSelect {
return fmt.Errorf("%w: حداقل باید %d مورد انتخاب کنید", ErrSelectionCount, cat.MinSelect)
}
if cat.MaxSelect > 0 && count > cat.MaxSelect {
return fmt.Errorf("%w: حداکثر می‌توانید %d مورد انتخاب کنید", ErrSelectionCount, cat.MaxSelect)
}
// جلوگیری از تکراری بودن id ها
uniqueIDs := make([]uint, 0, len(optionIDs))
seen := map[uint]bool{}
for _, id := range optionIDs {
if !seen[id] {
seen[id] = true
uniqueIDs = append(uniqueIDs, id)
}
}
validOptions, err := s.optionRepo.FindActiveOptionsByIDsInCategory(cat.ID, uniqueIDs)
if err != nil {
return err
}
if len(validOptions) != len(uniqueIDs) {
return ErrOptionsNotFound
}
return s.optionRepo.ReplaceUserSelections(userID, cat.ID, uniqueIDs)
}
// GetActiveOptions لیست گزینه‌های فعال یک دسته را برمی‌گرداند (برای نمایش به کاربر در هر مرحله)
func (s *OnboardingService) GetActiveOptions(key models.CategoryKey) ([]models.Option, error) {
return s.optionRepo.ListActiveOptionsByCategoryKey(key)
}
// ---------------- وضعیت کلی آنبوردینگ ----------------
func (s *OnboardingService) GetStatus(user *models.User) dto.OnboardingStatusResponse {
steps := make([]dto.StepView, 0, len(models.OnboardingStepOrder))
for _, s2 := range models.OnboardingStepOrder {
done := user.IsOnboarded || user.CurrentStep > s2.Step
steps = append(steps, dto.StepView{
Step: int(s2.Step),
Key: s2.Key,
Done: done,
})
}
currentKey := "completed"
for _, s2 := range models.OnboardingStepOrder {
if s2.Step == user.CurrentStep {
currentKey = s2.Key
break
}
}
return dto.OnboardingStatusResponse{
CurrentStep: int(user.CurrentStep),
CurrentStepKey: currentKey,
IsOnboarded: user.IsOnboarded,
ApprovalStatus: string(user.ApprovalStatus),
RejectionReason: user.RejectionReason,
Steps: steps,
}
}
+41
View File
@@ -0,0 +1,41 @@
package service
import "log"
// OTPSender اینترفیس ارسال پیامک کد تایید.
// برای اتصال به یک گیت‌وی واقعی (کاوه‌نگار، ملی‌پیامک و ...) کافیه یک
// پیاده‌سازی جدید از این اینترفیس بسازی و در main.go جایگزین mock کنی؛
// بقیه‌ی کد (UserService و...) نیازی به تغییر ندارد.
//
// نکته‌ی مهم: پیاده‌سازی واقعی باید SYNC باشه (منتظر پاسخ گیت‌وی بمونه)
// و نتیجه‌ی واقعی ارسال رو برگردونه؛ اگه async/بی‌خیال از نتیجه باشه،
// کاربر حتی در صورت شکست واقعی ارسال، پیام «با موفقیت ارسال شد» می‌گیره
// و منتظر کدی می‌مونه که هیچ‌وقت نمی‌رسه.
type OTPSender interface {
// Send کد OTP را برای شماره‌ی داده‌شده ارسال می‌کند.
// خروجی true یعنی گیت‌وی ارسال را با موفقیت تایید کرده است.
Send(phone string, otp string) bool
}
// MockOTPSender پیاده‌سازی توسعه/تست: به‌جای ارسال واقعی پیامک،
// کد را در لاگ سرور چاپ می‌کند. برای پروداکشن باید با یک گیت‌وی واقعی
// جایگزین شود.
type MockOTPSender struct {
// Debug اگر true باشد، کد OTP در لاگ چاپ می‌شود (فقط برای dev/staging).
Debug bool
}
func NewMockOTPSender(debug bool) *MockOTPSender {
return &MockOTPSender{Debug: debug}
}
func (s *MockOTPSender) Send(phone string, otp string) bool {
if s.Debug {
log.Printf("[MOCK-SMS] کد OTP برای %s: %s", phone, otp)
} else {
log.Printf("[MOCK-SMS] یک کد OTP برای %s ارسال شد", phone)
}
// mock همیشه موفق فرض می‌شود؛ برای شبیه‌سازی خطای گیت‌وی می‌تونی
// این مقدار رو شرطی کنی (مثلاً بر اساس یک فلگ تستی).
return true
}
+61
View File
@@ -0,0 +1,61 @@
package service
import (
"fmt"
"os"
"path/filepath"
"github.com/google/uuid"
)
// PhotoStorage اینترفیس ذخیره‌سازی فایل عکس پروفایل.
// برای اتصال به یک storage واقعی (S3، Arvan Object Storage و...) کافیه یک
// پیاده‌سازی جدید از این اینترفیس بسازی و در main.go جایگزین نسخه‌ی local کنی؛
// بقیه‌ی کد (ProfileService و...) نیازی به تغییر ندارد.
type PhotoStorage interface {
// Save فایل رو ذخیره می‌کنه و URL قابل‌دسترسی (نسبی یا کامل) رو برمی‌گردونه.
Save(userID uint, ext string, data []byte) (url string, err error)
// Delete فایل رو با URL برگردونده‌شده از Save حذف می‌کنه.
Delete(url string) error
}
// LocalPhotoStorage فایل‌ها رو روی دیسک محلی سرور ذخیره می‌کنه و از طریق
// مسیر استاتیک /uploads سرو می‌شن. برای پروداکشن با تعداد کاربر بالا،
// بهتره با یک object storage واقعی جایگزین بشه (فایل‌ها روی دیسک سرور API
// می‌مونن و بین چند instance به اشتراک گذاشته نمی‌شن).
type LocalPhotoStorage struct {
rootDir string // مسیر روی دیسک، مثلاً "./uploads/photos"
urlPrefix string // پیشوند URL عمومی، مثلاً "/uploads/photos"
}
func NewLocalPhotoStorage(rootDir, urlPrefix string) *LocalPhotoStorage {
return &LocalPhotoStorage{rootDir: rootDir, urlPrefix: urlPrefix}
}
func (s *LocalPhotoStorage) Save(userID uint, ext string, data []byte) (string, error) {
userDir := filepath.Join(s.rootDir, fmt.Sprintf("%d", userID))
if err := os.MkdirAll(userDir, 0o755); err != nil {
return "", err
}
filename := uuid.NewString() + ext
fullPath := filepath.Join(userDir, filename)
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
return "", err
}
return fmt.Sprintf("%s/%d/%s", s.urlPrefix, userID, filename), nil
}
func (s *LocalPhotoStorage) Delete(url string) error {
rel, err := filepath.Rel(s.urlPrefix, url)
if err != nil {
return err
}
fullPath := filepath.Join(s.rootDir, rel)
err = os.Remove(fullPath)
if os.IsNotExist(err) {
return nil
}
return err
}
+182
View File
@@ -0,0 +1,182 @@
package service
import (
"encoding/base64"
"errors"
"strings"
"time"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
)
const MaxPhotosPerUser = 6
var (
ErrTooManyPhotos = errors.New("حداکثر تعداد عکس مجاز رو داری")
ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست")
ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه")
ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه")
ErrInvalidPublicKey = errors.New("کلید عمومی نامعتبر است")
)
const MaxPhotoSizeBytes = 5 * 1024 * 1024
type ProfileService struct {
photoRepo *repository.PhotoRepository
userRepo *repository.UserRepository
storage PhotoStorage
}
func NewProfileService(photoRepo *repository.PhotoRepository, userRepo *repository.UserRepository, storage PhotoStorage) *ProfileService {
return &ProfileService{photoRepo: photoRepo, userRepo: userRepo, storage: storage}
}
var allowedImageExts = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".webp": true,
}
func (s *ProfileService) UploadPhoto(userID uint, ext string, data []byte) (*models.Photo, error) {
if !allowedImageExts[ext] {
return nil, ErrInvalidImage
}
if len(data) > MaxPhotoSizeBytes {
return nil, ErrImageTooLarge
}
count, err := s.photoRepo.CountByUser(userID)
if err != nil {
return nil, err
}
if count >= MaxPhotosPerUser {
return nil, ErrTooManyPhotos
}
url, err := s.storage.Save(userID, ext, data)
if err != nil {
return nil, err
}
photo := &models.Photo{UserID: userID, URL: url, SortOrder: int(count)}
if err := s.photoRepo.Create(photo); err != nil {
return nil, err
}
return photo, nil
}
func (s *ProfileService) DeletePhoto(userID, photoID uint) error {
photo, err := s.photoRepo.FindByID(photoID)
if err != nil {
return err
}
if photo.UserID != userID {
return ErrPhotoNotOwned
}
if err := s.photoRepo.Delete(photoID); err != nil {
return err
}
return s.storage.Delete(photo.URL)
}
// DeletePhotoAdmin مثل DeletePhoto ولی بدون چک مالکیت؛ برای نظارت محتوایی
// ادمین که باید بتونه هر عکسی رو صرف‌نظر از صاحبش حذف کنه.
func (s *ProfileService) DeletePhotoAdmin(photoID uint) error {
photo, err := s.photoRepo.FindByID(photoID)
if err != nil {
return err
}
if err := s.photoRepo.Delete(photoID); err != nil {
return err
}
return s.storage.Delete(photo.URL)
}
func (s *ProfileService) ListPhotos(userID uint) ([]models.Photo, error) {
return s.photoRepo.ListByUser(userID)
}
func (s *ProfileService) UpdateLocation(user *models.User, lat, lng float64) error {
return s.userRepo.UpdateFields(user, map[string]interface{}{
"latitude": lat,
"longitude": lng,
})
}
// UpdateBio بیوگرافی کوتاه پروفایل رو ذخیره می‌کنه (طول توسط binding در DTO چک شده)
func (s *ProfileService) UpdateBio(user *models.User, bio string) error {
bio = strings.TrimSpace(bio)
if err := s.userRepo.UpdateFields(user, map[string]interface{}{"bio": bio}); err != nil {
return err
}
user.Bio = &bio
return nil
}
// CheckIn ورود روزانه‌ی کاربر رو ثبت می‌کنه و استریک (چند روز پشت‌سرهم) رو
// به‌روز می‌کنه: اگه دیروز هم اومده بوده، استریک +۱ می‌شه؛ اگه امروز قبلاً
// چک‌این کرده، چیزی تغییر نمی‌کنه؛ وگرنه (وقفه افتاده) استریک از ۱ شروع می‌شه.
func (s *ProfileService) CheckIn(user *models.User) (*dto.CheckInResult, error) {
now := time.Now()
today := now.Format("2006-01-02")
if user.LastActiveDate != nil && user.LastActiveDate.Format("2006-01-02") == today {
return &dto.CheckInResult{StreakCount: user.StreakCount, IsNewToday: false}, nil
}
newStreak := 1
if user.LastActiveDate != nil {
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
if user.LastActiveDate.Format("2006-01-02") == yesterday {
newStreak = user.StreakCount + 1
}
}
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"streak_count": newStreak,
"last_active_date": now,
}); err != nil {
return nil, err
}
user.StreakCount = newStreak
user.LastActiveDate = &now
return &dto.CheckInResult{StreakCount: newStreak, IsNewToday: true}, nil
}
// UpdateNotificationSettings ترجیحات اعلان کاربر رو ذخیره می‌کنه.
func (s *ProfileService) UpdateNotificationSettings(user *models.User, req dto.UpdateNotificationSettingsRequest) error {
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"notify_matches": req.NotifyMatches,
"notify_messages": req.NotifyMessages,
"notify_likes": req.NotifyLikes,
}); err != nil {
return err
}
user.NotifyMatches = req.NotifyMatches
user.NotifyMessages = req.NotifyMessages
user.NotifyLikes = req.NotifyLikes
return nil
}
// DeleteAccount حساب کاربر رو غیرفعال می‌کنه (soft) نه پاک‌سازی کامل رکورد؛
// چون مچ‌ها/پیام‌های طرف مقابل نباید یهو پاره بشن. کاربر غیرفعال دیگه اصلاً
// نمی‌تونه لاگین کنه (چک IsActive توی AuthRequired و AuthService).
func (s *ProfileService) DeleteAccount(user *models.User) error {
return s.userRepo.UpdateFields(user, map[string]interface{}{"is_active": false})
}
// UpdatePublicKey کلید عمومی X25519 کاربر رو برای رمزنگاری سرتاسری پیام‌ها
// ثبت/جایگزین می‌کنه. کلید خصوصی متناظرش هیچ‌وقت به سرور نمی‌رسه؛ اینجا فقط
// طول کلید خام (باید دقیقاً ۳۲ بایت باشه) بعد از decode چک می‌شه، نه محتواش.
func (s *ProfileService) UpdatePublicKey(user *models.User, publicKeyB64 string) error {
raw, err := base64.StdEncoding.DecodeString(publicKeyB64)
if err != nil || len(raw) != 32 {
return ErrInvalidPublicKey
}
if err := s.userRepo.UpdateFields(user, map[string]interface{}{"public_key": publicKeyB64}); err != nil {
return err
}
user.PublicKey = &publicKeyB64
return nil
}