chore: merge backend repo history into monorepo at backend/
git-subtree-dir: backend git-subtree-mainline:dc11190527git-subtree-split:acae976622
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user