diff --git a/README.md b/README.md index 4a4fdd4..fcac98f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ - Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья. - Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков. - Отзывы к фильмам: положительные и негативные, с рейтингом полезности по лайкам и дизлайкам. +- Рекомендации фильмов: пользователю предлагаются фильмы, которые лайкнули + пользователи с максимальным пересечением по лайкам (при равенстве — все + такие пользователи). - Лента событий пользователя: лайки, друзья и отзывы. - Слоистая архитектура: контроллер → сервис → хранилище, выбор реализации хранилища через `@Qualifier`. - Внедрение зависимостей через конструктор (`@Autowired`). @@ -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`. @@ -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(*) = ; + +-- Шаг 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 () + 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; +``` + ## Реализация по этапам @@ -311,7 +345,7 @@ API доведён до соответствия REST. -
+
Этап 3 — База данных H2, DAO, жанры и рейтинги На этом этапе данные переехали из памяти в базу данных, @@ -334,4 +368,22 @@ API доведён до соответствия REST. - При создании и обновлении фильма проверяются рейтинг и жанры: неизвестный id — 404. - Интеграционные тесты всех хранилищ (`@JdbcTest`, база в памяти); unit-тесты сервисов обновлены. +
+ +
+Этап 4 — Отзывы, режиссёры, рекомендации + +На этом этапе добавлены отзывы с рейтингом полезности, режиссёры фильмов +и простая рекомендательная система. + +**Что сделано:** +- Отзывы: таблицы `reviews` и `review_likes`; рейтинг полезности считается в SQL + через `SUM(CASE WHEN is_useful THEN 1 ELSE -1 END)`. +- Режиссёры: таблицы `directors` и `film_director`, у фильма — список режиссёров. +- Рекомендации фильмов: находим всех пользователей с максимальным пересечением + по лайкам и возвращаем их фильмы, которых нет у нашего. + Запрос фильмов живёт в FilmDbStorage, поэтому жанры и режиссёры + в ответе подтягиваются тем же путём, что и у остальных списочных запросов. +- Все хранилища покрыты интеграционными тестами (`@JdbcTest`). +
\ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java index 4dbfac8..7a4718e 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java @@ -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; @@ -79,6 +80,12 @@ public Collection getCommonFriends( return userService.getCommonFriends(id, otherId); } + @GetMapping("/{id}/recommendations") + public Collection getRecommendations(@PathVariable long id) { + log.info("GET /users/{}/recommendations", id); + return userService.getRecommendations(id); + } + @GetMapping("/{id}/feed") public List getFeed(@PathVariable long id) { log.info("GET /users/{}/feed", id); diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java index baa3a43..bd736dc 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java @@ -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; @@ -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; } @@ -128,4 +134,9 @@ public Collection findAll() { public User findById(long id) { return getUserOrThrow(id); } + + public Collection getRecommendations(long userId) { + getUserOrThrow(userId); + return filmStorage.findRecommendations(userId); + } } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java index 787bd86..860d997 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java @@ -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 ?"; // Условия фильтрации популярных фильмов: по жанру и по году выпуска @@ -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; @@ -204,6 +227,39 @@ public List findCommon(long userId, long friendId) { return films; } + @Override + public List findRecommendations(long userId) { + Integer maxOverlap = jdbc.queryForObject( + FIND_MAX_OVERLAP_QUERY, + Integer.class, + userId, userId + ); + if (maxOverlap == null) { + return List.of(); + } + + List 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 params = new ArrayList<>(similarUserIds); + params.add(userId); // исключаем фильмы, которые уже лайкнул наш пользователь + + List films = jdbc.query(sql, mapper, params.toArray()); + // Жанры и режиссёры подтягиваются тем же путём, что и у остальных списочных запросов + loadGenresForFilms(films); + loadDirectors(films); + return films; + } + @Override public void addLike(long filmId, long userId) { jdbc.update(ADD_LIKE_QUERY, filmId, userId); diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java index 62f4a02..7045473 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java @@ -22,6 +22,8 @@ public interface FilmStorage { List findCommon(long userId, long friendId); + List findRecommendations(long userId); + void addLike(long filmId, long userId); boolean removeLike(long filmId, long userId); diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java index 8a786fd..cfaf0f5 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java @@ -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; @@ -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 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 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 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");