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',
|
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
|
||||||
@@ -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>
|
||||||
@@ -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': 'События',
|
||||||
|
|||||||
@@ -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
@@ -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 // Заглушка - нужно реализовать маскирование пароля
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
// 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user