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
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
- Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья.
- Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков.
- Отзывы к фильмам: положительные и негативные, с рейтингом полезности по лайкам и дизлайкам.
- Рекомендации фильмов: пользователю предлагаются фильмы, которые лайкнули
пользователи с максимальным пересечением по лайкам (при равенстве — все
такие пользователи).
- Лента событий пользователя: лайки, друзья и отзывы.
- Слоистая архитектура: контроллер → сервис → хранилище, выбор реализации хранилища через `@Qualifier`.
- Внедрение зависимостей через конструктор (`@Autowired`).
Expand Down Expand Up @@ -92,6 +95,7 @@
- `DELETE /users/{id}/friends/{friendId}` — удалить из друзей.
- `GET /users/{id}/friends` — список друзей пользователя.
- `GET /users/{id}/friends/common/{otherId}` — общие друзья с другим пользователем.
- `GET /users/{id}/recommendations` — рекомендации фильмов на основе похожих пользователей.
- `GET /users/{id}/feed` — лента событий пользователя по порядку: лайки (`LIKE`), друзья (`FRIEND`)
и отзывы (`REVIEW`) с операциями `ADD`, `REMOVE`, `UPDATE`.

Expand Down Expand Up @@ -269,6 +273,36 @@ JOIN friendship f2 ON u.id = f2.friend_id AND f2.user_id = 2
ORDER BY u.id;
```

Рекомендации для пользователя с id = 1: фильмы всех пользователей
с максимальным пересечением по лайкам, которых нет у нашего:

```sql
-- Шаг 1: максимальное пересечение лайков с любым другим пользователем
SELECT MAX(common) AS max_common FROM (
SELECT COUNT(*) AS common FROM film_likes fl1
JOIN film_likes fl2 ON fl1.film_id = fl2.film_id
WHERE fl1.user_id = 1 AND fl2.user_id <> 1
GROUP BY fl2.user_id
) t;

-- Шаг 2: все пользователи, у которых пересечение равно максимуму
SELECT fl2.user_id AS other_id FROM film_likes fl1
JOIN film_likes fl2 ON fl1.film_id = fl2.film_id
WHERE fl1.user_id = 1 AND fl2.user_id <> 1
GROUP BY fl2.user_id
HAVING COUNT(*) = <max_common>;

