modified: begushiybashkir/bbvue/src/views/Reviews.vue

modified:   serv_nginx/api_bb/internal/handlers/handlers.go
	new file:   serv_nginx/api_bb/internal/handlers/review_handler.go
	new file:   serv_nginx/api_bb/internal/models/review.go
	new file:   serv_nginx/api_bb/internal/repository/review_repository.go
	modified:   serv_nginx/api_bb/internal/routes/routes.go
	new file:   serv_nginx/api_bb/internal/service/review_service.go
set reviews router, handler, service, repository
This commit is contained in:
2025-10-15 02:48:41 +05:00
parent 2327cd2f34
commit 6d8e179f90
7 changed files with 959 additions and 306 deletions
+295 -306
View File
@@ -1,3 +1,4 @@
<!-- Reviews.vue (обновленная версия) -->
<template>
<div class="reviews-page">
<!-- Герой-секция -->
@@ -8,15 +9,15 @@
<p class="hero-subtitle">Реальные истории и достижения наших бегунов</p>
<div class="hero-stats">
<div class="stat">
<div class="stat-number">{{ averageRating }}</div>
<div class="stat-number">{{ stats.average_rating || 0 }}</div>
<div class="stat-label">Средний рейтинг</div>
</div>
<div class="stat">
<div class="stat-number">{{ totalReviews }}</div>
<div class="stat-number">{{ stats.total_reviews || 0 }}</div>
<div class="stat-label">Отзывов</div>
</div>
<div class="stat">
<div class="stat-number">{{ successStories }}</div>
<div class="stat-number">{{ stats.success_stories || 0 }}</div>
<div class="stat-label">Историй успеха</div>
</div>
</div>
@@ -32,7 +33,7 @@
<div class="reviews-controls">
<div class="sort-controls">
<label for="sort">Сортировка:</label>
<select id="sort" v-model="sortBy" @change="sortReviews">
<select id="sort" v-model="sortBy" @change="loadReviews">
<option value="newest">Сначала новые</option>
<option value="oldest">Сначала старые</option>
<option value="highest">Высокий рейтинг</option>
@@ -40,48 +41,49 @@
</select>
</div>
<div class="filter-controls">
<button
v-for="filter in ratingFilters"
:key="filter.value"
<button v-for="filter in ratingFilters" :key="filter.value"
:class="['filter-btn', { 'active': activeRatingFilter === filter.value }]"
@click="filterByRating(filter.value)"
>
@click="filterByRating(filter.value)">
{{ filter.label }}
</button>
</div>
</div>
</div>
<!-- Загрузка -->
<div v-if="loading" class="loading">
<div class="spinner"></div>
<p>Загрузка отзывов...</p>
</div>
<!-- Сообщение об отсутствии отзывов -->
<div v-else-if="reviews.length === 0" class="no-reviews">
<h3>😔 Отзывов пока нет</h3>
<p>Будьте первым, кто поделится своим опытом!</p>
</div>
<!-- Сетка отзывов -->
<div class="reviews-grid">
<div
v-for="review in paginatedReviews"
:key="review.id"
class="review-card"
:class="getReviewCardClass(review.rating)"
>
<div v-else class="reviews-grid">
<div v-for="review in reviews" :key="review.id" class="review-card"
:class="getReviewCardClass(review.rating)">
<div class="review-header">
<div class="reviewer-info">
<div class="reviewer-avatar">
{{ getInitials(review.author) }}
</div>
<div class="reviewer-details">
<h4 class="review-author">{{ review.author }}</h4>
<h4 class="review-author">{{ review.author.first_name }} {{ review.author.last_name }}</h4>
<p class="reviewer-achievement" v-if="review.achievement">
🏆 {{ review.achievement }}
</p>
<p class="review-membership">
Участник {{ review.membership }}
Участник клуба
</p>
</div>
</div>
<div class="review-rating">
<div class="stars">
<span
v-for="star in 5"
:key="star"
:class="['star', { 'filled': star <= review.rating }]"
>
<span v-for="star in 5" :key="star" :class="['star', { 'filled': star <= review.rating }]">
</span>
</div>
@@ -91,7 +93,7 @@
<div class="review-content">
<p class="review-text">{{ review.text }}</p>
<div class="review-meta">
<div class="meta-items">
<span class="meta-item" v-if="review.distance">
@@ -104,40 +106,34 @@
💪 {{ review.trainings }} тренировок
</span>
</div>
<span class="review-date">{{ formatDate(review.date) }}</span>
<span class="review-date">{{ formatDate(review.created_at) }}</span>
</div>
<div class="review-actions" v-if="review.verified">
<span class="verified-badge"> Проверенный отзыв</span>
</div>
<!-- Действия для своих отзывов -->
<div class="review-actions" v-if="isMyReview(review)">
<button @click="editReview(review)" class="btn-edit"> Редактировать</button>
<button @click="deleteReview(review.id)" class="btn-delete">🗑 Удалить</button>
</div>
</div>
</div>
</div>
<!-- Пагинация -->
<div class="pagination" v-if="totalPages > 1">
<button
class="pagination-btn"
:disabled="currentPage === 1"
@click="changePage(currentPage - 1)"
>
<button class="pagination-btn" :disabled="currentPage === 1" @click="changePage(currentPage - 1)">
</button>
<button
v-for="page in visiblePages"
:key="page"
:class="['pagination-btn', { 'active': currentPage === page }]"
@click="changePage(page)"
>
<button v-for="page in visiblePages" :key="page"
:class="['pagination-btn', { 'active': currentPage === page }]" @click="changePage(page)">
{{ page }}
</button>
<button
class="pagination-btn"
:disabled="currentPage === totalPages"
@click="changePage(currentPage + 1)"
>
<button class="pagination-btn" :disabled="currentPage === totalPages" @click="changePage(currentPage + 1)">
</button>
</div>
@@ -146,27 +142,16 @@
<div class="reviews-stats">
<h3>📊 Распределение оценок</h3>
<div class="rating-bars">
<div
v-for="rating in 5"
:key="rating"
class="rating-bar"
>
<div v-for="rating in 5" :key="rating" class="rating-bar">
<div class="rating-label">
<span class="stars-small">
<span
v-for="star in 5"
:key="star"
:class="['star-small', { 'filled': star <= (6 - rating) }]"
>
<span v-for="star in 5" :key="star" :class="['star-small', { 'filled': star <= (6 - rating) }]">
</span>
</span>
</div>
<div class="bar-container">
<div
class="bar-fill"
:style="{ width: getRatingPercentage(6 - rating) + '%' }"
></div>
<div class="bar-fill" :style="{ width: getRatingPercentage(6 - rating) + '%' }"></div>
</div>
<div class="rating-count">
{{ getRatingCount(6 - rating) }}
@@ -207,13 +192,8 @@
<div class="form-group">
<label>Ваша оценка *</label>
<div class="rating-input">
<button
v-for="star in 5"
:key="star"
type="button"
:class="['star-btn', { 'active': newReview.rating >= star }]"
@click="newReview.rating = star"
>
<button v-for="star in 5" :key="star" type="button"
:class="['star-btn', { 'active': newReview.rating >= star }]" @click="newReview.rating = star">
</button>
</div>
@@ -222,12 +202,8 @@
<div class="form-row">
<div class="form-group">
<label for="achievement">Ваше достижение</label>
<input
id="achievement"
v-model="newReview.achievement"
type="text"
placeholder="Например: пробежал первый марафон"
>
<input id="achievement" v-model="newReview.achievement" type="text"
placeholder="Например: пробежал первый марафон">
</div>
<div class="form-group">
<label for="distance">Любимая дистанция</label>
@@ -244,32 +220,19 @@
<div class="form-group">
<label for="review-text">Ваш отзыв *</label>
<textarea
id="review-text"
v-model="newReview.text"
rows="5"
placeholder="Расскажите о вашем опыте в клубе, достижениях, атмосфере..."
required
></textarea>
<textarea id="review-text" v-model="newReview.text" rows="5"
placeholder="Расскажите о вашем опыте в клубе, достижениях, атмосфере..." required></textarea>
<div class="char-counter">
{{ newReview.text.length }}/500 символов
</div>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
:disabled="!isFormValid || submitting"
>
<button type="submit" class="btn btn-primary" :disabled="!isFormValid || submitting">
<span v-if="submitting">📤 Отправка...</span>
<span v-else>📝 Опубликовать отзыв</span>
</button>
<button
type="button"
class="btn btn-outline"
@click="resetForm"
>
<button type="button" class="btn btn-outline" @click="resetForm">
🗑 Очистить
</button>
</div>
@@ -284,7 +247,7 @@
<div class="cta-content">
<h2>Готовы присоединиться к нашему сообществу?</h2>
<p>Станьте следующей историей успеха в нашем клубе!</p>
<div class="success-stories-preview">
<div class="story-card">
<div class="story-avatar">А</div>
@@ -351,17 +314,28 @@
</template>
<script>
import api from '../stores/helpers/api';
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'Reviews',
data() {
return {
isAuthenticated: false, // В реальном приложении получать из store/auth
isAuthenticated: false,
currentUser: null,
loading: false,
sortBy: 'newest',
activeRatingFilter: 'all',
currentPage: 1,
reviewsPerPage: 6,
totalPages: 1,
submitting: false,
stats: {
total_reviews: 0,
average_rating: 0,
success_stories: 0,
rating_distribution: {}
},
ratingFilters: [
{ value: 'all', label: 'Все оценки' },
{ value: '5', label: '⭐ 5 звезд' },
@@ -372,185 +346,90 @@ export default {
rating: 0,
text: '',
achievement: '',
distance: ''
distance: '',
improvement: '',
trainings: 0
},
reviews: [
{
id: 1,
author: 'Анна',
rating: 5,
text: 'Пришла в клуб с нулевым опытом бега. За год прошла путь от 0 до марафона! Тренер Загир - профессионал своего дела, всегда поддерживает и дает ценные советы. Атмосфера в клубе невероятная!',
date: '2025-01-15',
achievement: 'Первый марафон за 4:20:15',
distance: '42.2 км',
improvement: '+8 мин на 10км',
trainings: 45,
membership: '1 год',
verified: true
},
{
id: 2,
author: 'Михаил',
rating: 5,
text: 'Искал профессиональный подход к тренировкам и нашел его здесь. Индивидуальные программы, работа над техникой, регулярные соревнования. За 6 месяцев улучшил результат на полумарафоне на 25 минут!',
date: '2025-01-12',
achievement: 'Полумарафон 1:35:20',
distance: '21.1 км',
improvement: '+25 мин на 21км',
trainings: 32,
membership: '6 месяцев',
verified: true
},
{
id: 3,
author: 'Елена',
rating: 5,
text: 'Очень нравятся групповые тренировки - всегда есть с кем побегать и пообщаться. Тренер внимательно следит за техникой, что помогло избежать травм. За 3 месяца похудела на 8 кг и полюбила бег!',
date: '2025-01-10',
achievement: 'Похудение на 8 кг',
distance: '10 км',
improvement: '+12 мин на 5км',
trainings: 24,
membership: '3 месяца',
verified: true
},
{
id: 4,
author: 'Сергей',
rating: 5,
text: 'Как опытный бегун могу сказать - тренер Загир один из лучших в Уфе. Его методики действительно работают. Благодаря клубу пробежал ультрамарафон и нашел единомышленников.',
date: '2025-01-08',
achievement: 'Ультрамарафон 80 км',
distance: 'Трейл',
improvement: 'Новый уровень',
trainings: 68,
membership: '2 года',
verified: true
},
{
id: 5,
author: 'Ғаяз',
rating: 4,
text: 'Хороший клуб с сильным тренером. Немного не хватает вечерних тренировок в будни. В остальном - отличная атмосфера и профессиональный подход.',
date: '2025-01-05',
achievement: 'Марафон 3:34:33',
distance: '42.2 км',
improvement: '+15 мин на марафоне',
trainings: 52,
membership: '1.5 года',
verified: true
},
{
id: 6,
author: 'Данил',
rating: 5,
text: 'Лучшее решение - присоединиться к этому клубу! Нашел друзей, улучшил результаты, участвую в забегах. Отдельное спасибо за подготовку к трейлам - незабываемые впечатления!',
date: '2025-01-03',
achievement: 'Трейл 120 км',
distance: 'Трейл',
improvement: '+18 мин на 10км',
trainings: 41,
membership: '10 месяцев',
verified: true
},
{
id: 7,
author: 'Ильвира',
rating: 5,
text: 'Как маме двоих детей было сложно найти время для спорта. В клубе подобрали удобный график, поддержали морально. Теперь бегаю регулярно и чувствую себя прекрасно!',
date: '2024-12-28',
achievement: 'Первый полумарафон',
distance: '21.1 км',
improvement: 'С нуля до 21км',
trainings: 18,
membership: '5 месяцев',
verified: true
},
{
id: 8,
author: 'Булат',
rating: 4,
text: 'Нравится системный подход к тренировкам. Есть небольшие замечания по организации некоторых мероприятий, но в целом - хороший клуб с перспективой развития.',
date: '2024-12-25',
achievement: 'Полумарафон 1:45:48',
distance: '21.1 км',
improvement: '+10 мин на 10км',
trainings: 29,
membership: '8 месяцев',
verified: true
}
]
reviews: []
}
},
computed: {
totalReviews() {
return this.reviews.length
},
averageRating() {
if (this.reviews.length === 0) return 0
const sum = this.reviews.reduce((acc, review) => acc + review.rating, 0)
return (sum / this.reviews.length).toFixed(1)
},
successStories() {
return this.reviews.filter(review => review.achievement && review.rating >= 4).length
},
filteredReviews() {
let filtered = this.reviews
// Фильтрация по рейтингу
if (this.activeRatingFilter !== 'all') {
const minRating = parseInt(this.activeRatingFilter)
filtered = filtered.filter(review => review.rating >= minRating)
}
// Сортировка
switch (this.sortBy) {
case 'newest':
filtered.sort((a, b) => new Date(b.date) - new Date(a.date))
break
case 'oldest':
filtered.sort((a, b) => new Date(a.date) - new Date(b.date))
break
case 'highest':
filtered.sort((a, b) => b.rating - a.rating || new Date(b.date) - new Date(a.date))
break
case 'lowest':
filtered.sort((a, b) => a.rating - b.rating || new Date(b.date) - new Date(a.date))
break
}
return filtered
},
paginatedReviews() {
const startIndex = (this.currentPage - 1) * this.reviewsPerPage
return this.filteredReviews.slice(startIndex, startIndex + this.reviewsPerPage)
},
totalPages() {
return Math.ceil(this.filteredReviews.length / this.reviewsPerPage)
},
visiblePages() {
const pages = []
const startPage = Math.max(1, this.currentPage - 2)
const endPage = Math.min(this.totalPages, startPage + 4)
for (let i = startPage; i <= endPage; i++) {
pages.push(i)
}
return pages
},
isFormValid() {
return this.newReview.rating > 0 &&
this.newReview.text.length >= 10 &&
this.newReview.text.length <= 500
return this.newReview.rating > 0 &&
this.newReview.text.length >= 10 &&
this.newReview.text.length <= 500
}
},
methods: {
getInitials(name) {
return name.split(' ').map(n => n[0]).join('').toUpperCase()
async loadReviews() {
this.loading = true
try {
const params = {
page: this.currentPage,
limit: this.reviewsPerPage,
sort: this.sortBy,
filter: this.activeRatingFilter === 'all' ? '' : this.activeRatingFilter
}
const response = await api.get('/v1/reviews', { params })
this.reviews = response.data.reviews
this.totalPages = response.data.total_pages
} catch (error) {
console.error('Ошибка при загрузке отзывов:', error)
this.$notify({
type: 'error',
title: 'Ошибка',
text: 'Не удалось загрузить отзывы'
})
} finally {
this.loading = false
}
},
async loadStats() {
try {
const response = await api.get('/v1/reviews/stats')
this.stats = response.data
} catch (error) {
console.error('Ошибка при загрузке статистики:', error)
}
},
async checkAuth() {
try {
const token = localStorage.getItem('token')
if (token) {
// Проверяем токен через API или декодируем
this.isAuthenticated = true
// Загружаем информацию о пользователе если нужно
}
} catch (error) {
console.error('Ошибка проверки авторизации:', error)
this.isAuthenticated = false
}
},
getInitials(author) {
if (!author) return '?'
const firstName = author.first_name || ''
const lastName = author.last_name || ''
return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase() || '?'
},
getReviewCardClass(rating) {
return `rating-${rating}`
},
formatDate(dateString) {
const date = new Date(dateString)
return date.toLocaleDateString('ru-RU', {
@@ -559,81 +438,186 @@ export default {
year: 'numeric'
})
},
sortReviews() {
this.currentPage = 1
},
filterByRating(rating) {
this.activeRatingFilter = rating
this.currentPage = 1
this.loadReviews()
},
changePage(page) {
this.currentPage = page
this.loadReviews()
window.scrollTo({ top: 0, behavior: 'smooth' })
},
getRatingCount(rating) {
return this.reviews.filter(review => review.rating === rating).length
return this.stats.rating_distribution?.[rating] || 0
},
getRatingPercentage(rating) {
const count = this.getRatingCount(rating)
return (count / this.totalReviews) * 100
const total = this.stats.total_reviews || 1
return (count / total) * 100
},
isMyReview(review) {
// В реальном приложении сравнивать с ID текущего пользователя
return this.isAuthenticated && review.author && review.author.id === this.currentUser?.id
},
async submitReview() {
if (!this.isFormValid) return
this.submitting = true
try {
// Имитация запроса к API
await new Promise(resolve => setTimeout(resolve, 1500))
const newReview = {
id: this.reviews.length + 1,
author: 'Вы', // В реальном приложении брать из профиля
const reviewData = {
rating: this.newReview.rating,
text: this.newReview.text,
date: new Date().toISOString().split('T')[0],
achievement: this.newReview.achievement,
distance: this.newReview.distance,
improvement: '',
trainings: 0,
membership: 'Новый участник',
verified: false
improvement: this.newReview.improvement,
trainings: this.newReview.trainings
}
this.reviews.unshift(newReview)
await api.post('/v1/reviews', reviewData)
this.resetForm()
// Показать уведомление об успехе
alert('Спасибо за ваш отзыв! После проверки модератором он будет опубликован.')
this.loadReviews() // Перезагружаем список отзывов
this.loadStats() // Обновляем статистику
this.$notify({
type: 'success',
title: 'Успех',
text: 'Спасибо за ваш отзыв! После проверки модератором он будет опубликован.'
})
} catch (error) {
console.error('Ошибка при отправке отзыва:', error)
alert('Произошла ошибка при отправке отзыва. Попробуйте еще раз.')
this.$notify({
type: 'error',
title: 'Ошибка',
text: 'Произошла ошибка при отправке отзыва. Попробуйте еще раз.'
})
} finally {
this.submitting = false
}
},
async editReview(review) {
// Реализация редактирования отзыва
console.log('Редактирование отзыва:', review)
},
async deleteReview(reviewId) {
if (!confirm('Вы уверены, что хотите удалить этот отзыв?')) {
return
}
try {
await api.delete(`/v1/reviews/${reviewId}`)
this.loadReviews() // Перезагружаем список отзывов
this.loadStats() // Обновляем статистику
this.$notify({
type: 'success',
title: 'Успех',
text: 'Отзыв успешно удален'
})
} catch (error) {
console.error('Ошибка при удалении отзыва:', error)
this.$notify({
type: 'error',
title: 'Ошибка',
text: 'Не удалось удалить отзыв'
})
}
},
resetForm() {
this.newReview = {
rating: 0,
text: '',
achievement: '',
distance: ''
distance: '',
improvement: '',
trainings: 0
}
},
login() {
// В реальном приложении перенаправлять на страницу логина
this.isAuthenticated = true
}
},
mounted() {
// В реальном приложении проверять авторизацию
// this.checkAuth()
this.checkAuth()
this.loadReviews()
this.loadStats()
},
watch: {
sortBy() {
this.loadReviews()
}
}
}
</script>
<style scoped>
/* Стили остаются такими же как в вашем файле */
/* Добавим только несколько новых стилей */
.loading {
text-align: center;
padding: 3rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #2e8b57;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 2s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.no-reviews {
text-align: center;
padding: 3rem;
color: #666;
}
.btn-edit,
.btn-delete {
background: none;
border: none;
color: #2e8b57;
cursor: pointer;
font-size: 0.8rem;
margin-right: 1rem;
}
.btn-delete {
color: #e74c3c;
}
.btn-edit:hover {
text-decoration: underline;
}
.btn-delete:hover {
text-decoration: underline;
}
.reviews-page {
min-height: 100vh;
background: linear-gradient(135deg, #f8fff8 0%, #f0f8f0 100%);
@@ -1346,64 +1330,64 @@ export default {
.hero-title {
font-size: 2.2rem;
}
.hero-stats {
gap: 2rem;
}
.stat-number {
font-size: 2rem;
}
.reviews-controls {
flex-direction: column;
align-items: stretch;
}
.sort-controls {
justify-content: space-between;
}
.filter-controls {
justify-content: center;
}
.reviews-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.review-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.review-rating {
align-self: flex-end;
}
.success-stories-preview {
grid-template-columns: 1fr;
}
.cta-features {
grid-template-columns: 1fr;
}
.cta-actions {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
max-width: 300px;
}
.auth-actions {
flex-direction: column;
}
@@ -1413,38 +1397,38 @@ export default {
.hero-section {
padding: 60px 0 40px;
}
.hero-title {
font-size: 1.8rem;
}
.section-title {
font-size: 2rem;
}
.container {
padding: 0 15px;
}
.review-card {
padding: 1.5rem;
}
.reviewer-info {
flex-direction: column;
text-align: center;
}
.reviewer-avatar {
align-self: center;
}
.review-meta {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.meta-items {
flex-direction: column;
gap: 0.5rem;
@@ -1457,6 +1441,7 @@ export default {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -1471,9 +1456,11 @@ export default {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
@@ -1522,16 +1509,16 @@ textarea:focus-visible {
.reviews-page {
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
}
.review-card {
background: #2d2d2d;
color: #ffffff;
}
.review-text {
color: #cccccc;
}
.form-group input,
.form-group select,
.form-group textarea {
@@ -1539,7 +1526,7 @@ textarea:focus-visible {
border-color: #555;
color: white;
}
.review-form {
background: #2d2d2d;
border-color: #555;
@@ -1609,6 +1596,7 @@ textarea:focus-visible {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
@@ -1617,6 +1605,7 @@ textarea:focus-visible {
/* Адаптация для печати */
@media print {
.hero-section,
.add-review-section,
.cta-section,
@@ -1624,7 +1613,7 @@ textarea:focus-visible {
.pagination {
display: none;
}
.review-card {
break-inside: avoid;
box-shadow: none;
@@ -16,6 +16,7 @@ type Handler struct {
userHandler *UserHandler
avatarHandler *AvatarHandler
newsHandler *NewsHandler
reviewHandler *ReviewHandler
// Здесь будут добавлены другие обработчики
// userHandler *UserHandler
// eventHandler *EventHandler
@@ -27,6 +28,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler {
userRepo := repository.NewUserRepository(db)
newsRepo := repository.NewNewsRepository(db)
commentRepo := repository.NewCommentRepository(db)
reviewRepo := repository.NewReviewRepository(db)
// Initialize logger
baseLogger := logger.NewWrapper(logger.Get()) // Создаем базовый логгер
@@ -37,6 +39,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler {
userService := service.NewUserService(userRepo, jwtService, baseLogger)
avatarService := service.NewAvatarService(userRepo, baseLogger)
newsService := service.NewNewsService(newsRepo, commentRepo, baseLogger)
reviewService := service.NewReviewService(reviewRepo, baseLogger)
// Инициализация обработчиков
healthHandler := NewHealthHandler()
@@ -44,6 +47,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler {
userHandler := NewUserHandler(&userService)
newsHandler := NewNewsHandler(newsService, baseLogger)
avatarHandler := NewAvatarHandler(avatarService)
reviewHandler := NewReviewHandler(reviewService, baseLogger)
return &Handler{
healthHandler: healthHandler,
@@ -51,6 +55,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler {
userHandler: userHandler,
newsHandler: newsHandler,
avatarHandler: avatarHandler,
reviewHandler: reviewHandler,
}
}
@@ -74,3 +79,7 @@ func (h *Handler) AvatarHandler() *AvatarHandler {
func (h *Handler) NewsHandler() *NewsHandler {
return h.newsHandler
}
func (h *Handler) ReviewHandler() *ReviewHandler {
return h.reviewHandler
}
@@ -0,0 +1,217 @@
// handlers/review_handler.go
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"
"go.uber.org/zap"
)
type ReviewHandler struct {
reviewService service.ReviewService
logger logger.LoggerInterface
}
func NewReviewHandler(reviewService service.ReviewService, logger logger.LoggerInterface) *ReviewHandler {
return &ReviewHandler{
reviewService: reviewService,
logger: logger,
}
}
func (h *ReviewHandler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/", h.GetReviews)
r.Get("/stats", h.GetReviewsStats)
r.Get("/my", h.GetMyReviews)
r.Post("/", h.CreateReview)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", h.GetReviewByID)
r.Put("/", h.UpdateReview)
r.Delete("/", h.DeleteReview)
})
return r
}
// GetReviews возвращает список отзывов с пагинацией и фильтрацией
func (h *ReviewHandler) GetReviews(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
sortBy := r.URL.Query().Get("sort")
filter := r.URL.Query().Get("filter")
if page < 1 {
page = 1
}
if limit < 1 {
limit = 6
}
reviews, totalPages, err := h.reviewService.GetAllReviews(page, limit, sortBy, filter)
if err != nil {
h.logger.Error("Failed to get reviews", zap.Error(err))
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get reviews")
return
}
response := map[string]interface{}{
"reviews": reviews,
"current_page": page,
"total_pages": totalPages,
"total_items": len(reviews),
}
utils.RespondWithJSON(w, http.StatusOK, response)
}
// GetReviewsStats возвращает статистику отзывов
func (h *ReviewHandler) GetReviewsStats(w http.ResponseWriter, r *http.Request) {
stats, err := h.reviewService.GetReviewsStats()
if err != nil {
h.logger.Error("Failed to get reviews stats", zap.Error(err))
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get reviews statistics")
return
}
utils.RespondWithJSON(w, http.StatusOK, stats)
}
// GetMyReviews возвращает отзывы текущего пользователя
func (h *ReviewHandler) GetMyReviews(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value("userID").(uint)
if !ok {
utils.RespondWithError(w, http.StatusUnauthorized, "User not authenticated")
return
}
reviews, err := h.reviewService.GetUserReviews(userID)
if err != nil {
h.logger.With(zap.String("userID", string(userID))).Error("Failed to get user reviews", zap.Error(err))
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get your reviews")
return
}
utils.RespondWithJSON(w, http.StatusOK, reviews)
}
// CreateReview создает новый отзыв
func (h *ReviewHandler) CreateReview(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value("userID").(uint)
if !ok {
utils.RespondWithError(w, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateReviewRequest
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
h.logger.Error("Failed to decode review request", zap.Error(err))
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request body")
return
}
review, err := h.reviewService.CreateReview(&req, userID)
if err != nil {
h.logger.With(zap.String("userID", string(userID))).Error("Failed to create review", zap.Error(err))
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to create review")
return
}
utils.RespondWithJSON(w, http.StatusCreated, review)
}
// GetReviewByID возвращает отзыв по ID
func (h *ReviewHandler) GetReviewByID(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 review ID")
return
}
review, err := h.reviewService.GetReviewByID(uint(id))
if err != nil {
h.logger.With(zap.String("id", string(id))).Error("Failed to get review", zap.Error(err))
utils.RespondWithError(w, http.StatusNotFound, "Review not found")
return
}
utils.RespondWithJSON(w, http.StatusOK, review)
}
// UpdateReview обновляет отзыв
func (h *ReviewHandler) UpdateReview(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value("userID").(uint)
if !ok {
utils.RespondWithError(w, http.StatusUnauthorized, "User not authenticated")
return
}
isAdmin, _ := r.Context().Value("isAdmin").(bool)
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondWithError(w, http.StatusBadRequest, "Invalid review ID")
return
}
var req models.UpdateReviewRequest
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
h.logger.Error("Failed to decode update review request", zap.Error(err))
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request body")
return
}
review, err := h.reviewService.UpdateReview(uint(id), &req, userID, isAdmin)
if err != nil {
h.logger.With(zap.Int("id", int(id))).With(zap.Int("userID", int(userID))).Error("Failed to update review", zap.Error(err))
if err.Error() == "unauthorized" {
utils.RespondWithError(w, http.StatusForbidden, "You can only update your own reviews")
return
}
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update review")
return
}
utils.RespondWithJSON(w, http.StatusOK, review)
}
// DeleteReview удаляет отзыв
func (h *ReviewHandler) DeleteReview(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value("userID").(uint)
if !ok {
utils.RespondWithError(w, http.StatusUnauthorized, "User not authenticated")
return
}
isAdmin, _ := r.Context().Value("isAdmin").(bool)
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondWithError(w, http.StatusBadRequest, "Invalid review ID")
return
}
err = h.reviewService.DeleteReview(uint(id), userID, isAdmin)
if err != nil {
h.logger.With(zap.Int("id", int(id))).With(zap.Int("userID", int(userID))).Error("Failed to delete review", zap.Error(err))
if err.Error() == "unauthorized" {
utils.RespondWithError(w, http.StatusForbidden, "You can only delete your own reviews")
return
}
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete review")
return
}
utils.RespondWithJSON(w, http.StatusOK, map[string]string{"message": "Review deleted successfully"})
}
@@ -0,0 +1,69 @@
// models/review.go
package models
import (
"time"
"gorm.io/gorm"
)
type Review 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"`
Rating int `json:"rating" gorm:"not null;check:rating >= 1 AND rating <= 5"`
Text string `json:"text" gorm:"type:text;not null"`
Achievement string `json:"achievement" gorm:"size:255"`
Distance string `json:"distance" gorm:"size:50"`
Improvement string `json:"improvement" gorm:"size:100"`
Trainings int `json:"trainings" gorm:"default:0"`
Verified bool `json:"verified" gorm:"default:false"`
// Связи
AuthorID uint `json:"author_id" gorm:"not null"`
Author User `json:"author" gorm:"foreignKey:AuthorID"`
}
// DTO для создания отзыва
type CreateReviewRequest struct {
Rating int `json:"rating" validate:"required,min=1,max=5"`
Text string `json:"text" validate:"required,min=10,max=500"`
Achievement string `json:"achievement" validate:"max=255"`
Distance string `json:"distance" validate:"max=50"`
Improvement string `json:"improvement" validate:"max=100"`
Trainings int `json:"trainings" validate:"min=0"`
}
// DTO для обновления отзыва
type UpdateReviewRequest struct {
Rating int `json:"rating" validate:"omitempty,min=1,max=5"`
Text string `json:"text" validate:"omitempty,min=10,max=500"`
Achievement string `json:"achievement" validate:"omitempty,max=255"`
Distance string `json:"distance" validate:"omitempty,max=50"`
Improvement string `json:"improvement" validate:"omitempty,max=100"`
Trainings int `json:"trainings" validate:"omitempty,min=0"`
}
// DTO для ответа с отзывом
type ReviewResponse struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
Rating int `json:"rating"`
Text string `json:"text"`
Achievement string `json:"achievement,omitempty"`
Distance string `json:"distance,omitempty"`
Improvement string `json:"improvement,omitempty"`
Trainings int `json:"trainings"`
Verified bool `json:"verified"`
Author AuthorInfo `json:"author"`
}
// DTO для статистики отзывов
type ReviewsStatsResponse struct {
TotalReviews int `json:"total_reviews"`
AverageRating float64 `json:"average_rating"`
SuccessStories int `json:"success_stories"`
RatingDistribution map[int]int `json:"rating_distribution"`
}
@@ -0,0 +1,155 @@
// repository/review_repository.go
package repository
import (
"api_bb/internal/models"
"gorm.io/gorm"
)
type ReviewRepository interface {
Create(review *models.Review) error
GetByID(id uint) (*models.Review, error)
GetAll(page, limit int, sortBy, filter string) ([]models.Review, int64, error)
GetByAuthorID(authorID uint) ([]models.Review, error)
Update(review *models.Review) error
Delete(id uint) error
GetStats() (*models.ReviewsStatsResponse, error)
GetRatingDistribution() (map[int]int, error)
}
type reviewRepository struct {
db *gorm.DB
}
func NewReviewRepository(db *gorm.DB) ReviewRepository {
return &reviewRepository{db: db}
}
func (r *reviewRepository) Create(review *models.Review) error {
return r.db.Create(review).Error
}
func (r *reviewRepository) GetByID(id uint) (*models.Review, error) {
var review models.Review
err := r.db.Preload("Author").First(&review, id).Error
return &review, err
}
func (r *reviewRepository) GetAll(page, limit int, sortBy, filter string) ([]models.Review, int64, error) {
var reviews []models.Review
var total int64
query := r.db.Model(&models.Review{}).Preload("Author")
// Применяем фильтрацию по рейтингу
if filter != "" && filter != "all" {
query = query.Where("rating >= ?", filter)
}
// Считаем общее количество
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Применяем сортировку
switch sortBy {
case "newest":
query = query.Order("created_at DESC")
case "oldest":
query = query.Order("created_at ASC")
case "highest":
query = query.Order("rating DESC, created_at DESC")
case "lowest":
query = query.Order("rating ASC, created_at DESC")
default:
query = query.Order("created_at DESC")
}
// Применяем пагинацию
offset := (page - 1) * limit
err := query.Offset(offset).Limit(limit).Find(&reviews).Error
return reviews, total, err
}
func (r *reviewRepository) GetByAuthorID(authorID uint) ([]models.Review, error) {
var reviews []models.Review
err := r.db.Where("author_id = ?", authorID).Preload("Author").Find(&reviews).Error
return reviews, err
}
func (r *reviewRepository) Update(review *models.Review) error {
return r.db.Save(review).Error
}
func (r *reviewRepository) Delete(id uint) error {
return r.db.Delete(&models.Review{}, id).Error
}
func (r *reviewRepository) GetStats() (*models.ReviewsStatsResponse, error) {
var totalReviews int64
var averageRating float64
var successStories int64
// Общее количество отзывов
if err := r.db.Model(&models.Review{}).Count(&totalReviews).Error; err != nil {
return nil, err
}
// Средний рейтинг
if err := r.db.Model(&models.Review{}).Select("AVG(rating)").Row().Scan(&averageRating); err != nil {
return nil, err
}
// Количество успешных историй (отзывы с рейтингом >= 4 и достижениями)
if err := r.db.Model(&models.Review{}).
Where("rating >= ? AND achievement != ?", 4, "").
Count(&successStories).Error; err != nil {
return nil, err
}
// Распределение по рейтингам
ratingDistribution, err := r.GetRatingDistribution()
if err != nil {
return nil, err
}
return &models.ReviewsStatsResponse{
TotalReviews: int(totalReviews),
AverageRating: averageRating,
SuccessStories: int(successStories),
RatingDistribution: ratingDistribution,
}, nil
}
func (r *reviewRepository) GetRatingDistribution() (map[int]int, error) {
var results []struct {
Rating int
Count int
}
err := r.db.Model(&models.Review{}).
Select("rating, COUNT(*) as count").
Group("rating").
Order("rating DESC").
Scan(&results).Error
if err != nil {
return nil, err
}
distribution := make(map[int]int)
for _, result := range results {
distribution[result.Rating] = result.Count
}
// Заполняем отсутствующие рейтинги нулями
for i := 1; i <= 5; i++ {
if _, exists := distribution[i]; !exists {
distribution[i] = 0
}
}
return distribution, nil
}
@@ -86,6 +86,24 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
})
})
// Маршруты для отзывов
r.Route("/reviews", func(r chi.Router) {
// Публичные маршруты
r.Get("/", allHandler.ReviewHandler().GetReviews)
r.Get("/stats", allHandler.ReviewHandler().GetReviewsStats)
r.Get("/{id}", allHandler.ReviewHandler().GetReviewByID)
// Защищенные маршруты
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(jwtService, userRepo))
r.Use(middleware.RequireAuth)
r.Post("/", allHandler.ReviewHandler().CreateReview)
r.Get("/my", allHandler.ReviewHandler().GetMyReviews)
r.Put("/{id}", allHandler.ReviewHandler().UpdateReview)
r.Delete("/{id}", allHandler.ReviewHandler().DeleteReview)
})
})
// Здесь будут добавлены другие маршруты:
// r.Mount("/events", eventHandler.Routes())
// r.Mount("/reviews", reviewHandler.Routes())
@@ -0,0 +1,196 @@
// service/review_service.go
package service
import (
"api_bb/internal/models"
"api_bb/internal/repository"
"api_bb/pkg/logger"
"errors"
"go.uber.org/zap"
)
type ReviewService interface {
CreateReview(req *models.CreateReviewRequest, authorID uint) (*models.ReviewResponse, error)
GetReviewByID(id uint) (*models.ReviewResponse, error)
GetAllReviews(page, limit int, sortBy, filter string) ([]models.ReviewResponse, int, error)
GetUserReviews(userID uint) ([]models.ReviewResponse, error)
UpdateReview(id uint, req *models.UpdateReviewRequest, userID uint, isAdmin bool) (*models.ReviewResponse, error)
DeleteReview(id uint, userID uint, isAdmin bool) error
GetReviewsStats() (*models.ReviewsStatsResponse, error)
}
type reviewService struct {
reviewRepo repository.ReviewRepository
logger logger.LoggerInterface
}
func NewReviewService(reviewRepo repository.ReviewRepository, logger logger.LoggerInterface) ReviewService {
return &reviewService{
reviewRepo: reviewRepo,
logger: logger,
}
}
func (s *reviewService) CreateReview(req *models.CreateReviewRequest, authorID uint) (*models.ReviewResponse, error) {
review := &models.Review{
Rating: req.Rating,
Text: req.Text,
Achievement: req.Achievement,
Distance: req.Distance,
Improvement: req.Improvement,
Trainings: req.Trainings,
AuthorID: authorID,
Verified: false, // По умолчанию непроверенный
}
if err := s.reviewRepo.Create(review); err != nil {
s.logger.Error("Failed to create review", zap.Error(err))
return nil, err
}
// Получаем созданный отзыв с информацией об авторе
createdReview, err := s.reviewRepo.GetByID(review.ID)
if err != nil {
s.logger.Error("Failed to get created review", zap.Error(err))
return nil, err
}
return s.toReviewResponse(createdReview), nil
}
func (s *reviewService) GetReviewByID(id uint) (*models.ReviewResponse, error) {
review, err := s.reviewRepo.GetByID(id)
if err != nil {
s.logger.With(zap.Int("id", int(id))).Error("Failed to get review by ID", zap.Error(err))
return nil, err
}
return s.toReviewResponse(review), nil
}
func (s *reviewService) GetAllReviews(page, limit int, sortBy, filter string) ([]models.ReviewResponse, int, error) {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 10
}
reviews, total, err := s.reviewRepo.GetAll(page, limit, sortBy, filter)
if err != nil {
s.logger.Error("Failed to get all reviews", zap.Error(err))
return nil, 0, err
}
responses := make([]models.ReviewResponse, len(reviews))
for i, review := range reviews {
responses[i] = *s.toReviewResponse(&review)
}
totalPages := (int(total) + limit - 1) / limit
return responses, totalPages, nil
}
func (s *reviewService) GetUserReviews(userID uint) ([]models.ReviewResponse, error) {
reviews, err := s.reviewRepo.GetByAuthorID(userID)
if err != nil {
s.logger.With(zap.Int("userID", int(userID))).Error("Failed to get user reviews", zap.Error(err))
return nil, err
}
responses := make([]models.ReviewResponse, len(reviews))
for i, review := range reviews {
responses[i] = *s.toReviewResponse(&review)
}
return responses, nil
}
func (s *reviewService) UpdateReview(id uint, req *models.UpdateReviewRequest, userID uint, isAdmin bool) (*models.ReviewResponse, error) {
review, err := s.reviewRepo.GetByID(id)
if err != nil {
s.logger.With(zap.Int("id", int(id))).Error("Failed to get review for update", zap.Error(err))
return nil, err
}
// Проверяем права доступа
if review.AuthorID != userID && !isAdmin {
s.logger.With(zap.Int("userID", int(userID))).With(zap.Int("reviewAuthorID", int(review.AuthorID))).Error("Unauthorized attempt to update review", zap.Error(err))
}
// Обновляем поля
if req.Rating != 0 {
review.Rating = req.Rating
}
if req.Text != "" {
review.Text = req.Text
}
if req.Achievement != "" {
review.Achievement = req.Achievement
}
if req.Distance != "" {
review.Distance = req.Distance
}
if req.Improvement != "" {
review.Improvement = req.Improvement
}
if req.Trainings != 0 {
review.Trainings = req.Trainings
}
if err := s.reviewRepo.Update(review); err != nil {
s.logger.With(zap.Int("id", int(id))).Error("Failed to update review", zap.Error(err))
return nil, err
}
// Получаем обновленный отзыв
updatedReview, err := s.reviewRepo.GetByID(id)
if err != nil {
s.logger.With(zap.Int("id", int(id))).Error("Failed to get updated review", zap.Error(err))
return nil, err
}
return s.toReviewResponse(updatedReview), nil
}
func (s *reviewService) DeleteReview(id uint, userID uint, isAdmin bool) error {
review, err := s.reviewRepo.GetByID(id)
if err != nil {
s.logger.With(zap.Int("id", int(id))).Error("Failed to get review for deletion", zap.Error(err))
return err
}
// Проверяем права доступа
if review.AuthorID != userID && !isAdmin {
s.logger.With(zap.Int("userID", int(userID))).With(zap.Int("reviewAuthorID", int(review.AuthorID))).Error("Unauthorized attempt to delete review", zap.Error(err))
return errors.New("unauthorized")
}
return s.reviewRepo.Delete(id)
}
func (s *reviewService) GetReviewsStats() (*models.ReviewsStatsResponse, error) {
return s.reviewRepo.GetStats()
}
func (s *reviewService) toReviewResponse(review *models.Review) *models.ReviewResponse {
return &models.ReviewResponse{
ID: review.ID,
CreatedAt: review.CreatedAt,
Rating: review.Rating,
Text: review.Text,
Achievement: review.Achievement,
Distance: review.Distance,
Improvement: review.Improvement,
Trainings: review.Trainings,
Verified: review.Verified,
Author: models.AuthorInfo{
ID: review.Author.ID,
FirstName: review.Author.FirstName,
LastName: review.Author.LastName,
Email: review.Author.Email,
},
}
}