diff --git a/begushiybashkir/bbvue/src/router/index.js b/begushiybashkir/bbvue/src/router/index.js
index ec39c9e..d1db14b 100644
--- a/begushiybashkir/bbvue/src/router/index.js
+++ b/begushiybashkir/bbvue/src/router/index.js
@@ -48,7 +48,8 @@ const router = createRouter({
{
path: '/login',
name: 'Login',
- component: () => import('../views/Login.vue')
+ component: () => import('../views/Login.vue'),
+ meta: { guestOnly: true }
},
{
path: '/profile',
@@ -59,7 +60,8 @@ const router = createRouter({
{
path: '/register',
name: 'Register',
- component: () => import('../views/Register.vue')
+ component: () => import('../views/Register.vue'),
+ meta: { guestOnly: true }
},
{
path: '/profile/edit',
@@ -76,33 +78,83 @@ const router = createRouter({
path: '/privacy',
name: 'PrivacyPolicy',
component: () => import('../views/PrivacyPolicy.vue')
+ },
+ // Добавляем маршрут для выхода
+ {
+ path: '/logout',
+ name: 'Logout',
+ component: () => import('../views/Logout.vue')
}
]
})
+// Функция для показа уведомлений
+function showNotification(message) {
+ // Создаем элемент уведомления
+ const notification = document.createElement('div')
+ notification.style.cssText = `
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ background: #2e8b57;
+ color: white;
+ padding: 15px 20px;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+ z-index: 10000;
+ max-width: 300px;
+ font-family: Arial, sans-serif;
+ `
+ notification.textContent = message
+
+ document.body.appendChild(notification)
+
+ // Автоматически удаляем через 3 секунды
+ setTimeout(() => {
+ if (notification.parentNode) {
+ notification.parentNode.removeChild(notification)
+ }
+ }, 3000)
+}
+
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
-
- // Если пользователь переходит на защищенные страницы и не авторизован
+
+ // Проверяем, требует ли маршрут аутентификации
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
- // Проверяем, есть ли токен в localStorage
+ // Если есть токен, пробуем загрузить профиль
if (authStore.token) {
try {
- // Пытаемся загрузить профиль
await authStore.fetchProfile()
next()
+ return
} catch (error) {
- console.log(error)
+ console.log('Token validation failed:', error)
+ // Если токен невалидный, очищаем его и редиректим на логин
+ authStore.clearAuth()
next('/login')
+ return
}
} else {
+ // Если нет токена, редиректим на логин
next('/login')
+ return
}
- } else {
- next()
}
+
+ // Проверяем, предназначен ли маршрут только для гостей
+ if (to.meta.guestOnly && authStore.isAuthenticated) {
+ showNotification("Вы уже авторизованы. Перенаправляем в профиль...")
+
+ // Ждем немного чтобы пользователь увидел уведомление, затем редиректим
+ setTimeout(() => {
+ next('/profile')
+ }, 2000)
+ return
+ }
+
+ // Если все проверки пройдены, разрешаем навигацию
+ next()
})
-
-
-export default router
+export default router
\ No newline at end of file
diff --git a/begushiybashkir/bbvue/src/views/Login.vue b/begushiybashkir/bbvue/src/views/Login.vue
index 2de6431..18840db 100644
--- a/begushiybashkir/bbvue/src/views/Login.vue
+++ b/begushiybashkir/bbvue/src/views/Login.vue
@@ -62,11 +62,74 @@ export default {
methods: {
async handleLogin() {
const result = await this.authStore.login(this.credentials)
- alert("register success" + result.success + "| data: " + result.data)
+
if (result.success) {
- this.$router.push('/profile')
+ // Показываем уведомление об успешном входе
+ this.showSuccessNotification()
+
+ // Редиректим после небольшой задержки
+ setTimeout(() => {
+ this.$router.push('/profile')
+ }, 1500)
}
+ },
+ showSuccessNotification() {
+ const notification = document.createElement('div')
+ notification.style.cssText = `
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ background: #2e8b57;
+ color: white;
+ padding: 15px 20px;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+ z-index: 10000;
+ max-width: 300px;
+ font-family: Arial, sans-serif;
+ `
+ notification.textContent = '✅ Вход выполнен успешно!'
+
+ document.body.appendChild(notification)
+
+ setTimeout(() => {
+ if (notification.parentNode) {
+ notification.parentNode.removeChild(notification)
+ }
+ }, 3000)
}
+ },
+ // Добавляем проверку при монтировании компонента
+ mounted() {
+ // Если пользователь уже авторизован, показываем уведомление
+ if (this.authStore.isAuthenticated) {
+ this.showAlreadyLoggedInNotification()
+ }
+ },
+ showAlreadyLoggedInNotification() {
+ const notification = document.createElement('div')
+ notification.style.cssText = `
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ background: #ffd700;
+ color: #333;
+ padding: 15px 20px;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+ z-index: 10000;
+ max-width: 300px;
+ font-family: Arial, sans-serif;
+ `
+ notification.textContent = 'ℹ️ Вы уже авторизованы!'
+
+ document.body.appendChild(notification)
+
+ setTimeout(() => {
+ if (notification.parentNode) {
+ notification.parentNode.removeChild(notification)
+ }
+ }, 3000)
}
}
diff --git a/begushiybashkir/bbvue/src/views/Logout.vue b/begushiybashkir/bbvue/src/views/Logout.vue
new file mode 100644
index 0000000..bebc597
--- /dev/null
+++ b/begushiybashkir/bbvue/src/views/Logout.vue
@@ -0,0 +1,51 @@
+
+ Выполняется выход из системы...🚪 Выход из системы
+
Дорогие друзья! Мы рады сообщить об открытии новой группы для начинающих бегунов. Если вы всегда хотели начать бегать, но не знали как — это ваш шанс!
- -Первая тренировка: 20 января в 19:30 в Парке Якутова
-Не упустите возможность начать свой беговой путь в дружеской атмосфере нашего клуба!
- ` - }, - { - id: 2, - title: 'Стартует программа подготовки к Уфимскому марафону', - excerpt: '16-недельная программа подготовки для тех, кто хочет успешно выступить на главном беговом событии весны.', - date: '2025-01-10', - category: 'events', - image: 'news2.jpg', - views: 89, - comments: 12, - content: ` -Внимание всем бегунам! Открывается запись на специальную программу подготовки к Уфимскому марафону 2025.
- -Программа включает в себя все аспекты подготовки: беговые объемы, силовую подготовку, питание и восстановление.
- -Старт программы: 1 февраля 2025
-Место: Основные тренировки в Парке Якутова и на стадионе Динамо
- ` - }, - { - id: 3, - title: 'Итоги забега РосХим Стерлитамак 2025', - excerpt: 'Наши участники показали блестящие результаты на зимнем забеге. Поздравляем всех финишеров и призеров!', - date: '2025-01-05', - category: 'achievements', - image: 'news3.jpg', - views: 156, - comments: 15, - content: ` -Гордимся нашими бегунами! На прошедшем забеге РосХим в Стерлитамаке участники клуба "Бегущий Башкир" показали отличные результаты.
- -Всего от нашего клуба в забеге участвовало 12 человек, и каждый показал достойный результат!
- -Особые поздравления нашим новичкам, которые впервые преодолели дистанцию 10 км. Вы большие молодцы!
- -Следующий старт — Уфимский полумарафон 15 февраля. Готовимся!
- ` - }, - { - id: 4, - title: 'Зимний спортивный фестиваль от клуба', - excerpt: 'Приглашаем всех на зимний фестиваль бега с мастер-классами, эстафетами и горячим чаем.', - date: '2024-12-28', - category: 'community', - image: 'news4.jpg', - views: 78, - comments: 6, - content: ` -Дорогие друзья! Приглашаем вас на наш традиционный зимний спортивный фестиваль.
- -Мероприятие бесплатное для всех участников клуба. Приглашаем также друзей и семьи!
- -Когда: 15 января 2025
-Где: Парк Якутова, главная аллея
- -Не забудьте теплую одежду и хорошее настроение!
- ` - }, - { - id: 5, - title: 'Новые тренировочные программы от тренера', - excerpt: 'Загир Аминев разработал новые программы тренировок для разных уровней подготовки.', - date: '2024-12-20', - category: 'training', - image: 'news5.jpg', - views: 92, - comments: 3, - content: ` -Наш тренер Загир Аминев подготовил новые тренировочные программы, которые уже доступны для всех участников клуба.
- -Каждая программа включает:
-Получить программу можно у тренера на любой тренировке или написав в Telegram.
- ` - }, - { - id: 6, - title: 'Набор волонтеров на Уфимский марафон', - excerpt: 'Приглашаем желающих помочь в организации главного бегового события весны в Уфе.', - date: '2024-12-15', - category: 'community', - image: 'news6.jpg', - views: 64, - comments: 4, - content: ` -Друзья! Организационный комитет Уфимского марафона начинает набор волонтеров, и мы приглашаем участников нашего клуба присоединиться.
- -Все волонтеры получат:
-Если хотите стать часть команды волонтеров, пишите Загиру в Telegram.
- ` - } ] } }, @@ -417,6 +248,44 @@ export default { } }, methods: { + async fetchNews() { + try { + const response = await this.$http.get('/api/news', { + params: { + limit: 20, + offset: 0 + } + }) + this.news = response.data.news + } catch (error) { + console.error('Failed to fetch news:', error) + // Fallback на локальные данные если API недоступно + this.news = this.getFallbackNews() + } + }, + async openNewsModal(newsItem) { + try { + // Получаем полную новость с API + const response = await this.$http.get(`/api/news/${newsItem.id}`) + this.selectedNews = response.data + } catch (error) { + console.error('Failed to fetch news details:', error) + this.selectedNews = newsItem + } + + this.showNewsModal = true + document.body.style.overflow = 'hidden' + }, + async handleSubscribe() { + try { + await this.$http.post('/api/subscribe', { email: this.subscribeEmail }) + alert('Спасибо за подписку! Проверьте вашу почту для подтверждения.') + this.subscribeEmail = '' + } catch (error) { + console.error('Subscription failed:', error) + alert('Ошибка подписки. Попробуйте позже.') + } + }, setFilter(filter) { this.activeFilter = filter this.visibleNews = 6 @@ -424,14 +293,6 @@ export default { loadMore() { this.visibleNews += 3 }, - openNewsModal(newsItem) { - this.selectedNews = newsItem - this.showNewsModal = true - document.body.style.overflow = 'hidden' - - // Увеличиваем счетчик просмотров - newsItem.views++ - }, closeNewsModal() { this.showNewsModal = false document.body.style.overflow = '' @@ -449,16 +310,6 @@ export default { alert('Ссылка скопирована в буфер обмена!') } }, - handleSubscribe() { - // Здесь будет логика подписки - console.log('Подписка на email:', this.subscribeEmail) - alert('Спасибо за подписку! Проверьте вашу почту для подтверждения.') - this.subscribeEmail = '' - }, - getImageUrl(imageName) { - // Заглушка для изображений - return `https://via.placeholder.com/400x250/2e8b57/ffffff?text=${encodeURIComponent(imageName)}` - }, getCategoryLabel(category) { const labels = { 'events': 'События', diff --git a/begushiybashkir/bbvue/src/views/Profile.vue b/begushiybashkir/bbvue/src/views/Profile.vue index 8181ef9..b183c89 100644 --- a/begushiybashkir/bbvue/src/views/Profile.vue +++ b/begushiybashkir/bbvue/src/views/Profile.vue @@ -209,7 +209,7 @@ export default { }, async handleLogout() { await this.authStore.logout() - this.$router.push('/login') + this.$router.push('/') }, editProfile() { this.$router.push('/profile/edit') diff --git a/serv_nginx/api_bb/cmd/main.go b/serv_nginx/api_bb/cmd/main.go index 3c2aa3e..07e20d8 100644 --- a/serv_nginx/api_bb/cmd/main.go +++ b/serv_nginx/api_bb/cmd/main.go @@ -2,29 +2,22 @@ package main import ( - "context" "log" - "net/http" "os" "os/signal" "syscall" - "time" + + "api_bb/internal/app" + "api_bb/internal/config" + "api_bb/pkg/logger" "go.uber.org/zap" - "gorm.io/driver/postgres" - "gorm.io/gorm" - - "api_bb/internal/config" - "api_bb/internal/models" - "api_bb/internal/routes" - "api_bb/pkg/logger" ) func main() { - // Загрузка конфигурации cfg := config.Load() - + // Инициализация логгера if err := logger.Init( os.Getenv("LOG_LEVEL"), @@ -35,58 +28,13 @@ func main() { } defer logger.Sync() - zapLogger := logger.Get() - // Логируем начало работы logger.LogApplicationStart(os.Getenv("REST_API_VERSION"), os.Getenv("ENVIRONMENT"), "") - // Логирование попытки подключения к БД - zapLogger.Info("attempting to connect to database", - zap.String("host", extractHostFromDSN(cfg.DatabaseURL)), // функция для извлечения хоста из DSN - zap.String("database", extractDBNameFromDSN(cfg.DatabaseURL)), // функция для извлечения имени БД - ) - - // Подключение к базе данных - db, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{}) - if err != nil { - zapLogger.Fatal("failed to connect to database", - zap.Error(err), - zap.String("database_url", maskPassword(cfg.DatabaseURL)), // маскируем пароль в логах - ) - } - - // Логирование успешного подключения к БД - zapLogger.Info("successfully connected to database", - zap.String("host", extractHostFromDSN(cfg.DatabaseURL)), - zap.String("database", extractDBNameFromDSN(cfg.DatabaseURL)), - ) - - // Проверка соединения с БД - sqlDB, err := db.DB() - if err != nil { - zapLogger.Fatal("failed to get database instance", zap.Error(err)) - } - - if err := sqlDB.Ping(); err != nil { - zapLogger.Fatal("database ping failed", zap.Error(err)) - } - - zapLogger.Info("database ping successful") - - // Автомиграция - zapLogger.Info("starting database migration") - if err := db.AutoMigrate(&models.User{}); err != nil { - zapLogger.Fatal("database migration failed", zap.Error(err)) - } - zapLogger.Info("database migration completed successfully") - - // Настройка роутера - router := routes.SetupRouter(db, cfg) - - // Настройка HTTP сервера - server := &http.Server{ - Addr: ":" + cfg.Port, - Handler: router, + // Создание и инициализация приложения + application := app.NewApp(cfg) + if err := application.Initialize(); err != nil { + logger.Get().Fatal("failed to initialize application", zap.Error(err)) } // Канал для graceful shutdown @@ -96,56 +44,21 @@ func main() { // Запуск сервера в горутине go func() { - zapLogger.Info("starting HTTP server", zap.String("port", cfg.Port)) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - zapLogger.Fatal("failed to start server", zap.Error(err)) + if err := application.Start(); err != nil { + logger.Get().Fatal("failed to start server", zap.Error(err)) } + done <- true }() // Ожидание сигнала shutdown <-quit - zapLogger.Info("shutdown signal received") + logger.Get().Info("shutdown signal received") - // Логирование закрытия соединения с БД - zapLogger.Info("closing database connection") - if err := sqlDB.Close(); err != nil { - zapLogger.Error("failed to close database connection", zap.Error(err)) - } else { - zapLogger.Info("database connection closed successfully") - } - - // Graceful shutdown - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - server.SetKeepAlivesEnabled(false) - if err := server.Shutdown(ctx); err != nil { - zapLogger.Fatal("could not gracefully shutdown the server", zap.Error(err)) + // Graceful shutdown приложения + if err := application.Shutdown(); err != nil { + logger.Get().Fatal("could not gracefully shutdown the application", zap.Error(err)) } logger.LogApplicationShutdown("graceful shutdown") - close(done) -} - -// Вспомогательные функции для работы с DSN - -// extractHostFromDSN извлекает хост из DSN строки -func extractHostFromDSN(dsn string) string { - // Простая реализация - в продакшене лучше использовать парсер DSN - // Для postgres DSN формата: "host=localhost user=gorm password=gorm dbname=gorm port=9920 sslmode=disable TimeZone=Asia/Shanghai" - // Можно использовать более сложный парсер или регулярные выражения - return dsn // Заглушка - нужно реализовать парсинг DSN -} - -// extractDBNameFromDSN извлекает имя базы данных из DSN строки -func extractDBNameFromDSN(dsn string) string { - // Аналогично extractHostFromDSN - нужно реализовать парсинг - return dsn // Заглушка - нужно реализовать парсинг DSN -} - -// maskPassword маскирует пароль в DSN строке для безопасного логирования -func maskPassword(dsn string) string { - // Простая реализация - заменяет пароль на *** - // В продакшене нужно использовать более надежный метод - return dsn // Заглушка - нужно реализовать маскирование пароля + <-done } \ No newline at end of file diff --git a/serv_nginx/api_bb/go.mod b/serv_nginx/api_bb/go.mod index 2d7211d..aba0ac5 100644 --- a/serv_nginx/api_bb/go.mod +++ b/serv_nginx/api_bb/go.mod @@ -11,9 +11,17 @@ require ( gorm.io/gorm v1.31.0 ) -require go.uber.org/multierr v1.10.0 // indirect +require ( + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/sys v0.37.0 // indirect +) require ( + github.com/go-playground/validator/v10 v10.28.0 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect diff --git a/serv_nginx/api_bb/go.sum b/serv_nginx/api_bb/go.sum index d526fb6..b300b11 100644 --- a/serv_nginx/api_bb/go.sum +++ b/serv_nginx/api_bb/go.sum @@ -1,10 +1,18 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -21,6 +29,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -36,6 +46,8 @@ golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/serv_nginx/api_bb/internal/app/app.go b/serv_nginx/api_bb/internal/app/app.go new file mode 100644 index 0000000..ad1001e --- /dev/null +++ b/serv_nginx/api_bb/internal/app/app.go @@ -0,0 +1,108 @@ +package app + +import ( + "context" + "net/http" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + "api_bb/internal/config" + "api_bb/internal/database" + "api_bb/internal/routes" + "api_bb/pkg/logger" +) + +type App struct { + cfg *config.Config + db *database.Database + server *http.Server +} + +func NewApp(cfg *config.Config) *App { + return &App{ + cfg: cfg, + } +} + +// Initialize инициализирует приложение (БД, миграции, роутинг) +func (a *App) Initialize() error { + zapLogger := logger.Get() + + // Инициализация базы данных + dbConfig := &database.Config{ + URL: a.cfg.DatabaseURL, + } + a.db = database.NewDatabase(dbConfig) + + // Подключение к БД + if err := a.db.Connect(); err != nil { + return err + } + + // Проверка соединения + if err := a.db.Ping(); err != nil { + return err + } + + // Выполнение миграций + if err := a.db.Migrate(); err != nil { + return err + } + + // Настройка роутера + router := routes.SetupRouter(a.db.DB, a.cfg) + + // Настройка HTTP сервера + a.server = &http.Server{ + Addr: ":" + a.cfg.Port, + Handler: router, + } + + zapLogger.Info("application initialized successfully") + return nil +} + +// Start запускает HTTP сервер +func (a *App) Start() error { + zapLogger := logger.Get() + + zapLogger.Info("starting HTTP server", zap.String("port", a.cfg.Port)) + + if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return err + } + + return nil +} + +// Shutdown gracefully останавливает приложение +func (a *App) Shutdown() error { + zapLogger := logger.Get() + + zapLogger.Info("shutdown signal received") + + // Graceful shutdown сервера + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + a.server.SetKeepAlivesEnabled(false) + if err := a.server.Shutdown(ctx); err != nil { + zapLogger.Error("could not gracefully shutdown the server", zap.Error(err)) + return err + } + + // Закрытие соединения с БД + if err := a.db.Close(); err != nil { + return err + } + + zapLogger.Info("application shutdown completed") + return nil +} + +// GetDB возвращает экземпляр базы данных +func (a *App) GetDB() *gorm.DB { + return a.db.DB +} \ No newline at end of file diff --git a/serv_nginx/api_bb/internal/database/database.go b/serv_nginx/api_bb/internal/database/database.go new file mode 100644 index 0000000..f312357 --- /dev/null +++ b/serv_nginx/api_bb/internal/database/database.go @@ -0,0 +1,139 @@ +package database + +import ( + "fmt" + "strings" + + "go.uber.org/zap" + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "api_bb/pkg/logger" +) + +type Database struct { + DB *gorm.DB + cfg *Config +} + +type Config struct { + URL string +} + +func NewDatabase(cfg *Config) *Database { + return &Database{ + cfg: cfg, + } +} + +// Connect устанавливает соединение с базой данных +func (d *Database) Connect() error { + zapLogger := logger.Get() + + // Логирование попытки подключения к БД + zapLogger.Info("attempting to connect to database", + zap.String("host", ExtractHostFromDSN(d.cfg.URL)), + zap.String("database", ExtractDBNameFromDSN(d.cfg.URL)), + ) + + db, err := gorm.Open(postgres.Open(d.cfg.URL), &gorm.Config{}) + if err != nil { + zapLogger.Error("failed to connect to database", + zap.Error(err), + zap.String("database_url", MaskPassword(d.cfg.URL)), + ) + return fmt.Errorf("failed to connect to database: %w", err) + } + + d.DB = db + + // Логирование успешного подключения к БД + zapLogger.Info("successfully connected to database", + zap.String("host", ExtractHostFromDSN(d.cfg.URL)), + zap.String("database", ExtractDBNameFromDSN(d.cfg.URL)), + ) + + return nil +} + +// Ping проверяет соединение с базой данных +func (d *Database) Ping() error { + zapLogger := logger.Get() + + sqlDB, err := d.DB.DB() + if err != nil { + zapLogger.Error("failed to get database instance", zap.Error(err)) + return fmt.Errorf("failed to get database instance: %w", err) + } + + if err := sqlDB.Ping(); err != nil { + zapLogger.Error("database ping failed", zap.Error(err)) + return fmt.Errorf("database ping failed: %w", err) + } + + zapLogger.Info("database ping successful") + return nil +} + +// Close закрывает соединение с базой данных +func (d *Database) Close() error { + zapLogger := logger.Get() + + if d.DB == nil { + return nil + } + + sqlDB, err := d.DB.DB() + if err != nil { + zapLogger.Error("failed to get database instance for closing", zap.Error(err)) + return fmt.Errorf("failed to get database instance: %w", err) + } + + zapLogger.Info("closing database connection") + if err := sqlDB.Close(); err != nil { + zapLogger.Error("failed to close database connection", zap.Error(err)) + return fmt.Errorf("failed to close database connection: %w", err) + } + + zapLogger.Info("database connection closed successfully") + return nil +} + +// Вспомогательные функции для работы с DSN + +// ExtractHostFromDSN извлекает хост из DSN строки +func ExtractHostFromDSN(dsn string) string { + // Простая реализация для PostgreSQL DSN + parts := strings.Split(dsn, " ") + for _, part := range parts { + if strings.HasPrefix(part, "host=") { + return strings.TrimPrefix(part, "host=") + } + } + return "unknown" +} + +// ExtractDBNameFromDSN извлекает имя базы данных из DSN строки +func ExtractDBNameFromDSN(dsn string) string { + // Простая реализация для PostgreSQL DSN + parts := strings.Split(dsn, " ") + for _, part := range parts { + if strings.HasPrefix(part, "dbname=") { + return strings.TrimPrefix(part, "dbname=") + } + } + return "unknown" +} + +// MaskPassword маскирует пароль в DSN строке для безопасного логирования +func MaskPassword(dsn string) string { + // Простая реализация - заменяет пароль на *** + parts := strings.Split(dsn, " ") + for i, part := range parts { + if strings.HasPrefix(part, "password=") { + parts[i] = "password=***" + break + } + } + return strings.Join(parts, " ") +} \ No newline at end of file diff --git a/serv_nginx/api_bb/internal/database/migrate.go b/serv_nginx/api_bb/internal/database/migrate.go new file mode 100644 index 0000000..5f5dd89 --- /dev/null +++ b/serv_nginx/api_bb/internal/database/migrate.go @@ -0,0 +1,78 @@ +package database + +import ( + "go.uber.org/zap" + + "api_bb/internal/models" + "api_bb/pkg/logger" +) + +// Migrate выполняет автоматические миграции для всех моделей +func (d *Database) Migrate() error { + zapLogger := logger.Get() + + zapLogger.Info("starting database migration") + + // Список всех моделей для миграции + models := []interface{}{ + &models.User{}, + &models.News{}, + &models.Comment{}, + // Добавьте другие модели здесь + } + + for _, model := range models { + modelName := getModelName(model) + zapLogger.Debug("migrating model", zap.String("model", modelName)) + + if err := d.DB.AutoMigrate(model); err != nil { + zapLogger.Error("failed to migrate model", + zap.String("model", modelName), + zap.Error(err), + ) + return err + } + } + + zapLogger.Info("database migration completed successfully") + return nil +} + +// MigrateModels выполняет миграции для конкретных моделей +func (d *Database) MigrateModels(models ...interface{}) error { + zapLogger := logger.Get() + + zapLogger.Info("starting migration for specific models", + zap.Int("model_count", len(models)), + ) + + for _, model := range models { + modelName := getModelName(model) + zapLogger.Debug("migrating model", zap.String("model", modelName)) + + if err := d.DB.AutoMigrate(model); err != nil { + zapLogger.Error("failed to migrate model", + zap.String("model", modelName), + zap.Error(err), + ) + return err + } + } + + zapLogger.Info("models migration completed successfully") + return nil +} + +// getModelName возвращает имя модели для логирования +func getModelName(model interface{}) string { + switch model.(type) { + case *models.User: + return "User" + case *models.News: + return "News" + case *models.Comment: + return "Comment" + default: + return "Unknown" + } +} \ No newline at end of file diff --git a/serv_nginx/api_bb/internal/handlers/news_handler.go b/serv_nginx/api_bb/internal/handlers/news_handler.go new file mode 100644 index 0000000..7bf5733 --- /dev/null +++ b/serv_nginx/api_bb/internal/handlers/news_handler.go @@ -0,0 +1,278 @@ +package handlers + +import ( + "api_bb/internal/models" + "api_bb/internal/service" + "api_bb/pkg/logger" + "api_bb/pkg/utils" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + "go.uber.org/zap" +) + +type NewsHandler struct { + newsService service.NewsService + logger logger.Interface + validator *validator.Validate +} + +func NewNewsHandler(newsService service.NewsService, log logger.Interface) *NewsHandler { + return &NewsHandler{ + newsService: newsService, + logger: log, + validator: validator.New(), + } +} + +// GetNews возвращает список новостей с пагинацией и фильтрацией +func (h *NewsHandler) GetNews(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + category := r.URL.Query().Get("category") + + if limit == 0 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + + news, total, err := h.newsService.GetAllNews(limit, offset, category) + if err != nil { + h.logger.Error("Failed to get news", zap.Error(err)) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get news") + return + } + + utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{ + "news": news, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +// GetNewsByID возвращает конкретную новость +func (h *NewsHandler) GetNewsByID(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondWithError(w, http.StatusBadRequest, "Invalid news ID") + return + } + + news, err := h.newsService.GetNewsByID(uint(id)) + if err != nil { + utils.RespondWithError(w, http.StatusNotFound, "News not found") + return + } + + utils.RespondWithJSON(w, http.StatusOK, news) +} + +// CreateNews создает новую новость +func (h *NewsHandler) CreateNews(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value("userID").(uint) + if !ok { + utils.RespondWithError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var req models.CreateNewsRequest + if err := utils.DecodeJSONBody(w, r, &req); err != nil { + utils.RespondWithError(w, http.StatusBadRequest, "Invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + utils.RespondWithError(w, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + news, err := h.newsService.CreateNews(req, userID) + if err != nil { + h.logger.Error("Failed to create news", zap.Error(err)) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to create news") + return + } + + utils.RespondWithJSON(w, http.StatusCreated, news) +} + +// UpdateNews обновляет новость +func (h *NewsHandler) UpdateNews(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value("userID").(uint) + if !ok { + utils.RespondWithError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondWithError(w, http.StatusBadRequest, "Invalid news ID") + return + } + + var req models.UpdateNewsRequest + if err := utils.DecodeJSONBody(w, r, &req); err != nil { + utils.RespondWithError(w, http.StatusBadRequest, "Invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + utils.RespondWithError(w, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + news, err := h.newsService.UpdateNews(uint(id), req, userID) + if err != nil { + if err.Error() == "access denied" { + utils.RespondWithError(w, http.StatusForbidden, "Access denied") + return + } + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update news") + return + } + + utils.RespondWithJSON(w, http.StatusOK, news) +} + +// DeleteNews удаляет новость +func (h *NewsHandler) DeleteNews(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value("userID").(uint) + if !ok { + utils.RespondWithError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondWithError(w, http.StatusBadRequest, "Invalid news ID") + return + } + + err = h.newsService.DeleteNews(uint(id), userID) + if err != nil { + if err.Error() == "access denied" { + utils.RespondWithError(w, http.StatusForbidden, "Access denied") + return + } + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete news") + return + } + + utils.RespondWithJSON(w, http.StatusOK, map[string]string{"message": "News deleted successfully"}) +} + +// CreateComment создает комментарий к новости +func (h *NewsHandler) CreateComment(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value("userID").(uint) + if !ok { + utils.RespondWithError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + newsIDStr := chi.URLParam(r, "id") + newsID, err := strconv.ParseUint(newsIDStr, 10, 32) + if err != nil { + utils.RespondWithError(w, http.StatusBadRequest, "Invalid news ID") + return + } + + var req models.CreateCommentRequest + if err := utils.DecodeJSONBody(w, r, &req); err != nil { + utils.RespondWithError(w, http.StatusBadRequest, "Invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + utils.RespondWithError(w, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + comment, err := h.newsService.CreateComment(uint(newsID), req, userID) + if err != nil { + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to create comment") + return + } + + utils.RespondWithJSON(w, http.StatusCreated, comment) +} + +// GetComments возвращает комментарии к новости +func (h *NewsHandler) GetComments(w http.ResponseWriter, r *http.Request) { + newsIDStr := chi.URLParam(r, "id") + newsID, err := strconv.ParseUint(newsIDStr, 10, 32) + if err != nil { + utils.RespondWithError(w, http.StatusBadRequest, "Invalid news ID") + return + } + + comments, err := h.newsService.GetCommentsByNewsID(uint(newsID)) + if err != nil { + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get comments") + return + } + + utils.RespondWithJSON(w, http.StatusOK, comments) +} + +// DeleteComment удаляет комментарий +func (h *NewsHandler) DeleteComment(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value("userID").(uint) + if !ok { + utils.RespondWithError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + commentIDStr := chi.URLParam(r, "commentId") + commentID, err := strconv.ParseUint(commentIDStr, 10, 32) + if err != nil { + utils.RespondWithError(w, http.StatusBadRequest, "Invalid comment ID") + return + } + + err = h.newsService.DeleteComment(uint(commentID), userID) + if err != nil { + if err.Error() == "access denied" { + utils.RespondWithError(w, http.StatusForbidden, "Access denied") + return + } + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete comment") + return + } + + utils.RespondWithJSON(w, http.StatusOK, map[string]string{"message": "Comment deleted successfully"}) +} + +// GetUserNews возвращает новости конкретного пользователя +func (h *NewsHandler) GetUserNews(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value("userID").(uint) + if !ok { + utils.RespondWithError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + + if limit == 0 { + limit = 10 + } + + news, total, err := h.newsService.GetUserNews(userID, limit, offset) + if err != nil { + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get user news") + return + } + + utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{ + "news": news, + "total": total, + }) +} \ No newline at end of file diff --git a/serv_nginx/api_bb/internal/models/news.go b/serv_nginx/api_bb/internal/models/news.go new file mode 100644 index 0000000..14f0113 --- /dev/null +++ b/serv_nginx/api_bb/internal/models/news.go @@ -0,0 +1,100 @@ +package models + +import ( + "time" + "gorm.io/gorm" +) + +type NewsCategory string + +const ( + NewsCategoryEvents NewsCategory = "events" + NewsCategoryTraining NewsCategory = "training" + NewsCategoryAchievements NewsCategory = "achievements" + NewsCategoryCommunity NewsCategory = "community" +) + +type News struct { + ID uint `json:"id" gorm:"primarykey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"` + + Title string `json:"title" gorm:"size:255;not null"` + Excerpt string `json:"excerpt" gorm:"size:500;not null"` + Content string `json:"content" gorm:"type:text;not null"` + Image string `json:"image" gorm:"size:255"` + Category NewsCategory `json:"category" gorm:"type:varchar(20);not null"` + Views int `json:"views" gorm:"default:0"` + + // Связи + AuthorID uint `json:"author_id" gorm:"not null"` + Author User `json:"author" gorm:"foreignKey:AuthorID"` + + Comments []Comment `json:"comments,omitempty" gorm:"foreignKey:NewsID"` +} + +type Comment struct { + ID uint `json:"id" gorm:"primarykey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Content string `json:"content" gorm:"type:text;not null"` + + // Связи + NewsID uint `json:"news_id" gorm:"not null"` + AuthorID uint `json:"author_id" gorm:"not null"` + Author User `json:"author" gorm:"foreignKey:AuthorID"` +} + +// DTO для создания новости +type CreateNewsRequest struct { + Title string `json:"title" validate:"required,min=5,max=255"` + Excerpt string `json:"excerpt" validate:"required,min=10,max=500"` + Content string `json:"content" validate:"required,min=50"` + Image string `json:"image"` + Category NewsCategory `json:"category" validate:"required,oneof=events training achievements community"` +} + +// DTO для обновления новости +type UpdateNewsRequest struct { + Title string `json:"title" validate:"omitempty,min=5,max=255"` + Excerpt string `json:"excerpt" validate:"omitempty,min=10,max=500"` + Content string `json:"content" validate:"omitempty,min=50"` + Image string `json:"image"` + Category NewsCategory `json:"category" validate:"omitempty,oneof=events training achievements community"` +} + +// DTO для ответа с новостью +type NewsResponse struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title"` + Excerpt string `json:"excerpt"` + Content string `json:"content"` + Image string `json:"image"` + Category NewsCategory `json:"category"` + Views int `json:"views"` + Author AuthorInfo `json:"author"` + Comments int `json:"comments_count"` +} + +type AuthorInfo struct { + ID uint `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email,omitempty"` +} + +// DTO для комментария +type CreateCommentRequest struct { + Content string `json:"content" validate:"required,min=1,max=1000"` +} + +type CommentResponse struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + Content string `json:"content"` + Author AuthorInfo `json:"author"` +} \ No newline at end of file diff --git a/serv_nginx/api_bb/internal/repository/comment_repository.go b/serv_nginx/api_bb/internal/repository/comment_repository.go new file mode 100644 index 0000000..90a3927 --- /dev/null +++ b/serv_nginx/api_bb/internal/repository/comment_repository.go @@ -0,0 +1,42 @@ +package repository + +import ( + "api_bb/internal/models" + "gorm.io/gorm" +) + +type CommentRepository interface { + Create(comment *models.Comment) error + GetByNewsID(newsID uint) ([]models.Comment, error) + Delete(id uint) error + GetByID(id uint) (*models.Comment, error) +} + +type commentRepository struct { + db *gorm.DB +} + +func NewCommentRepository(db *gorm.DB) CommentRepository { + return &commentRepository{db: db} +} + +func (r *commentRepository) Create(comment *models.Comment) error { + return r.db.Create(comment).Error +} + +func (r *commentRepository) GetByNewsID(newsID uint) ([]models.Comment, error) { + var comments []models.Comment + err := r.db.Preload("Author").Where("news_id = ?", newsID). + Order("created_at ASC").Find(&comments).Error + return comments, err +} + +func (r *commentRepository) Delete(id uint) error { + return r.db.Delete(&models.Comment{}, id).Error +} + +func (r *commentRepository) GetByID(id uint) (*models.Comment, error) { + var comment models.Comment + err := r.db.Preload("Author").Where("id = ?", id).First(&comment).Error + return &comment, err +} \ No newline at end of file diff --git a/serv_nginx/api_bb/internal/repository/news_repository.go b/serv_nginx/api_bb/internal/repository/news_repository.go new file mode 100644 index 0000000..0bd07ee --- /dev/null +++ b/serv_nginx/api_bb/internal/repository/news_repository.go @@ -0,0 +1,88 @@ +package repository + +import ( + "api_bb/internal/models" + "gorm.io/gorm" +) + +type NewsRepository interface { + Create(news *models.News) error + GetByID(id uint) (*models.News, error) + GetAll(limit, offset int, category string) ([]models.News, int64, error) + Update(news *models.News) error + Delete(id uint) error + IncrementViews(id uint) error + GetByAuthor(authorID uint, limit, offset int) ([]models.News, int64, error) +} + +type newsRepository struct { + db *gorm.DB +} + +func NewNewsRepository(db *gorm.DB) NewsRepository { + return &newsRepository{db: db} +} + +func (r *newsRepository) Create(news *models.News) error { + return r.db.Create(news).Error +} + +func (r *newsRepository) GetByID(id uint) (*models.News, error) { + var news models.News + err := r.db.Preload("Author").Preload("Comments.Author"). + Where("id = ?", id).First(&news).Error + return &news, err +} + +func (r *newsRepository) GetAll(limit, offset int, category string) ([]models.News, int64, error) { + var news []models.News + var total int64 + + query := r.db.Preload("Author") + + if category != "" && category != "all" { + query = query.Where("category = ?", category) + } + + // Получаем общее количество + if err := query.Model(&models.News{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + // Получаем данные с пагинацией + err := query.Order("created_at DESC"). + Limit(limit).Offset(offset). + Find(&news).Error + + return news, total, err +} + +func (r *newsRepository) Update(news *models.News) error { + return r.db.Save(news).Error +} + +func (r *newsRepository) Delete(id uint) error { + return r.db.Delete(&models.News{}, id).Error +} + +func (r *newsRepository) IncrementViews(id uint) error { + return r.db.Model(&models.News{}).Where("id = ?", id). + Update("views", gorm.Expr("views + ?", 1)).Error +} + +func (r *newsRepository) GetByAuthor(authorID uint, limit, offset int) ([]models.News, int64, error) { + var news []models.News + var total int64 + + query := r.db.Preload("Author").Where("author_id = ?", authorID) + + if err := query.Model(&models.News{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + err := query.Order("created_at DESC"). + Limit(limit).Offset(offset). + Find(&news).Error + + return news, total, err +} \ 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 691e489..3dec3d2 100644 --- a/serv_nginx/api_bb/internal/routes/routes.go +++ b/serv_nginx/api_bb/internal/routes/routes.go @@ -25,6 +25,8 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler { // Initialize repositories userRepo := repository.NewUserRepository(db) + newsRepo := repository.NewNewsRepository(db) + commentRepo := repository.NewCommentRepository(db) // Initialize logger baseLogger := logger.NewWrapper(logger.Get()) // Создаем базовый логгер @@ -33,11 +35,13 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler { jwtService := service.NewJWTService(config.JWTSecret) authService := service.NewAuthService(userRepo, jwtService, baseLogger) // Передаем логгер userService := service.NewUserService(userRepo, jwtService, baseLogger) + newsService := service.NewNewsService(newsRepo, commentRepo, baseLogger) // Initialize handlers healthHandler := handlers.NewHealthHandler() authHandler := handlers.NewAuthHandler(authService, jwtService) userHandler := handlers.NewUserHandler(&userService) + newsHandler := handlers.NewNewsHandler(newsService, baseLogger) // Health routes r.Mount("/api", healthHandler.Routes()) @@ -58,6 +62,29 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler { // Здесь будут другие защищенные маршруты пользователя }) + r.Route("/news", func(r chi.Router) { + + // Публичные маршруты + r.Get("/", newsHandler.GetNews) + r.Get("/{id}", newsHandler.GetNewsByID) + r.Get("/{id}/comments", newsHandler.GetComments) + r.Get("/check", healthHandler.Check) + + // Защищенные маршруты + r.Group(func(r chi.Router) { + r.Use(middleware.AuthMiddleware(jwtService, userRepo)) + r.Use(middleware.RequireAuth) + + r.Post("/", newsHandler.CreateNews) + r.Put("/{id}", newsHandler.UpdateNews) + r.Delete("/{id}", newsHandler.DeleteNews) + r.Post("/{id}/comments", newsHandler.CreateComment) + r.Delete("/comments/{commentId}", newsHandler.DeleteComment) + r.Get("/my/news", newsHandler.GetUserNews) + r.Get("/check", healthHandler.Check) + }) + }) + // Здесь будут добавлены другие маршруты: // r.Mount("/events", eventHandler.Routes()) // r.Mount("/reviews", reviewHandler.Routes()) diff --git a/serv_nginx/api_bb/internal/service/news_service.go b/serv_nginx/api_bb/internal/service/news_service.go new file mode 100644 index 0000000..203e5bd --- /dev/null +++ b/serv_nginx/api_bb/internal/service/news_service.go @@ -0,0 +1,246 @@ +package service + +import ( + "api_bb/internal/models" + "api_bb/internal/repository" + "api_bb/pkg/logger" + "errors" + + "go.uber.org/zap" +) + +type NewsService interface { + CreateNews(req models.CreateNewsRequest, authorID uint) (*models.NewsResponse, error) + GetNewsByID(id uint) (*models.NewsResponse, error) + GetAllNews(limit, offset int, category string) ([]models.NewsResponse, int64, error) + UpdateNews(id uint, req models.UpdateNewsRequest, userID uint) (*models.NewsResponse, error) + DeleteNews(id uint, userID uint) error + IncrementViews(id uint) error + CreateComment(newsID uint, req models.CreateCommentRequest, authorID uint) (*models.CommentResponse, error) + GetCommentsByNewsID(newsID uint) ([]models.CommentResponse, error) + DeleteComment(commentID, userID uint) error + GetUserNews(userID uint, limit, offset int) ([]models.NewsResponse, int64, error) +} + +type newsService struct { + newsRepo repository.NewsRepository + commentRepo repository.CommentRepository + logger logger.Interface +} + +func NewNewsService(newsRepo repository.NewsRepository, commentRepo repository.CommentRepository, log logger.Interface) NewsService { + + + serviceLogger := log.With(zap.String("service", "news")) + + return &newsService{ + newsRepo: newsRepo, + commentRepo: commentRepo, + logger: serviceLogger, + } +} + +func (s *newsService) CreateNews(req models.CreateNewsRequest, authorID uint) (*models.NewsResponse, error) { + news := &models.News{ + Title: req.Title, + Excerpt: req.Excerpt, + Content: req.Content, + Image: req.Image, + Category: req.Category, + AuthorID: authorID, + } + + if err := s.newsRepo.Create(news); err != nil { + s.logger.Error("Failed to create news", zap.Error(err)) + return nil, errors.New("failed to create news") + } + + // Получаем созданную новость с автором + createdNews, err := s.newsRepo.GetByID(news.ID) + if err != nil { + return nil, err + } + + return s.toNewsResponse(createdNews), nil +} + +func (s *newsService) GetNewsByID(id uint) (*models.NewsResponse, error) { + news, err := s.newsRepo.GetByID(id) + if err != nil { + return nil, errors.New("news not found") + } + + // Увеличиваем счетчик просмотров + go s.newsRepo.IncrementViews(id) + + return s.toNewsResponse(news), nil +} + +func (s *newsService) GetAllNews(limit, offset int, category string) ([]models.NewsResponse, int64, error) { + news, total, err := s.newsRepo.GetAll(limit, offset, category) + if err != nil { + return nil, 0, err + } + + responses := make([]models.NewsResponse, len(news)) + for i, n := range news { + responses[i] = *s.toNewsResponse(&n) + } + + return responses, total, nil +} + +func (s *newsService) UpdateNews(id uint, req models.UpdateNewsRequest, userID uint) (*models.NewsResponse, error) { + news, err := s.newsRepo.GetByID(id) + if err != nil { + return nil, errors.New("news not found") + } + + // Проверяем права доступа + if news.AuthorID != userID { + return nil, errors.New("access denied") + } + + // Обновляем поля + if req.Title != "" { + news.Title = req.Title + } + if req.Excerpt != "" { + news.Excerpt = req.Excerpt + } + if req.Content != "" { + news.Content = req.Content + } + if req.Image != "" { + news.Image = req.Image + } + if req.Category != "" { + news.Category = req.Category + } + + if err := s.newsRepo.Update(news); err != nil { + return nil, err + } + + return s.toNewsResponse(news), nil +} + +func (s *newsService) DeleteNews(id uint, userID uint) error { + news, err := s.newsRepo.GetByID(id) + if err != nil { + return errors.New("news not found") + } + + // Проверяем права доступа + if news.AuthorID != userID { + return errors.New("access denied") + } + + return s.newsRepo.Delete(id) +} + +func (s *newsService) IncrementViews(id uint) error { + return s.newsRepo.IncrementViews(id) +} + +func (s *newsService) CreateComment(newsID uint, req models.CreateCommentRequest, authorID uint) (*models.CommentResponse, error) { + // Проверяем существование новости + _, err := s.newsRepo.GetByID(newsID) + if err != nil { + return nil, errors.New("news not found") + } + + comment := &models.Comment{ + Content: req.Content, + NewsID: newsID, + AuthorID: authorID, + } + + if err := s.commentRepo.Create(comment); err != nil { + return nil, err + } + + // Получаем созданный комментарий с автором + createdComment, err := s.commentRepo.GetByID(comment.ID) + if err != nil { + return nil, err + } + + return s.toCommentResponse(createdComment), nil +} + +func (s *newsService) GetCommentsByNewsID(newsID uint) ([]models.CommentResponse, error) { + comments, err := s.commentRepo.GetByNewsID(newsID) + if err != nil { + return nil, err + } + + responses := make([]models.CommentResponse, len(comments)) + for i, c := range comments { + responses[i] = *s.toCommentResponse(&c) + } + + return responses, nil +} + +func (s *newsService) DeleteComment(commentID, userID uint) error { + comment, err := s.commentRepo.GetByID(commentID) + if err != nil { + return errors.New("comment not found") + } + + // Проверяем права доступа + if comment.AuthorID != userID { + return errors.New("access denied") + } + + return s.commentRepo.Delete(commentID) +} + +func (s *newsService) GetUserNews(userID uint, limit, offset int) ([]models.NewsResponse, int64, error) { + news, total, err := s.newsRepo.GetByAuthor(userID, limit, offset) + if err != nil { + return nil, 0, err + } + + responses := make([]models.NewsResponse, len(news)) + for i, n := range news { + responses[i] = *s.toNewsResponse(&n) + } + + return responses, total, nil +} + +// Вспомогательные методы для преобразования +func (s *newsService) toNewsResponse(news *models.News) *models.NewsResponse { + return &models.NewsResponse{ + ID: news.ID, + CreatedAt: news.CreatedAt, + UpdatedAt: news.UpdatedAt, + Title: news.Title, + Excerpt: news.Excerpt, + Content: news.Content, + Image: news.Image, + Category: news.Category, + Views: news.Views, + Author: models.AuthorInfo{ + ID: news.Author.ID, + FirstName: news.Author.FirstName, + LastName: news.Author.LastName, + }, + Comments: len(news.Comments), + } +} + +func (s *newsService) toCommentResponse(comment *models.Comment) *models.CommentResponse { + return &models.CommentResponse{ + ID: comment.ID, + CreatedAt: comment.CreatedAt, + Content: comment.Content, + Author: models.AuthorInfo{ + ID: comment.Author.ID, + FirstName: comment.Author.FirstName, + LastName: comment.Author.LastName, + }, + } +} \ No newline at end of file diff --git a/serv_nginx/api_bb/pkg/utils/utils.go b/serv_nginx/api_bb/pkg/utils/utils.go index 255e810..1dc89df 100644 --- a/serv_nginx/api_bb/pkg/utils/utils.go +++ b/serv_nginx/api_bb/pkg/utils/utils.go @@ -2,7 +2,11 @@ package utils import ( "encoding/json" + "errors" + "fmt" + "io" "net/http" + "strings" ) func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) { @@ -13,4 +17,59 @@ func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) { func RespondWithError(w http.ResponseWriter, statusCode int, message string) { RespondWithJSON(w, statusCode, map[string]string{"error": message}) -} \ No newline at end of file +} + +// DecodeJSONBody декодирует JSON тело запроса +func DecodeJSONBody(w http.ResponseWriter, r *http.Request, dst interface{}) error { + if r.Header.Get("Content-Type") != "application/json" { + return errors.New("Content-Type header is not application/json") + } + + r.Body = http.MaxBytesReader(w, r.Body, 1048576) // 1MB limit + + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + err := dec.Decode(dst) + if err != nil { + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + + switch { + case errors.As(err, &syntaxError): + return fmt.Errorf("request body contains badly-formed JSON (at position %d)", syntaxError.Offset) + + case errors.Is(err, io.ErrUnexpectedEOF): + return errors.New("request body contains badly-formed JSON") + + case errors.As(err, &unmarshalTypeError): + return fmt.Errorf("request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset) + + case strings.HasPrefix(err.Error(), "json: unknown field "): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") + return fmt.Errorf("request body contains unknown field %s", fieldName) + + case errors.Is(err, io.EOF): + return errors.New("request body must not be empty") + + case err.Error() == "http: request body too large": + return errors.New("request body must not be larger than 1MB") + + default: + return err + } + } + + err = dec.Decode(&struct{}{}) + if err != io.EOF { + return errors.New("request body must only contain a single JSON object") + } + + return nil +} + +// GetUserIDFromContext извлекает userID из контекста +func GetUserIDFromContext(r *http.Request) (uint, bool) { + userID, ok := r.Context().Value("userID").(uint) + return userID, ok +}