diff --git a/serv_nginx/api_bb/internal/handlers/handlers.go b/serv_nginx/api_bb/internal/handlers/handlers.go index d815c56..aac5482 100644 --- a/serv_nginx/api_bb/internal/handlers/handlers.go +++ b/serv_nginx/api_bb/internal/handlers/handlers.go @@ -17,6 +17,7 @@ type Handler struct { avatarHandler *AvatarHandler newsHandler *NewsHandler reviewHandler *ReviewHandler + userStatsHandler *UserStatsHandler // Здесь будут добавлены другие обработчики // userHandler *UserHandler // eventHandler *EventHandler @@ -29,6 +30,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler { newsRepo := repository.NewNewsRepository(db) commentRepo := repository.NewCommentRepository(db) reviewRepo := repository.NewReviewRepository(db) + userStatsRepo := repository.NewUserStatsRepository(db) // Initialize logger baseLogger := logger.NewWrapper(logger.Get()) // Создаем базовый логгер @@ -40,6 +42,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler { avatarService := service.NewAvatarService(userRepo, baseLogger) newsService := service.NewNewsService(newsRepo, commentRepo, baseLogger) reviewService := service.NewReviewService(reviewRepo, baseLogger) + userStatsService := service.NewUserStatsService(userStatsRepo) // Инициализация обработчиков healthHandler := NewHealthHandler() @@ -48,6 +51,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler { newsHandler := NewNewsHandler(newsService, baseLogger) avatarHandler := NewAvatarHandler(avatarService) reviewHandler := NewReviewHandler(reviewService, baseLogger) + userStatsHandler := NewUserStatsHandler(userStatsService) return &Handler{ healthHandler: healthHandler, @@ -56,6 +60,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler { newsHandler: newsHandler, avatarHandler: avatarHandler, reviewHandler: reviewHandler, + userStatsHandler: userStatsHandler, } } @@ -68,7 +73,7 @@ func (h *Handler) AuthHandler() *AuthHandler { return h.authHandler } -func (h *Handler) UserHandler() *UserHandler { // ДОБАВЛЕН геттер для UserHandler +func (h *Handler) UserHandler() *UserHandler { return h.userHandler } @@ -83,3 +88,7 @@ func (h *Handler) NewsHandler() *NewsHandler { func (h *Handler) ReviewHandler() *ReviewHandler { return h.reviewHandler } + +func (h *Handler) UserStatsHandler() *UserStatsHandler { + return h.userStatsHandler +} diff --git a/serv_nginx/api_bb/internal/handlers/user_stats_handler.go b/serv_nginx/api_bb/internal/handlers/user_stats_handler.go new file mode 100644 index 0000000..cb2ecdd --- /dev/null +++ b/serv_nginx/api_bb/internal/handlers/user_stats_handler.go @@ -0,0 +1,348 @@ +// handlers/user_stats_handler.go +package handlers + +import ( + "net/http" + "strconv" + + "api_bb/internal/service" + "api_bb/pkg/logger" + "api_bb/pkg/middleware" + "api_bb/pkg/utils" + + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +type UserStatsHandler struct { + logger logger.LoggerInterface + userStatsService service.UserStatsService +} + +func NewUserStatsHandler(userStatsService service.UserStatsService) *UserStatsHandler { + return &UserStatsHandler{ + logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "user_stats"))), + userStatsService: userStatsService, + } +} + +// GetUserStats возвращает статистику текущего пользователя +func (h *UserStatsHandler) GetUserStats(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling get user stats request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем пользователя из контекста + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("get user stats failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + // Получаем статистику через сервис + stats, err := h.userStatsService.GetUserStats(user.ID) + if err != nil { + h.logger.Error("failed to get user stats from service", + zap.Uint("user_id", user.ID), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get user stats: "+err.Error()) + return + } + + h.logger.Info("user stats retrieved successfully", + zap.Uint("user_id", user.ID), + zap.Float64("total_distance", stats.TotalDistance), + zap.Int("workouts_count", stats.WorkoutsCount), + ) + + utils.RespondWithJSON(w, http.StatusOK, stats) +} + +// GetUserStatsByID возвращает статистику пользователя по ID (для администраторов) +func (h *UserStatsHandler) GetUserStatsByID(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling get user stats by ID request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем текущего пользователя для проверки прав + currentUser, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("get user stats by ID failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + // Проверяем права администратора + if currentUser.Role != "admin" { + h.logger.Warn("get user stats by ID failed - insufficient permissions", + zap.Uint("user_id", currentUser.ID), + zap.String("role", currentUser.Role), + ) + utils.RespondWithError(w, http.StatusForbidden, "Insufficient permissions") + return + } + + // Получаем ID пользователя из параметров URL + userIDStr := chi.URLParam(r, "userID") + userID, err := strconv.ParseUint(userIDStr, 10, 32) + if err != nil { + h.logger.Warn("invalid user ID parameter", + zap.String("user_id_param", userIDStr), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusBadRequest, "Invalid user ID") + return + } + + // Получаем статистику через сервис + stats, err := h.userStatsService.GetUserStats(uint(userID)) + if err != nil { + h.logger.Error("failed to get user stats by ID from service", + zap.Uint("target_user_id", uint(userID)), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get user stats: "+err.Error()) + return + } + + h.logger.Info("user stats by ID retrieved successfully", + zap.Uint("admin_user_id", currentUser.ID), + zap.Uint("target_user_id", uint(userID)), + ) + + utils.RespondWithJSON(w, http.StatusOK, stats) +} + +// UpdatePersonalBest обновляет личный рекорд пользователя +func (h *UserStatsHandler) UpdatePersonalBest(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling update personal best request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем пользователя из контекста + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("update personal best failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + var req struct { + DistanceType string `json:"distance_type"` + Time string `json:"time"` + } + + if err := utils.DecodeJSONBody(w, r, &req); err != nil { + h.logger.Error("failed to decode update personal best request", + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error()) + return + } + + // Валидация обязательных полей + if req.DistanceType == "" || req.Time == "" { + h.logger.Warn("update personal best failed - missing required fields") + utils.RespondWithError(w, http.StatusBadRequest, "Distance type and time are required") + return + } + + // Валидация типа дистанции + validDistanceTypes := map[string]bool{ + "5k": true, "10k": true, "half": true, "marathon": true, + } + if !validDistanceTypes[req.DistanceType] { + h.logger.Warn("update personal best failed - invalid distance type", + zap.String("distance_type", req.DistanceType), + ) + utils.RespondWithError(w, http.StatusBadRequest, "Invalid distance type. Must be: 5k, 10k, half, marathon") + return + } + + h.logger.Info("updating personal best", + zap.Uint("user_id", user.ID), + zap.String("distance_type", req.DistanceType), + zap.String("time", req.Time), + ) + + // Обновляем личный рекорд через сервис + if err := h.userStatsService.UpdatePersonalBest(user.ID, req.DistanceType, req.Time); err != nil { + h.logger.Error("failed to update personal best in service", + zap.Uint("user_id", user.ID), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update personal best: "+err.Error()) + return + } + + h.logger.Info("personal best updated successfully", + zap.Uint("user_id", user.ID), + zap.String("distance_type", req.DistanceType), + ) + + utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{ + "message": "Personal best updated successfully", + "distance_type": req.DistanceType, + "time": req.Time, + }) +} + +// IncrementWorkout увеличивает счетчик тренировок и обновляет статистику +func (h *UserStatsHandler) IncrementWorkout(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling increment workout request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем пользователя из контекста + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("increment workout failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + var req struct { + Distance float64 `json:"distance"` + Duration int `json:"duration"` + } + + if err := utils.DecodeJSONBody(w, r, &req); err != nil { + h.logger.Error("failed to decode increment workout request", + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error()) + return + } + + // Валидация данных тренировки + if req.Distance <= 0 { + h.logger.Warn("increment workout failed - invalid distance", + zap.Float64("distance", req.Distance), + ) + utils.RespondWithError(w, http.StatusBadRequest, "Distance must be greater than 0") + return + } + if req.Duration <= 0 { + h.logger.Warn("increment workout failed - invalid duration", + zap.Int("duration", req.Duration), + ) + utils.RespondWithError(w, http.StatusBadRequest, "Duration must be greater than 0") + return + } + + h.logger.Info("incrementing workout stats", + zap.Uint("user_id", user.ID), + zap.Float64("distance", req.Distance), + zap.Int("duration", req.Duration), + ) + + // Обновляем статистику через сервис + if err := h.userStatsService.IncrementWorkout(user.ID, req.Distance, req.Duration); err != nil { + h.logger.Error("failed to increment workout in service", + zap.Uint("user_id", user.ID), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update workout stats: "+err.Error()) + return + } + + h.logger.Info("workout stats incremented successfully", + zap.Uint("user_id", user.ID), + zap.Float64("distance", req.Distance), + zap.Int("duration", req.Duration), + ) + + utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{ + "message": "Workout stats updated successfully", + "distance": req.Distance, + "duration": req.Duration, + }) +} + +// ResetWeeklyDistance сбрасывает недельный пробег +func (h *UserStatsHandler) ResetWeeklyDistance(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling reset weekly distance request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем пользователя из контекста + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("reset weekly distance failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + h.logger.Info("resetting weekly distance", + zap.Uint("user_id", user.ID), + ) + + // Сбрасываем недельный пробег через сервис + if err := h.userStatsService.ResetWeeklyDistance(user.ID); err != nil { + h.logger.Error("failed to reset weekly distance in service", + zap.Uint("user_id", user.ID), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to reset weekly distance: "+err.Error()) + return + } + + h.logger.Info("weekly distance reset successfully", + zap.Uint("user_id", user.ID), + ) + + utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{ + "message": "Weekly distance reset successfully", + }) +} + +// ResetMonthlyDistance сбрасывает месячный пробег +func (h *UserStatsHandler) ResetMonthlyDistance(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling reset monthly distance request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем пользователя из контекста + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("reset monthly distance failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + h.logger.Info("resetting monthly distance", + zap.Uint("user_id", user.ID), + ) + + // Сбрасываем месячный пробег через сервис + if err := h.userStatsService.ResetMonthlyDistance(user.ID); err != nil { + h.logger.Error("failed to reset monthly distance in service", + zap.Uint("user_id", user.ID), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to reset monthly distance: "+err.Error()) + return + } + + h.logger.Info("monthly distance reset successfully", + zap.Uint("user_id", user.ID), + ) + + utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{ + "message": "Monthly distance reset successfully", + }) +} \ No newline at end of file diff --git a/serv_nginx/api_bb/internal/routes/routes.go b/serv_nginx/api_bb/internal/routes/routes.go index 8275bd4..ec7ee97 100644 --- a/serv_nginx/api_bb/internal/routes/routes.go +++ b/serv_nginx/api_bb/internal/routes/routes.go @@ -74,6 +74,15 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler { r.Delete("/delete", allHandler.AvatarHandler().DeleteAvatar) r.Get("/{filename}", allHandler.AvatarHandler().GetAvatar) }) + + r.Route("/stats", func(r chi.Router) { + r.Get("/", allHandler.UserStatsHandler().GetUserStats) + r.Get("/{userID}", allHandler.UserStatsHandler().GetUserStatsByID) + r.Post("/workout", allHandler.UserStatsHandler().IncrementWorkout) + r.Put("/personal-best", allHandler.UserStatsHandler().UpdatePersonalBest) + r.Post("/weekly/reset", allHandler.UserStatsHandler().ResetWeeklyDistance) + r.Post("/monthly/reset", allHandler.UserStatsHandler().ResetMonthlyDistance) + }) // Здесь будут другие защищенные маршруты пользователя }) diff --git a/serv_nginx/api_bb/internal/service/user_stats_service.go b/serv_nginx/api_bb/internal/service/user_stats_service.go index 3cd0366..c388184 100644 --- a/serv_nginx/api_bb/internal/service/user_stats_service.go +++ b/serv_nginx/api_bb/internal/service/user_stats_service.go @@ -1,45 +1,258 @@ -// services/user_stats_service.go +// service/user_stats_service.go package service import ( "time" + "api_bb/internal/models" "api_bb/internal/repository" + "api_bb/pkg/logger" + + "go.uber.org/zap" ) -type UserStatsService struct { - userStatsRepo repository.UserStatsRepository +type UserStatsService interface { + GetUserStats(userID uint) (*models.UserStatsResponse, error) + UpdatePersonalBest(userID uint, distanceType string, time string) error + IncrementWorkout(userID uint, distance float64, duration int) error + ResetWeeklyDistance(userID uint) error + ResetMonthlyDistance(userID uint) error + CreateUserStats(userID uint) error } -func NewUserStatsService(userStatsRepo repository.UserStatsRepository) *UserStatsService { - return &UserStatsService{ +type userStatsService struct { + logger logger.LoggerInterface + userStatsRepo repository.UserStatsRepository +} + +func NewUserStatsService(userStatsRepo repository.UserStatsRepository) UserStatsService { + return &userStatsService{ + logger: logger.NewWrapper(logger.Get().With(zap.String("service", "user_stats"))), userStatsRepo: userStatsRepo, } } -func (s *UserStatsService) AddWorkout(userID uint, distance float64, duration int, workoutTime time.Time) error { - // Обновляем общую статистику - if err := s.userStatsRepo.IncrementWorkouts(userID, distance, duration); err != nil { +// GetUserStats возвращает статистику пользователя в формате DTO +func (s *userStatsService) GetUserStats(userID uint) (*models.UserStatsResponse, error) { + s.logger.Info("getting user stats", + zap.Uint("user_id", userID), + ) + + stats, err := s.userStatsRepo.GetUserStatsResponse(userID) + if err != nil { + s.logger.Error("failed to get user stats from repository", + zap.Uint("user_id", userID), + zap.Error(err), + ) + return nil, err + } + + s.logger.Debug("user stats retrieved successfully", + zap.Uint("user_id", userID), + zap.Float64("total_distance", stats.TotalDistance), + zap.Int("workouts_count", stats.WorkoutsCount), + ) + + return stats, nil +} + +// UpdatePersonalBest обновляет личный рекорд пользователя +func (s *userStatsService) UpdatePersonalBest(userID uint, distanceType string, time string) error { + s.logger.Info("updating personal best", + zap.Uint("user_id", userID), + zap.String("distance_type", distanceType), + zap.String("time", time), + ) + + // Проверяем существование статистики пользователя + _, err := s.userStatsRepo.GetByUserID(userID) + if err != nil { + s.logger.Warn("user stats not found, creating new stats", + zap.Uint("user_id", userID), + ) + if err := s.CreateUserStats(userID); err != nil { + return err + } + } + + if err := s.userStatsRepo.UpdatePersonalBest(userID, distanceType, time); err != nil { + s.logger.Error("failed to update personal best in repository", + zap.Uint("user_id", userID), + zap.String("distance_type", distanceType), + zap.Error(err), + ) return err } - // Обновляем серии - if err := s.userStatsRepo.UpdateStreaks(userID, workoutTime); err != nil { + s.logger.Info("personal best updated successfully", + zap.Uint("user_id", userID), + zap.String("distance_type", distanceType), + zap.String("time", time), + ) + + return nil +} + +// IncrementWorkout увеличивает счетчик тренировок и обновляет статистику +func (s *userStatsService) IncrementWorkout(userID uint, distance float64, duration int) error { + s.logger.Info("incrementing workout stats", + zap.Uint("user_id", userID), + zap.Float64("distance", distance), + zap.Int("duration", duration), + ) + + // Проверяем существование статистики пользователя + _, err := s.userStatsRepo.GetByUserID(userID) + if err != nil { + s.logger.Warn("user stats not found, creating new stats", + zap.Uint("user_id", userID), + ) + if err := s.CreateUserStats(userID); err != nil { + return err + } + } + + // Обновляем серии тренировок + currentTime := time.Now() + if err := s.userStatsRepo.UpdateStreaks(userID, currentTime); err != nil { + s.logger.Error("failed to update streaks in repository", + zap.Uint("user_id", userID), + zap.Error(err), + ) return err } // Обновляем недельный и месячный пробег if err := s.userStatsRepo.UpdateWeeklyDistance(userID, distance); err != nil { + s.logger.Error("failed to update weekly distance in repository", + zap.Uint("user_id", userID), + zap.Error(err), + ) return err } - return s.userStatsRepo.UpdateMonthlyDistance(userID, distance) + if err := s.userStatsRepo.UpdateMonthlyDistance(userID, distance); err != nil { + s.logger.Error("failed to update monthly distance in repository", + zap.Uint("user_id", userID), + zap.Error(err), + ) + return err + } + + // Увеличиваем счетчик тренировок и обновляем общие показатели + if err := s.userStatsRepo.IncrementWorkouts(userID, distance, duration); err != nil { + s.logger.Error("failed to increment workouts in repository", + zap.Uint("user_id", userID), + zap.Error(err), + ) + return err + } + + s.logger.Info("workout stats incremented successfully", + zap.Uint("user_id", userID), + zap.Float64("distance", distance), + zap.Int("duration", duration), + ) + + return nil } -func (s *UserStatsService) GetUserStats(userID uint) (*models.UserStatsResponse, error) { - return s.userStatsRepo.GetUserStatsResponse(userID) +// ResetWeeklyDistance сбрасывает недельный пробег +func (s *userStatsService) ResetWeeklyDistance(userID uint) error { + s.logger.Info("resetting weekly distance", + zap.Uint("user_id", userID), + ) + + userStats, err := s.userStatsRepo.GetByUserID(userID) + if err != nil { + s.logger.Error("failed to get user stats for weekly reset", + zap.Uint("user_id", userID), + zap.Error(err), + ) + return err + } + + userStats.WeeklyDistance = 0 + if err := s.userStatsRepo.Update(userStats); err != nil { + s.logger.Error("failed to reset weekly distance in repository", + zap.Uint("user_id", userID), + zap.Error(err), + ) + return err + } + + s.logger.Info("weekly distance reset successfully", + zap.Uint("user_id", userID), + ) + + return nil } -func (s *UserStatsService) UpdatePersonalBest(userID uint, distanceType string, time string) error { - return s.userStatsRepo.UpdatePersonalBest(userID, distanceType, time) +// ResetMonthlyDistance сбрасывает месячный пробег +func (s *userStatsService) ResetMonthlyDistance(userID uint) error { + s.logger.Info("resetting monthly distance", + zap.Uint("user_id", userID), + ) + + userStats, err := s.userStatsRepo.GetByUserID(userID) + if err != nil { + s.logger.Error("failed to get user stats for monthly reset", + zap.Uint("user_id", userID), + zap.Error(err), + ) + return err + } + + userStats.MonthlyDistance = 0 + if err := s.userStatsRepo.Update(userStats); err != nil { + s.logger.Error("failed to reset monthly distance in repository", + zap.Uint("user_id", userID), + zap.Error(err), + ) + return err + } + + s.logger.Info("monthly distance reset successfully", + zap.Uint("user_id", userID), + ) + + return nil +} + +// CreateUserStats создает начальную статистику для пользователя +func (s *userStatsService) CreateUserStats(userID uint) error { + s.logger.Info("creating user stats", + zap.Uint("user_id", userID), + ) + + userStats := &models.UserStats{ + UserID: userID, + TotalDistance: 0, + TotalTime: 0, + AvgPace: "0:00", + WorkoutsCount: 0, + CurrentStreak: 0, + LongestStreak: 0, + WeeklyDistance: 0, + MonthlyDistance: 0, + Best5K: "", + Best10K: "", + BestHalf: "", + BestMarathon: "", + LastWorkout: time.Time{}, + } + + if err := s.userStatsRepo.Create(userStats); err != nil { + s.logger.Error("failed to create user stats in repository", + zap.Uint("user_id", userID), + zap.Error(err), + ) + return err + } + + s.logger.Info("user stats created successfully", + zap.Uint("user_id", userID), + ) + + return nil } \ No newline at end of file