Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- У фильма есть рейтинг и список жанров; жанры в ответе — без дублей, по порядку id.
- Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья.
- Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков.
- Отзывы к фильмам: положительные и негативные, с рейтингом полезности по лайкам и дизлайкам.
- Слоистая архитектура: контроллер → сервис → хранилище, выбор реализации хранилища через `@Qualifier`.
- Внедрение зависимостей через конструктор (`@Autowired`).
- Строгая валидация входящих данных через `spring-boot-starter-validation`.
Expand Down Expand Up @@ -81,6 +82,18 @@
- `GET /users/{id}/friends` — список друзей пользователя.
- `GET /users/{id}/friends/common/{otherId}` — общие друзья с другим пользователем.

### Отзывы (`/reviews`)
- `POST /reviews` — добавить отзыв.
- `PUT /reviews` — изменить текст и тип отзыва.
- `DELETE /reviews/{id}` — удалить отзыв.
- `GET /reviews/{id}` — получить отзыв по ID.
- `GET /reviews?filmId={filmId}&count={count}` — отзывы к фильму (без `filmId` — ко всем),
по убыванию полезности; по умолчанию 10.
- `PUT /reviews/{id}/like/{userId}` — отметить отзыв полезным.
- `PUT /reviews/{id}/dislike/{userId}` — отметить отзыв бесполезным.
- `DELETE /reviews/{id}/like/{userId}` — убрать лайк.
- `DELETE /reviews/{id}/dislike/{userId}` — убрать дизлайк.

