Разработка системы отзывов и рейтингов товаров для интернет-магазина
Отзывы — социальное доказательство, которое влияет на конверсию сильнее, чем описание товара. Средний рейтинг и количество отзывов отображаются в сниппетах Google через structured data, что даёт преимущество в поиске. Разработка системы отзывов занимает 4–7 рабочих дней.
Модель данных
CREATE TABLE reviews (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
order_item_id BIGINT REFERENCES order_items(id), -- подтверждённая покупка
user_id BIGINT REFERENCES users(id),
guest_name VARCHAR(100),
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
title VARCHAR(255),
body TEXT,
pros TEXT, -- Достоинства
cons TEXT, -- Недостатки
status VARCHAR(20) DEFAULT 'pending', -- pending, approved, rejected, spam
is_verified_purchase BOOLEAN DEFAULT FALSE,
helpful_count INT DEFAULT 0,
not_helpful_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE review_photos (
id BIGSERIAL PRIMARY KEY,
review_id BIGINT REFERENCES reviews(id) ON DELETE CASCADE,
url VARCHAR(500) NOT NULL,
sort_order SMALLINT DEFAULT 0
);
CREATE TABLE review_votes (
review_id BIGINT REFERENCES reviews(id) ON DELETE CASCADE,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
vote BOOLEAN NOT NULL, -- true = helpful, false = not helpful
PRIMARY KEY (review_id, user_id)
);
Кто может оставить отзыв
Три модели доступа:
| Модель | Плюсы | Минусы |
|---|---|---|
| Только покупатели | Высокое доверие | Мало отзывов |
| Авторизованные пользователи | Баланс | Возможны накрутки |
| Все (включая гостей) | Максимум отзывов | Требует ручной модерации |
Рекомендуется: авторизованные пользователи + пометка «Подтверждённая покупка» для тех, у кого есть завершённый заказ с этим товаром.
public function store(Request $request, Product $product): JsonResponse
{
$request->validate([
'rating' => 'required|integer|between:1,5',
'body' => 'required|string|min:20|max:2000',
'title' => 'nullable|string|max:255',
'pros' => 'nullable|string|max:500',
'cons' => 'nullable|string|max:500',
'photos' => 'nullable|array|max:5',
'photos.*' => 'url',
]);
$isVerified = OrderItem::whereHas('order', fn($q) =>
$q->where('user_id', $request->user()->id)->where('status', 'completed')
)->where('product_id', $product->id)->exists();
$review = Review::create([
...$request->validated(),
'product_id' => $product->id,
'user_id' => $request->user()->id,
'is_verified_purchase' => $isVerified,
'status' => $this->needsModeration($request) ? 'pending' : 'approved',
]);
$product->recalculateRating();
return response()->json(new ReviewResource($review), 201);
}
Модерация
Модерация может быть ручной или автоматизированной. Автоматические правила:
- Отзыв от верифицированного покупателя с рейтингом 4–5 и без стоп-слов →
approvedавтоматически - Первый отзыв нового пользователя →
pendingдля проверки - Текст содержит URL, телефон или слова из стоп-листа →
pendingилиspam
class ReviewModerationService
{
private array $stopWords = ['http', 'www.', 't.me/', 'whatsapp'];
public function needsModeration(string $text, User $user): bool
{
foreach ($this->stopWords as $word) {
if (str_contains(strtolower($text), $word)) return true;
}
return $user->reviews()->where('status', 'approved')->count() === 0;
}
}
В admin-панели — очередь отзывов на модерацию с быстрыми действиями: одобрить / отклонить / пометить спамом.
Пересчёт рейтинга товара
public function recalculateRating(): void
{
$stats = $this->reviews()
->where('status', 'approved')
->selectRaw('COUNT(*) as count, AVG(rating) as avg, SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END) as five_star')
->first();
$this->update([
'rating_avg' => round($stats->avg, 2),
'rating_count' => $stats->count,
]);
// Инвалидируем кеш страницы товара
Cache::forget("product:{$this->id}:rating");
}
Рейтинг хранится денормализованно в таблице products для быстрой сортировки в каталоге.
Распределение оценок
На странице товара показывается не только средний рейтинг, но и гистограмма:
const RatingDistribution = ({ distribution }: { distribution: Record<number, number> }) => {
const total = Object.values(distribution).reduce((a, b) => a + b, 0);
return (
<div className="space-y-1">
{[5, 4, 3, 2, 1].map(star => (
<div key={star} className="flex items-center gap-2">
<span className="w-4 text-sm">{star}</span>
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-yellow-400 rounded-full"
style={{ width: `${((distribution[star] ?? 0) / total) * 100}%` }}
/>
</div>
<span className="text-sm text-gray-500 w-8">{distribution[star] ?? 0}</span>
</div>
))}
</div>
);
};
Ответы магазина
Менеджер может отвечать на отзывы прямо из admin-панели. Ответ отображается под отзывом с пометкой «Ответ магазина»:
CREATE TABLE review_replies (
id BIGSERIAL PRIMARY KEY,
review_id BIGINT REFERENCES reviews(id) ON DELETE CASCADE,
user_id BIGINT REFERENCES users(id),
body TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
Structured data для SEO
Отзывы должны экспортироваться в JSON-LD для Google:
const ProductSchema = ({ product }: { product: ProductDetail }) => (
<script type="application/ld+json">{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: product.rating_avg,
reviewCount: product.rating_count,
bestRating: 5,
worstRating: 1,
},
review: product.topReviews.map(r => ({
'@type': 'Review',
author: { '@type': 'Person', name: r.user_name },
reviewRating: { '@type': 'Rating', ratingValue: r.rating },
reviewBody: r.body,
datePublished: r.created_at,
})),
})}</script>
);
Звёзды в сниппете появляются в поиске при наличии минимум 1 отзыва.
Сортировка и фильтрация отзывов
На странице товара отзывы сортируются: по дате, по рейтингу (высокий/низкий), по полезности. Фильтр по количеству звёзд — кликабельные строки в гистограмме. Это помогает покупателю найти релевантные отзывы и снижает bounce rate на странице товара.