-- Шаг 3: их фильмы, которых нет у пользователя 1
SELECT f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name AS mpa_name
FROM films f
JOIN film_likes fl ON f.id = fl.film_id
LEFT JOIN mpa m ON f.mpa_id = m.id
WHERE fl.user_id IN (<similar_user_ids>)
AND f.id NOT IN (SELECT film_id FROM film_likes WHERE user_id = 1)
GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name
ORDER BY f.id;
```

</details>

## Реализация по этапам
Expand Down Expand Up @@ -311,7 +345,7 @@ API доведён до соответствия REST.

</details>

<details open>
<details>
<summary><strong>Этап 3 — База данных H2, DAO, жанры и рейтинги</strong></summary>

На этом этапе данные переехали из памяти в базу данных,
Expand All @@ -334,4 +368,22 @@ API доведён до соответствия REST.
- При создании и обновлении фильма проверяются рейтинг и жанры: неизвестный id — 404.
- Интеграционные тесты всех хранилищ (`@JdbcTest`, база в памяти); unit-тесты сервисов обновлены.

</details>

<details>
<summary><strong>Этап 4 — Отзывы, режиссёры, рекомендации</strong></summary>

На этом этапе добавлены отзывы с рейтингом полезности, режиссёры фильмов
и простая рекомендательная система.

**Что сделано:**
- Отзывы: таблицы `reviews` и `review_likes`; рейтинг полезности считается в SQL
через `SUM(CASE WHEN is_useful THEN 1 ELSE -1 END)`.
- Режиссёры: таблицы `directors` и `film_director`, у фильма — список режиссёров.
- Рекомендации фильмов: находим всех пользователей с максимальным пересечением
по лайкам и возвращаем их фильмы, которых нет у нашего.
Запрос фильмов живёт в FilmDbStorage, поэтому жанры и режиссёры
в ответе подтягиваются тем же путём, что и у остальных списочных запросов.
- Все хранилища покрыты интеграционными тестами (`@JdbcTest`).

</details>
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import ru.yandex.practicum.filmorate.model.Event;
import ru.yandex.practicum.filmorate.model.Film;
import ru.yandex.practicum.filmorate.model.User;
import ru.yandex.practicum.filmorate.service.UserService;

Expand Down Expand Up @@ -79,6 +80,12 @@ public Collection<User> getCommonFriends(
return userService.getCommonFriends(id, otherId);
}

@GetMapping("/{id}/recommendations")
public Collection<Film> getRecommendations(@PathVariable long id) {
log.info("GET /users/{}/recommendations", id);
return userService.getRecommendations(id);
}

@GetMapping("/{id}/feed")
public List<Event> getFeed(@PathVariable long id) {
log.info("GET /users/{}/feed", id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
import ru.yandex.practicum.filmorate.exception.ValidationException;
import ru.yandex.practicum.filmorate.model.Event;
import ru.yandex.practicum.filmorate.model.EventType;
import ru.yandex.practicum.filmorate.model.Film;
import ru.yandex.practicum.filmorate.model.Operation;
import ru.yandex.practicum.filmorate.model.User;
import ru.yandex.practicum.filmorate.storage.film.FilmStorage;
import ru.yandex.practicum.filmorate.storage.user.UserStorage;

import java.util.Collection;
Expand All @@ -20,11 +22,15 @@
public class UserService {

private final UserStorage userStorage;
private final FilmStorage filmStorage;
private final FeedService feedService;

@Autowired
public UserService(@Qualifier("userDbStorage") UserStorage userStorage, FeedService feedService) {
public UserService(@Qualifier("userDbStorage") UserStorage userStorage,
@Qualifier("filmDbStorage") FilmStorage filmStorage,
FeedService feedService) {
this.userStorage = userStorage;
this.filmStorage = filmStorage;
this.feedService = feedService;
}

Expand Down Expand Up @@ -128,4 +134,9 @@ public Collection<User> findAll() {
public User findById(long id) {
return getUserOrThrow(id);
}

public Collection<Film> getRecommendations(long userId) {
getUserOrThrow(userId);
return filmStorage.findRecommendations(userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public class FilmDbStorage implements FilmStorage {
private static final String FIND_POPULAR_TAIL_QUERY = " "
+ "GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name "
+ "ORDER BY COUNT(fl.user_id) DESC, f.id";
// Лимит отдельно: в общих фильмах, поиске и фильмах режиссёра его нет
// Лимит отдельно: в общих фильмах, рекомендациях и фильмах режиссёра его нет
private static final String LIMIT_CLAUSE = " LIMIT ?";

// Условия фильтрации популярных фильмов: по жанру и по году выпуска
Expand Down Expand Up @@ -105,6 +105,29 @@ public class FilmDbStorage implements FilmStorage {
"GROUP BY f.id, f.name, f.description, f.release_date, " +
"f.duration, f.mpa_id, m.name ";

// Рекомендации: максимум пересечений по лайкам среди всех пользователей
private static final String FIND_MAX_OVERLAP_QUERY =
"SELECT MAX(common) AS max_common FROM ("
+ "SELECT COUNT(*) AS common FROM film_likes fl1 "
+ "JOIN film_likes fl2 ON fl1.film_id = fl2.film_id "
+ "WHERE fl1.user_id = ? AND fl2.user_id <> ? "
+ "GROUP BY fl2.user_id"
+ ") t";
// Все пользователи, у которых пересечение равно максимуму
private static final String FIND_SIMILAR_USER_IDS_QUERY =
"SELECT fl2.user_id AS other_id FROM film_likes fl1 "
+ "JOIN film_likes fl2 ON fl1.film_id = fl2.film_id "
+ "WHERE fl1.user_id = ? AND fl2.user_id <> ? "
+ "GROUP BY fl2.user_id "
+ "HAVING COUNT(*) = ?";
// Фильмы, которые лайкнули похожие пользователи, но не лайкнул наш.
private static final String FIND_RECOMMENDATIONS_QUERY = SELECT_FILMS
+ "JOIN film_likes fl ON f.id = fl.film_id "
+ "WHERE fl.user_id IN (%s) "
+ "AND f.id NOT IN (SELECT film_id FROM film_likes WHERE user_id = ?) "
+ "GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name "
+ "ORDER BY f.id";

private final JdbcTemplate jdbc;
private final FilmRowMapper mapper;
private final GenreRowMapper genreMapper;
Expand Down Expand Up @@ -204,6 +227,39 @@ public List<Film> findCommon(long userId, long friendId) {
return films;
}

@Override
public List<Film> findRecommendations(long userId) {
Integer maxOverlap = jdbc.queryForObject(
FIND_MAX_OVERLAP_QUERY,
Integer.class,
userId, userId
);
if (maxOverlap == null) {
return List.of();
}

List<Long> similarUserIds = jdbc.query(
FIND_SIMILAR_USER_IDS_QUERY,
(rs, rowNum) -> rs.getLong("other_id"),
userId, userId, maxOverlap
);
if (similarUserIds.isEmpty()) {
return List.of();
}

String placeholders = String.join(", ", Collections.nCopies(similarUserIds.size(), "?"));
String sql = String.format(FIND_RECOMMENDATIONS_QUERY, placeholders);

List<Object> params = new ArrayList<>(similarUserIds);
params.add(userId); // исключаем фильмы, которые уже лайкнул наш пользователь

List<Film> films = jdbc.query(sql, mapper, params.toArray());
// Жанры и режиссёры подтягиваются тем же путём, что и у остальных списочных запросов
loadGenresForFilms(films);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Жанры теперь есть)
А режиссёры у рекомендованных фильмов всё ещё пустые: в остальных методах со списками фильмов рядом стоит и loadDirectors(films), а здесь его нет.

loadDirectors(films);
return films;
}

@Override
public void addLike(long filmId, long userId) {
jdbc.update(ADD_LIKE_QUERY, filmId, userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public interface FilmStorage {

List<Film> findCommon(long userId, long friendId);

List<Film> findRecommendations(long userId);

void addLike(long filmId, long userId);

boolean removeLike(long filmId, long userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest;
import org.springframework.context.annotation.Import;
import ru.yandex.practicum.filmorate.exception.NotFoundException;
import ru.yandex.practicum.filmorate.model.Film;
import ru.yandex.practicum.filmorate.model.Director;
import ru.yandex.practicum.filmorate.model.Film;
import ru.yandex.practicum.filmorate.model.Genre;
import ru.yandex.practicum.filmorate.model.Mpa;
import ru.yandex.practicum.filmorate.model.User;
Expand Down Expand Up @@ -305,6 +305,87 @@ void shouldReturnEmptyCommonFilmsWhenNoSharedLikes() {
assertEquals(0, filmStorage.findCommon(anna.getId(), boris.getId()).size());
}

// Рекомендации: фильм похожего пользователя, которого нет у нашего
@Test
void shouldReturnRecommendationsFromMostSimilarUser() {
User anna = createUser("anna");
User boris = createUser("boris");
Film first = createFilm("Первый");
Film second = createFilm("Второй");
filmStorage.addLike(first.getId(), anna.getId());
filmStorage.addLike(first.getId(), boris.getId());
filmStorage.addLike(second.getId(), boris.getId());

List<Film> recommendations = filmStorage.findRecommendations(anna.getId());

assertEquals(List.of(second.getId()),
recommendations.stream().map(Film::getId).toList());
}

// Жанры в рекомендациях подтягиваются — это то, чего не хватало в UserDbStorage
@Test
void shouldReturnRecommendationsWithGenres() {
User anna = createUser("anna");
User boris = createUser("boris");
Film first = createFilm("Первый");
Film second = newFilm("Второй");
second.getGenres().add(genre(1));
second = filmStorage.add(second);
filmStorage.addLike(first.getId(), anna.getId());
filmStorage.addLike(first.getId(), boris.getId());
filmStorage.addLike(second.getId(), boris.getId());

List<Film> recommendations = filmStorage.findRecommendations(anna.getId());

assertEquals(1, recommendations.size());
assertEquals(List.of("Комедия"), genreNames(recommendations.getFirst()));
}

@Test
void shouldReturnEmptyRecommendationsWhenNoSimilarUsers() {
User anna = createUser("anna");
createUser("boris");
Film film = createFilm("Фильм");
filmStorage.addLike(film.getId(), anna.getId());

assertTrue(filmStorage.findRecommendations(anna.getId()).isEmpty());
}

@Test
void shouldReturnEmptyWhenSimilarUserLikedNothingNew() {
User anna = createUser("anna");
User boris = createUser("boris");
Film film = createFilm("Фильм");
filmStorage.addLike(film.getId(), anna.getId());
filmStorage.addLike(film.getId(), boris.getId());

assertTrue(filmStorage.findRecommendations(anna.getId()).isEmpty());
}

// При равном пересечении учитываем всех «похожих», а не только одного
@Test
void shouldUseAllSimilarUsersWithMaxOverlap() {
User anna = createUser("anna");
User boris = createUser("boris");
User vera = createUser("vera");
Film first = createFilm("Первый");
Film second = createFilm("Второй");
Film third = createFilm("Третий");
// У Анны и Бориса пересечение по first, у Анны и Веры — тоже по first
filmStorage.addLike(first.getId(), anna.getId());
filmStorage.addLike(first.getId(), boris.getId());
filmStorage.addLike(first.getId(), vera.getId());
// У Бориса есть свой фильм second, у Веры — third; оба должны попасть в рекомендации
filmStorage.addLike(second.getId(), boris.getId());
filmStorage.addLike(third.getId(), vera.getId());

List<Film> recommendations = filmStorage.findRecommendations(anna.getId());

// Оба фильма от похожих пользователей, порядок по id
assertEquals(List.of(second.getId(), third.getId()),
recommendations.stream().map(Film::getId).toList());
}

@Test
void shouldReturnAllFilmsByDirectorSortedByLikes() {
Director director = createDirector("Christopher Nolan");
Expand Down
Loading