### Жанры (`/genres`)
- `GET /genres` — получить список всех жанров.
- `GET /genres/{id}` — получить жанр по ID.
Expand All @@ -100,6 +113,10 @@ erDiagram
users ||--o{ film_likes : "user_id"
users ||--o{ friendship : "user_id"
users ||--o{ friendship : "friend_id"
users ||--o{ reviews : "user_id"
films ||--o{ reviews : "film_id"
reviews ||--o{ review_likes : "review_id"
users ||--o{ review_likes : "user_id"

films {
BIGINT id PK
Expand Down Expand Up @@ -136,6 +153,18 @@ erDiagram
BIGINT user_id PK, FK
BIGINT friend_id PK, FK
}
reviews {
BIGINT id PK
VARCHAR content
BOOLEAN is_positive
BIGINT user_id FK
BIGINT film_id FK
}
review_likes {
BIGINT review_id PK, FK
BIGINT user_id PK, FK
BOOLEAN is_useful "TRUE — лайк, FALSE — дизлайк"
}
```

| Таблица | Что хранит |
Expand All @@ -147,6 +176,8 @@ erDiagram
| `film_genres` | связь «фильм — жанр», многие ко многим |
| `film_likes` | лайки: какой пользователь лайкнул какой фильм |
| `friendship` | дружба: одна строка — `user_id` добавил в друзья `friend_id`; подтверждённая (взаимная) дружба — две встречные строки |
| `reviews` | отзывы пользователей к фильмам; рейтинг полезности не хранится, а считается по `review_likes` |
| `review_likes` | оценки отзывов: одна строка — один пользователь, лайк или дизлайк |

У таблиц-связок составной первичный ключ, поэтому одна и та же пара не запишется дважды.
При удалении фильма или пользователя связанные с ним записи удаляются каскадно.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package ru.yandex.practicum.filmorate.controller;

import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import ru.yandex.practicum.filmorate.model.Review;
import ru.yandex.practicum.filmorate.service.ReviewService;

import java.util.Collection;

@RestController
@RequestMapping("/reviews")
@Slf4j
public class ReviewController {

private final ReviewService reviewService;

@Autowired
public ReviewController(ReviewService reviewService) {
this.reviewService = reviewService;
}

@PostMapping
public Review create(@Valid @RequestBody Review review) {
log.info("POST /reviews : userId={}, filmId={}", review.getUserId(), review.getFilmId());
return reviewService.add(review);
}

@PutMapping
public Review update(@Valid @RequestBody Review review) {
log.info("PUT /reviews : id={}", review.getReviewId());
return reviewService.update(review);
}

@DeleteMapping("/{id}")
public void delete(@PathVariable long id) {
log.info("DELETE /reviews/{}", id);
reviewService.delete(id);
}

@GetMapping("/{id}")
public Review findById(@PathVariable long id) {
log.info("GET /reviews/{}", id);
return reviewService.findById(id);
}

@GetMapping
public Collection<Review> findMostUseful(
@RequestParam(required = false) Long filmId,
@RequestParam(defaultValue = "10") int count) {
log.info("GET /reviews?filmId={}&count={}", filmId, count);
return reviewService.findMostUseful(filmId, count);
}

@PutMapping("/{id}/like/{userId}")
public void addLike(@PathVariable long id, @PathVariable long userId) {
log.info("PUT /reviews/{}/like/{}", id, userId);
reviewService.addLike(id, userId);
}

@PutMapping("/{id}/dislike/{userId}")
public void addDislike(@PathVariable long id, @PathVariable long userId) {
log.info("PUT /reviews/{}/dislike/{}", id, userId);
reviewService.addDislike(id, userId);
}

@DeleteMapping("/{id}/like/{userId}")
public void removeLike(@PathVariable long id, @PathVariable long userId) {
log.info("DELETE /reviews/{}/like/{}", id, userId);
reviewService.removeLike(id, userId);
}

@DeleteMapping("/{id}/dislike/{userId}")
public void removeDislike(@PathVariable long id, @PathVariable long userId) {
log.info("DELETE /reviews/{}/dislike/{}", id, userId);
reviewService.removeDislike(id, userId);
}
}
24 changes: 24 additions & 0 deletions src/main/java/ru/yandex/practicum/filmorate/model/Review.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package ru.yandex.practicum.filmorate.model;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

@Data
public class Review {
private Long reviewId;

@NotBlank(message = "Текст отзыва не может быть пустым")
private String content;

@NotNull(message = "Тип отзыва должен быть указан")
private Boolean isPositive;

@NotNull(message = "Автор отзыва должен быть указан")
private Long userId;

@NotNull(message = "Фильм должен быть указан")
private Long filmId;

private int useful;
}
118 changes: 118 additions & 0 deletions src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package ru.yandex.practicum.filmorate.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import ru.yandex.practicum.filmorate.exception.NotFoundException;
import ru.yandex.practicum.filmorate.exception.ValidationException;
import ru.yandex.practicum.filmorate.model.Review;
import ru.yandex.practicum.filmorate.storage.film.FilmStorage;
import ru.yandex.practicum.filmorate.storage.review.ReviewStorage;
import ru.yandex.practicum.filmorate.storage.user.UserStorage;

import java.util.Collection;

@Service
@Slf4j
public class ReviewService {

private final ReviewStorage reviewStorage;
private final FilmStorage filmStorage;
private final UserStorage userStorage;

@Autowired
public ReviewService(ReviewStorage reviewStorage,
@Qualifier("filmDbStorage") FilmStorage filmStorage,
@Qualifier("userDbStorage") UserStorage userStorage) {
this.reviewStorage = reviewStorage;
this.filmStorage = filmStorage;
this.userStorage = userStorage;
}

private Review getReviewOrThrow(long id) {
return reviewStorage.findById(id)
.orElseThrow(() -> new NotFoundException("Отзыв с id=" + id + " не найден"));
}

private void checkUserExists(long id) {
userStorage.findById(id)
.orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден"));
}

private void checkFilmExists(long id) {
filmStorage.findById(id)
.orElseThrow(() -> new NotFoundException("Фильм с id=" + id + " не найден"));
}

public Review add(Review review) {
checkUserExists(review.getUserId());
checkFilmExists(review.getFilmId());
Review created = reviewStorage.add(review);
log.debug("Пользователь id={} оставил отзыв id={} к фильму id={}",
created.getUserId(), created.getReviewId(), created.getFilmId());
return created;
}

// Автор, фильм и рейтинг полезности не меняются — только текст и тип отзыва
public Review update(Review review) {
if (review.getReviewId() == null) {
throw new ValidationException("Не указан id отзыва");
}
getReviewOrThrow(review.getReviewId());
return reviewStorage.update(review);
}

public void delete(long id) {
getReviewOrThrow(id);
reviewStorage.delete(id);
log.debug("Отзыв id={} удалён", id);
}

public Review findById(long id) {
return getReviewOrThrow(id);
}

public Collection<Review> findMostUseful(Long filmId, int count) {
if (count <= 0) {
throw new ValidationException(
"Количество отзывов должно быть положительным числом, получено: " + count
);
}
if (filmId != null) {
checkFilmExists(filmId);
}
return reviewStorage.findMostUseful(filmId, count);
}

public void addLike(long reviewId, long userId) {
addRating(reviewId, userId, true);
}

public void addDislike(long reviewId, long userId) {
addRating(reviewId, userId, false);
}

public void removeLike(long reviewId, long userId) {
removeRating(reviewId, userId, true);
}

public void removeDislike(long reviewId, long userId) {
removeRating(reviewId, userId, false);
}

// Повторная оценка заменяет предыдущую: лайк после дизлайка меняет рейтинг на +2
private void addRating(long reviewId, long userId, boolean isUseful) {
getReviewOrThrow(reviewId);
checkUserExists(userId);
reviewStorage.addRating(reviewId, userId, isUseful);
log.debug("Пользователь id={} оценил отзыв id={}: полезный={}", userId, reviewId, isUseful);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Можно добавить запрет на самолайк

@DariaPrindina DariaPrindina Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

запрет на самолайк сломает автотесты Практикума: в тесте «Add like to review» автор лайкает собственный отзыв и ожидается 200. Поэтому оставляю как в спецификации, без ограничения.

}

private void removeRating(long reviewId, long userId, boolean isUseful) {
getReviewOrThrow(reviewId);
checkUserExists(userId);
reviewStorage.removeRating(reviewId, userId, isUseful);
log.debug("Пользователь id={} убрал оценку отзыва id={}: полезный={}", userId, reviewId, isUseful);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package ru.yandex.practicum.filmorate.storage.mapper;

import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
import ru.yandex.practicum.filmorate.model.Review;

import java.sql.ResultSet;
import java.sql.SQLException;

@Component
public class ReviewRowMapper implements RowMapper<Review> {

@Override
public Review mapRow(ResultSet rs, int rowNum) throws SQLException {
Review review = new Review();
review.setReviewId(rs.getLong("id"));
review.setContent(rs.getString("content"));
review.setIsPositive(rs.getBoolean("is_positive"));
review.setUserId(rs.getLong("user_id"));
review.setFilmId(rs.getLong("film_id"));
review.setUseful(rs.getInt("useful"));
return review;
}
}
Loading
Loading