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:
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
</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>
|
||||
@@ -221,182 +221,13 @@ export default {
|
||||
showNewsModal: false,
|
||||
selectedNews: null,
|
||||
subscribeEmail: '',
|
||||
news: [], // Теперь пустой массив
|
||||
filters: [
|
||||
{ value: 'all', label: 'Все новости' },
|
||||
{ value: 'events', label: 'События' },
|
||||
{ value: 'training', label: 'Тренировки' },
|
||||
{ value: 'achievements', 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: {
|
||||
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': 'События',
|
||||
|
||||
@@ -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')
|
||||
|
||||
+17
-104
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
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())
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user