modified: begushiybashkir/bbvue/src/router/index.js

modified:   begushiybashkir/bbvue/src/views/Login.vue
	new file:   begushiybashkir/bbvue/src/views/Logout.vue
	modified:   begushiybashkir/bbvue/src/views/News.vue
	modified:   begushiybashkir/bbvue/src/views/Profile.vue
	modified:   serv_nginx/api_bb/cmd/main.go
	modified:   serv_nginx/api_bb/go.mod
	modified:   serv_nginx/api_bb/go.sum
	new file:   serv_nginx/api_bb/internal/app/app.go
	new file:   serv_nginx/api_bb/internal/database/database.go
	new file:   serv_nginx/api_bb/internal/database/migrate.go
	new file:   serv_nginx/api_bb/internal/handlers/news_handler.go
	new file:   serv_nginx/api_bb/internal/models/news.go
	new file:   serv_nginx/api_bb/internal/repository/comment_repository.go
	new file:   serv_nginx/api_bb/internal/repository/news_repository.go
	modified:   serv_nginx/api_bb/internal/routes/routes.go
	new file:   serv_nginx/api_bb/internal/service/news_service.go
	modified:   serv_nginx/api_bb/pkg/utils/utils.go
save router paths to login  logout profile from upsunction commit
This commit is contained in:
2025-10-12 21:38:50 +05:00
parent 12f805f9e1
commit 6bb475acb2
18 changed files with 1424 additions and 309 deletions
+62 -10
View File
@@ -48,7 +48,8 @@ const router = createRouter({
{ {
path: '/login', path: '/login',
name: 'Login', name: 'Login',
component: () => import('../views/Login.vue') component: () => import('../views/Login.vue'),
meta: { guestOnly: true }
}, },
{ {
path: '/profile', path: '/profile',
@@ -59,7 +60,8 @@ const router = createRouter({
{ {
path: '/register', path: '/register',
name: 'Register', name: 'Register',
component: () => import('../views/Register.vue') component: () => import('../views/Register.vue'),
meta: { guestOnly: true }
}, },
{ {
path: '/profile/edit', path: '/profile/edit',
@@ -76,33 +78,83 @@ const router = createRouter({
path: '/privacy', path: '/privacy',
name: 'PrivacyPolicy', name: 'PrivacyPolicy',
component: () => import('../views/PrivacyPolicy.vue') 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) => { router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore() const authStore = useAuthStore()
// Если пользователь переходит на защищенные страницы и не авторизован // Проверяем, требует ли маршрут аутентификации
if (to.meta.requiresAuth && !authStore.isAuthenticated) { if (to.meta.requiresAuth && !authStore.isAuthenticated) {
// Проверяем, есть ли токен в localStorage // Если есть токен, пробуем загрузить профиль
if (authStore.token) { if (authStore.token) {
try { try {
// Пытаемся загрузить профиль
await authStore.fetchProfile() await authStore.fetchProfile()
next() next()
return
} catch (error) { } catch (error) {
console.log(error) console.log('Token validation failed:', error)
// Если токен невалидный, очищаем его и редиректим на логин
authStore.clearAuth()
next('/login') next('/login')
return
} }
} else { } else {
// Если нет токена, редиректим на логин
next('/login') next('/login')
return
} }
} else {
next()
} }
// Проверяем, предназначен ли маршрут только для гостей
if (to.meta.guestOnly && authStore.isAuthenticated) {
showNotification("Вы уже авторизованы. Перенаправляем в профиль...")
// Ждем немного чтобы пользователь увидел уведомление, затем редиректим
setTimeout(() => {
next('/profile')
}, 2000)
return
}
// Если все проверки пройдены, разрешаем навигацию
next()
}) })
export default router export default router
+65 -2
View File
@@ -62,11 +62,74 @@ export default {
methods: { methods: {
async handleLogin() { async handleLogin() {
const result = await this.authStore.login(this.credentials) const result = await this.authStore.login(this.credentials)
alert("register success" + result.success + "| data: " + result.data)
if (result.success) { 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)
} }
} }
</script> </script>
@@ -0,0 +1,51 @@
<template>
<div class="page">
<h1>🚪 Выход из системы</h1>
<div class="logout-content">
<p>Выполняется выход из системы...</p>
<div class="loading-spinner"></div>
</div>
</div>
</template>
<script>
import { useAuthStore } from '../stores/auth'
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'Logout',
setup() {
const authStore = useAuthStore()
return { authStore }
},
async mounted() {
// Выполняем выход
await this.authStore.logout()
// Редиректим на главную страницу
this.$router.push('/')
}
}
</script>
<style scoped>
.logout-content {
text-align: center;
padding: 2rem;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #2e8b57;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
+39 -188
View File
@@ -221,182 +221,13 @@ export default {
showNewsModal: false, showNewsModal: false,
selectedNews: null, selectedNews: null,
subscribeEmail: '', subscribeEmail: '',
news: [], // Теперь пустой массив
filters: [ filters: [
{ value: 'all', label: 'Все новости' }, { value: 'all', label: 'Все новости' },
{ value: 'events', label: 'События' }, { value: 'events', label: 'События' },
{ value: 'training', label: 'Тренировки' }, { value: 'training', label: 'Тренировки' },
{ value: 'achievements', label: 'Достижения' }, { value: 'achievements', label: 'Достижения' },
{ value: 'community', label: 'Сообщество' } { value: 'community', label: 'Сообщество' }
],
news: [
{
id: 1,
title: 'Открыт набор в новую группу для начинающих',
excerpt: 'Приглашаем всех желающих начать свой беговой путь. Бесплатное пробное занятие и индивидуальный подход к каждому.',
date: '2025-01-15',
category: 'training',
image: 'news1.jpg',
views: 124,
comments: 8,
content: `
<p>Дорогие друзья! Мы рады сообщить об открытии новой группы для начинающих бегунов. Если вы всегда хотели начать бегать, но не знали как — это ваш шанс!</p>
<h3>Что вас ждет:</h3>
<ul>
<li>✅ Бесплатная первая тренировка</li>
<li>✅ Постепенное увеличение нагрузок</li>
<li>✅ Работа над правильной техникой</li>
<li>✅ Поддержка опытных участников</li>
</ul>
<p><strong>Первая тренировка:</strong> 20 января в 19:30 в Парке Якутова</p>
<p>Не упустите возможность начать свой беговой путь в дружеской атмосфере нашего клуба!</p>
`
},
{
id: 2,
title: 'Стартует программа подготовки к Уфимскому марафону',
excerpt: '16-недельная программа подготовки для тех, кто хочет успешно выступить на главном беговом событии весны.',
date: '2025-01-10',
category: 'events',
image: 'news2.jpg',
views: 89,
comments: 12,
content: `
<p>Внимание всем бегунам! Открывается запись на специальную программу подготовки к Уфимскому марафону 2025.</p>
<h3>Детали программы:</h3>
<ul>
<li>📅 Продолжительность: 16 недель</li>
<li>🏃‍♂️ Тренировки: 3-4 раза в неделю</li>
<li>🎯 Дистанции: от 5км до марафона</li>
<li>👨‍🏫 Индивидуальные планы от тренера</li>
</ul>
<p>Программа включает в себя все аспекты подготовки: беговые объемы, силовую подготовку, питание и восстановление.</p>
<p><strong>Старт программы:</strong> 1 февраля 2025</p>
<p><strong>Место:</strong> Основные тренировки в Парке Якутова и на стадионе Динамо</p>
`
},
{
id: 3,
title: 'Итоги забега РосХим Стерлитамак 2025',
excerpt: 'Наши участники показали блестящие результаты на зимнем забеге. Поздравляем всех финишеров и призеров!',
date: '2025-01-05',
category: 'achievements',
image: 'news3.jpg',
views: 156,
comments: 15,
content: `
<p>Гордимся нашими бегунами! На прошедшем забеге РосХим в Стерлитамаке участники клуба "Бегущий Башкир" показали отличные результаты.</p>
<h3>Лучшие результаты:</h3>
<ul>
<li>🥇 Сергей — 1 место в возрастной категории (10км - 36:52)</li>
<li>🥈 Ильгам — 2 место (10км - 37:59)</li>
<li>🥉 Данил — 3 место (21км - 1:30:40)</li>
</ul>
<p>Всего от нашего клуба в забеге участвовало 12 человек, и каждый показал достойный результат!</p>
<p>Особые поздравления нашим новичкам, которые впервые преодолели дистанцию 10 км. Вы большие молодцы!</p>
<p>Следующий старт — Уфимский полумарафон 15 февраля. Готовимся!</p>
`
},
{
id: 4,
title: 'Зимний спортивный фестиваль от клуба',
excerpt: 'Приглашаем всех на зимний фестиваль бега с мастер-классами, эстафетами и горячим чаем.',
date: '2024-12-28',
category: 'community',
image: 'news4.jpg',
views: 78,
comments: 6,
content: `
<p>Дорогие друзья! Приглашаем вас на наш традиционный зимний спортивный фестиваль.</p>
<h3>Программа мероприятия:</h3>
<ul>
<li>⏰ 10:00 — Регистрация участников</li>
<li>🏃‍♂️ 10:30 — Эстафеты для всех возрастов</li>
<li>🎯 11:30 — Мастер-класс по технике зимнего бега</li>
<li>🍵 12:30 — Чаепитие с угощениями</li>
<li>🎁 13:00 — Награждение и розыгрыш призов</li>
</ul>
<p>Мероприятие бесплатное для всех участников клуба. Приглашаем также друзей и семьи!</p>
<p><strong>Когда:</strong> 15 января 2025</p>
<p><strong>Где:</strong> Парк Якутова, главная аллея</p>
<p>Не забудьте теплую одежду и хорошее настроение!</p>
`
},
{
id: 5,
title: 'Новые тренировочные программы от тренера',
excerpt: 'Загир Аминев разработал новые программы тренировок для разных уровней подготовки.',
date: '2024-12-20',
category: 'training',
image: 'news5.jpg',
views: 92,
comments: 3,
content: `
<p>Наш тренер Загир Аминев подготовил новые тренировочные программы, которые уже доступны для всех участников клуба.</p>
<h3>Что нового:</h3>
<ul>
<li>📊 Программа для начинающих (0-3 месяца)</li>
<li>⚡ Программа для любителей (3-12 месяцев)</li>
<li>🏆 Программа для продвинутых (1+ год)</li>
<li>🏔️ Специальная программа для трейлраннинга</li>
</ul>
<p>Каждая программа включает:</p>
<ul>
<li>✅ Подробное расписание тренировок</li>
<li>✅ Рекомендации по питанию</li>
<li>✅ План восстановления</li>
<li>✅ Советы по экипировке</li>
</ul>
<p>Получить программу можно у тренера на любой тренировке или написав в Telegram.</p>
`
},
{
id: 6,
title: 'Набор волонтеров на Уфимский марафон',
excerpt: 'Приглашаем желающих помочь в организации главного бегового события весны в Уфе.',
date: '2024-12-15',
category: 'community',
image: 'news6.jpg',
views: 64,
comments: 4,
content: `
<p>Друзья! Организационный комитет Уфимского марафона начинает набор волонтеров, и мы приглашаем участников нашего клуба присоединиться.</p>
<h3>Чем могут помочь волонтеры:</h3>
<ul>
<li>📋 Регистрация участников</li>
<li>🎽 Выдача стартовых пакетов</li>
<li>💧 Организация пунктов питания</li>
<li>🏁 Помощь на финише</li>
<li>📢 Информационная поддержка</li>
</ul>
<p>Все волонтеры получат:</p>
<ul>
<li>✅ Фирменную футболку волонтера</li>
<li>✅ Питание в день мероприятия</li>
<li>✅ Благодарственное письмо</li>
<li>✅ Незабываемые эмоции</li>
</ul>
<p>Если хотите стать часть команды волонтеров, пишите Загиру в Telegram.</p>
`
}
] ]
} }
}, },
@@ -417,6 +248,44 @@ export default {
} }
}, },
methods: { 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) { setFilter(filter) {
this.activeFilter = filter this.activeFilter = filter
this.visibleNews = 6 this.visibleNews = 6
@@ -424,14 +293,6 @@ export default {
loadMore() { loadMore() {
this.visibleNews += 3 this.visibleNews += 3
}, },
openNewsModal(newsItem) {
this.selectedNews = newsItem
this.showNewsModal = true
document.body.style.overflow = 'hidden'
// Увеличиваем счетчик просмотров
newsItem.views++
},
closeNewsModal() { closeNewsModal() {
this.showNewsModal = false this.showNewsModal = false
document.body.style.overflow = '' document.body.style.overflow = ''
@@ -449,16 +310,6 @@ export default {
alert('Ссылка скопирована в буфер обмена!') 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) { getCategoryLabel(category) {
const labels = { const labels = {
'events': 'События', 'events': 'События',
+1 -1
View File
@@ -209,7 +209,7 @@ export default {
}, },
async handleLogout() { async handleLogout() {
await this.authStore.logout() await this.authStore.logout()
this.$router.push('/login') this.$router.push('/')
}, },
editProfile() { editProfile() {
this.$router.push('/profile/edit') this.$router.push('/profile/edit')
+16 -103
View File
@@ -2,26 +2,19 @@
package main package main
import ( import (
"context"
"log" "log"
"net/http"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"api_bb/internal/app"
"api_bb/internal/config"
"api_bb/pkg/logger"
"go.uber.org/zap" "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() { func main() {
// Загрузка конфигурации // Загрузка конфигурации
cfg := config.Load() cfg := config.Load()
@@ -35,58 +28,13 @@ func main() {
} }
defer logger.Sync() defer logger.Sync()
zapLogger := logger.Get()
// Логируем начало работы // Логируем начало работы
logger.LogApplicationStart(os.Getenv("REST_API_VERSION"), os.Getenv("ENVIRONMENT"), "") logger.LogApplicationStart(os.Getenv("REST_API_VERSION"), os.Getenv("ENVIRONMENT"), "")
// Логирование попытки подключения к БД // Создание и инициализация приложения
zapLogger.Info("attempting to connect to database", application := app.NewApp(cfg)
zap.String("host", extractHostFromDSN(cfg.DatabaseURL)), // функция для извлечения хоста из DSN if err := application.Initialize(); err != nil {
zap.String("database", extractDBNameFromDSN(cfg.DatabaseURL)), // функция для извлечения имени БД logger.Get().Fatal("failed to initialize application", zap.Error(err))
)
// Подключение к базе данных
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,
} }
// Канал для graceful shutdown // Канал для graceful shutdown
@@ -96,56 +44,21 @@ func main() {
// Запуск сервера в горутине // Запуск сервера в горутине
go func() { go func() {
zapLogger.Info("starting HTTP server", zap.String("port", cfg.Port)) if err := application.Start(); err != nil {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Get().Fatal("failed to start server", zap.Error(err))
zapLogger.Fatal("failed to start server", zap.Error(err))
} }
done <- true
}() }()
// Ожидание сигнала shutdown // Ожидание сигнала shutdown
<-quit <-quit
zapLogger.Info("shutdown signal received") logger.Get().Info("shutdown signal received")
// Логирование закрытия соединения с БД // Graceful shutdown приложения
zapLogger.Info("closing database connection") if err := application.Shutdown(); err != nil {
if err := sqlDB.Close(); err != nil { logger.Get().Fatal("could not gracefully shutdown the application", zap.Error(err))
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))
} }
logger.LogApplicationShutdown("graceful shutdown") logger.LogApplicationShutdown("graceful shutdown")
close(done) <-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 // Заглушка - нужно реализовать маскирование пароля
} }
+9 -1
View File
@@ -11,9 +11,17 @@ require (
gorm.io/gorm v1.31.0 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 ( require (
github.com/go-playground/validator/v10 v10.28.0
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect
+12
View File
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 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 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 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/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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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= 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/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+108
View File
@@ -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
}
@@ -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, " ")
}
@@ -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"
}
}
@@ -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,
})
}
+100
View File
@@ -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"`
}
@@ -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
}
@@ -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
}
@@ -25,6 +25,8 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
// Initialize repositories // Initialize repositories
userRepo := repository.NewUserRepository(db) userRepo := repository.NewUserRepository(db)
newsRepo := repository.NewNewsRepository(db)
commentRepo := repository.NewCommentRepository(db)
// Initialize logger // Initialize logger
baseLogger := logger.NewWrapper(logger.Get()) // Создаем базовый логгер baseLogger := logger.NewWrapper(logger.Get()) // Создаем базовый логгер
@@ -33,11 +35,13 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
jwtService := service.NewJWTService(config.JWTSecret) jwtService := service.NewJWTService(config.JWTSecret)
authService := service.NewAuthService(userRepo, jwtService, baseLogger) // Передаем логгер authService := service.NewAuthService(userRepo, jwtService, baseLogger) // Передаем логгер
userService := service.NewUserService(userRepo, jwtService, baseLogger) userService := service.NewUserService(userRepo, jwtService, baseLogger)
newsService := service.NewNewsService(newsRepo, commentRepo, baseLogger)
// Initialize handlers // Initialize handlers
healthHandler := handlers.NewHealthHandler() healthHandler := handlers.NewHealthHandler()
authHandler := handlers.NewAuthHandler(authService, jwtService) authHandler := handlers.NewAuthHandler(authService, jwtService)
userHandler := handlers.NewUserHandler(&userService) userHandler := handlers.NewUserHandler(&userService)
newsHandler := handlers.NewNewsHandler(newsService, baseLogger)
// Health routes // Health routes
r.Mount("/api", healthHandler.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("/events", eventHandler.Routes())
// r.Mount("/reviews", reviewHandler.Routes()) // r.Mount("/reviews", reviewHandler.Routes())
@@ -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,
},
}
}
+59
View File
@@ -2,7 +2,11 @@ package utils
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt"
"io"
"net/http" "net/http"
"strings"
) )
func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) { func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) {
@@ -14,3 +18,58 @@ func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) {
func RespondWithError(w http.ResponseWriter, statusCode int, message string) { func RespondWithError(w http.ResponseWriter, statusCode int, message string) {
RespondWithJSON(w, statusCode, map[string]string{"error": message}) RespondWithJSON(w, statusCode, map[string]string{"error": message})
} }
// 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
}