From 9467c77de86cca3e5932a6a255ea3e93fae725f1 Mon Sep 17 00:00:00 2001 From: Kinza Date: Fri, 9 Oct 2026 01:03:59 +0300 Subject: [PATCH 1/5] feat: add film recommendations based on similar users --- README.md | 44 +++++++++- .../filmorate/controller/UserController.java | 7 ++ .../filmorate/service/UserService.java | 6 ++ .../filmorate/storage/user/UserDbStorage.java | 38 ++++++++- .../filmorate/storage/user/UserStorage.java | 4 + .../storage/user/UserDbStorageTest.java | 85 ++++++++++++++++++- 6 files changed, 180 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fe5e661..ffdd30c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ - Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья. - Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков. - Отзывы к фильмам: положительные и негативные, с рейтингом полезности по лайкам и дизлайкам. +- Рекомендации фильмов: пользователю предлагаются фильмы, которые лайкнул пользователь + с максимальным пересечением по лайкам. - Слоистая архитектура: контроллер → сервис → хранилище, выбор реализации хранилища через `@Qualifier`. - Внедрение зависимостей через конструктор (`@Autowired`). - Строгая валидация входящих данных через `spring-boot-starter-validation`. @@ -86,6 +88,7 @@ - `DELETE /users/{id}/friends/{friendId}` — удалить из друзей. - `GET /users/{id}/friends` — список друзей пользователя. - `GET /users/{id}/friends/common/{otherId}` — общие друзья с другим пользователем. +- `GET /users/{id}/recommendations` — рекомендации фильмов на основе похожих пользователей. ### Отзывы (`/reviews`) - `POST /reviews` — добавить отзыв. @@ -232,6 +235,29 @@ JOIN friendship f2 ON u.id = f2.friend_id AND f2.user_id = 2 ORDER BY u.id; ``` +Рекомендации для пользователя с id = 1: фильмы самого похожего пользователя, +которых нет у нашего: + +```sql +-- Шаг 1: самый похожий по пересечению лайков +SELECT fl2.user_id, 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 +ORDER BY common DESC, fl2.user_id +LIMIT 1; + +-- Шаг 2: его фильмы, которых нет у пользователя 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 = + AND f.id NOT IN (SELECT film_id FROM film_likes WHERE user_id = 1) +ORDER BY f.id; +``` + ## Реализация по этапам @@ -274,7 +300,7 @@ API доведён до соответствия REST. -
+
Этап 3 — База данных H2, DAO, жанры и рейтинги На этом этапе данные переехали из памяти в базу данных, @@ -297,4 +323,20 @@ API доведён до соответствия REST. - При создании и обновлении фильма проверяются рейтинг и жанры: неизвестный id — 404. - Интеграционные тесты всех хранилищ (`@JdbcTest`, база в памяти); unit-тесты сервисов обновлены. +
+ +
+Этап 4 — Отзывы, режиссёры, рекомендации + +На этом этапе добавлены отзывы с рейтингом полезности, режиссёры фильмов +и простая рекомендательная система. + +**Что сделано:** +- Отзывы: таблицы `reviews` и `review_likes`; рейтинг полезности считается в SQL + через `SUM(CASE WHEN is_useful THEN 1 ELSE -1 END)`. +- Режиссёры: таблицы `directors` и `film_director`, у фильма — список режиссёров. +- Рекомендации фильмов: два SQL-запроса — поиск самого похожего пользователя + по пересечению лайков и его фильмы, которых нет у пользователя. +- Все эндпоинты покрыты интеграционными тестами (`@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 8c154dc..19759a3 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.User; import ru.yandex.practicum.filmorate.service.UserService; @@ -76,4 +77,10 @@ public Collection getCommonFriends( log.info("GET /users/{}/friends/common/{}", id, otherId); return userService.getCommonFriends(id, otherId); } + + @GetMapping("/{id}/recommendations") + public Collection getRecommendations(@PathVariable long id) { + log.info("GET /users/{}/recommendations", id); + return userService.getRecommendations(id); + } } \ No newline at end of file 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 cde4926..409248a 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java @@ -6,6 +6,7 @@ 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.Film; import ru.yandex.practicum.filmorate.model.User; import ru.yandex.practicum.filmorate.storage.user.UserStorage; @@ -115,4 +116,9 @@ public Collection findAll() { public User findById(long id) { return getUserOrThrow(id); } + + public Collection getRecommendations(long userId) { + getUserOrThrow(userId); + return userStorage.findRecommendations(userId); + } } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorage.java index 076ec76..6d38e4e 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorage.java @@ -6,7 +6,9 @@ import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; import java.sql.Date; @@ -14,6 +16,7 @@ import java.sql.Statement; import java.util.Collection; import java.util.Optional; +import java.util.List; @Repository public class UserDbStorage implements UserStorage { @@ -42,13 +45,31 @@ public class UserDbStorage implements UserStorage { + "JOIN friendship f2 ON u.id = f2.friend_id AND f2.user_id = ? " + "ORDER BY u.id"; + private static final String FIND_MOST_SIMILAR_USER_QUERY = + "SELECT fl2.user_id AS other_id, 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 " + + "ORDER BY common DESC, fl2.user_id " + + "LIMIT 1"; + private static final String FIND_RECOMMENDATIONS_QUERY = + "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 = ? " + + "AND f.id NOT IN (SELECT film_id FROM film_likes WHERE user_id = ?) " + + "ORDER BY f.id"; private final JdbcTemplate jdbc; private final UserRowMapper mapper; + private final FilmRowMapper filmMapper; @Autowired - public UserDbStorage(JdbcTemplate jdbc, UserRowMapper mapper) { + public UserDbStorage(JdbcTemplate jdbc, UserRowMapper mapper, FilmRowMapper filmMapper) { this.jdbc = jdbc; this.mapper = mapper; + this.filmMapper = filmMapper; } @Override @@ -114,4 +135,19 @@ public Collection findFriends(long userId) { public Collection findCommonFriends(long userId, long otherId) { return jdbc.query(FIND_COMMON_FRIENDS_QUERY, mapper, userId, otherId); } + + @Override + public List findRecommendations(long userId) { + List similarUserIds = jdbc.query( + FIND_MOST_SIMILAR_USER_QUERY, + (rs, rowNum) -> rs.getLong("other_id"), + userId, userId + ); + if (similarUserIds.isEmpty()) { + return List.of(); + } + + Long similarUserId = similarUserIds.getFirst(); + return jdbc.query(FIND_RECOMMENDATIONS_QUERY, filmMapper, similarUserId, userId); + } } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java index f44bea4..b764df4 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java @@ -1,8 +1,10 @@ package ru.yandex.practicum.filmorate.storage.user; +import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.User; import java.util.Collection; +import java.util.List; import java.util.Optional; public interface UserStorage { @@ -24,4 +26,6 @@ public interface UserStorage { Collection findFriends(long userId); Collection findCommonFriends(long userId, long otherId); + + List findRecommendations(long userId); } \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java index 24adb41..16ba8c9 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java @@ -6,7 +6,11 @@ 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.User; +import ru.yandex.practicum.filmorate.storage.film.FilmDbStorage; +import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper; +import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; import java.time.LocalDate; @@ -15,16 +19,20 @@ import static org.junit.jupiter.api.Assertions.*; // Поднимается только база и JdbcTemplate, хранилище и маппер подключаем сами +// FilmDbStorage нужен для лайков, на которых строятся рекомендации. @JdbcTest @AutoConfigureTestDatabase -@Import({UserDbStorage.class, UserRowMapper.class}) +@Import({UserDbStorage.class, UserRowMapper.class, FilmRowMapper.class, + FilmDbStorage.class, GenreRowMapper.class}) class UserDbStorageTest { private final UserDbStorage userStorage; + private final FilmDbStorage filmStorage; @Autowired - UserDbStorageTest(UserDbStorage userStorage) { + UserDbStorageTest(UserDbStorage userStorage, FilmDbStorage filmStorage) { this.userStorage = userStorage; + this.filmStorage = filmStorage; } // Пользователей в тестовой базе нет: создаём сами, id берём из того, что вернул add @@ -37,6 +45,19 @@ private User createUser(String login) { return userStorage.add(user); } + private Film createFilm(String name) { + Film film = new Film(); + film.setName(name); + film.setDescription("Описание"); + film.setReleaseDate(LocalDate.of(2000, 1, 1)); + film.setDuration(120); + return filmStorage.add(film); + } + + private void likeFilm(Film film, User user) { + filmStorage.addLike(film.getId(), user.getId()); + } + @Test void shouldAddUserAndFindById() { User user = createUser("anna"); @@ -132,4 +153,64 @@ void shouldFindCommonFriends() { assertEquals(List.of(common), userStorage.findCommonFriends(anna.getId(), boris.getId())); } + + @Test + void shouldReturnRecommendationsFromMostSimilarUser() { + User anna = createUser("anna"); + User boris = createUser("boris"); + Film first = createFilm("Первый"); + Film second = createFilm("Второй"); + userStorage.addFriend(anna.getId(), boris.getId()); + likeFilm(first, anna); + likeFilm(first, boris); + likeFilm(second, boris); + + List recommendations = userStorage.findRecommendations(anna.getId()); + + assertEquals(List.of(second.getId()), + recommendations.stream().map(Film::getId).toList()); + } + + @Test + void shouldReturnEmptyRecommendationsWhenNoSimilarUsers() { + User anna = createUser("anna"); + createUser("boris"); + Film film = createFilm("Фильм"); + likeFilm(film, anna); + + assertTrue(userStorage.findRecommendations(anna.getId()).isEmpty()); + } + + @Test + void shouldReturnEmptyWhenSimilarUserLikedNothingNew() { + User anna = createUser("anna"); + User boris = createUser("boris"); + Film film = createFilm("Фильм"); + likeFilm(film, anna); + likeFilm(film, boris); + + assertTrue(userStorage.findRecommendations(anna.getId()).isEmpty()); + } + + @Test + void shouldPickUserWithLargestOverlap() { + User anna = createUser("anna"); + User boris = createUser("boris"); + User vera = createUser("vera"); + Film first = createFilm("Первый"); + Film second = createFilm("Второй"); + Film third = createFilm("Третий"); + + likeFilm(first, anna); + likeFilm(second, anna); + likeFilm(first, boris); + likeFilm(second, boris); + likeFilm(third, boris); + likeFilm(first, vera); + + List recommendations = userStorage.findRecommendations(anna.getId()); + + assertEquals(List.of(third.getId()), + recommendations.stream().map(Film::getId).toList()); + } } \ No newline at end of file From 171a54934838b56a405ef7ec6abf71111d1b6bf7 Mon Sep 17 00:00:00 2001 From: Kinza Date: Fri, 9 Oct 2026 01:24:05 +0300 Subject: [PATCH 2/5] Trigger CI From e7b9ff1a9e395e1b85834ad214e288bddb293e6a Mon Sep 17 00:00:00 2001 From: Kinza Date: Sat, 10 Oct 2026 03:41:50 +0300 Subject: [PATCH 3/5] Include all users with max overlap in recommendations --- README.md | 2 +- .../filmorate/service/UserService.java | 8 +- .../filmorate/storage/film/FilmDbStorage.java | 57 ++++++++- .../filmorate/storage/film/FilmStorage.java | 2 + .../filmorate/storage/user/UserDbStorage.java | 38 +----- .../filmorate/storage/user/UserStorage.java | 4 - .../storage/film/FilmDbStorageTest.java | 117 ++++++++++++------ .../storage/user/UserDbStorageTest.java | 85 +------------ 8 files changed, 149 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index ffdd30c..842d7d9 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,6 @@ API доведён до соответствия REST. - Режиссёры: таблицы `directors` и `film_director`, у фильма — список режиссёров. - Рекомендации фильмов: два SQL-запроса — поиск самого похожего пользователя по пересечению лайков и его фильмы, которых нет у пользователя. -- Все эндпоинты покрыты интеграционными тестами (`@JdbcTest`). +- Все хранилища покрыты интеграционными тестами (`@JdbcTest`).
\ No newline at end of file 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 409248a..f48537b 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java @@ -8,6 +8,7 @@ import ru.yandex.practicum.filmorate.exception.ValidationException; import ru.yandex.practicum.filmorate.model.Film; 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; @@ -17,10 +18,13 @@ public class UserService { private final UserStorage userStorage; + private final FilmStorage filmStorage; @Autowired - public UserService(@Qualifier("userDbStorage") UserStorage userStorage) { + public UserService(@Qualifier("userDbStorage") UserStorage userStorage, + @Qualifier("filmDbStorage") FilmStorage filmStorage) { this.userStorage = userStorage; + this.filmStorage = filmStorage; } private void validateDifferentUsers(long userId, long otherId) { @@ -119,6 +123,6 @@ public User findById(long id) { public Collection getRecommendations(long userId) { getUserOrThrow(userId); - return userStorage.findRecommendations(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 5280bef..635c2bf 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 @@ -41,7 +41,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 ?"; // Условия фильтрации популярных фильмов: по жанру и по году выпуска @@ -77,6 +77,29 @@ public class FilmDbStorage implements FilmStorage { private static final String REMOVE_LIKE_QUERY = "DELETE FROM film_likes WHERE film_id = ? AND user_id = ?"; + // Рекомендации: максимум пересечений по лайкам среди всех пользователей + 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; @@ -168,6 +191,38 @@ 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); + 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 3e5ecd3..9d661be 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/main/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorage.java index 6d38e4e..076ec76 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorage.java @@ -6,9 +6,7 @@ import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; import ru.yandex.practicum.filmorate.exception.NotFoundException; -import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.User; -import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; import java.sql.Date; @@ -16,7 +14,6 @@ import java.sql.Statement; import java.util.Collection; import java.util.Optional; -import java.util.List; @Repository public class UserDbStorage implements UserStorage { @@ -45,31 +42,13 @@ public class UserDbStorage implements UserStorage { + "JOIN friendship f2 ON u.id = f2.friend_id AND f2.user_id = ? " + "ORDER BY u.id"; - private static final String FIND_MOST_SIMILAR_USER_QUERY = - "SELECT fl2.user_id AS other_id, 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 " - + "ORDER BY common DESC, fl2.user_id " - + "LIMIT 1"; - private static final String FIND_RECOMMENDATIONS_QUERY = - "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 = ? " - + "AND f.id NOT IN (SELECT film_id FROM film_likes WHERE user_id = ?) " - + "ORDER BY f.id"; private final JdbcTemplate jdbc; private final UserRowMapper mapper; - private final FilmRowMapper filmMapper; @Autowired - public UserDbStorage(JdbcTemplate jdbc, UserRowMapper mapper, FilmRowMapper filmMapper) { + public UserDbStorage(JdbcTemplate jdbc, UserRowMapper mapper) { this.jdbc = jdbc; this.mapper = mapper; - this.filmMapper = filmMapper; } @Override @@ -135,19 +114,4 @@ public Collection findFriends(long userId) { public Collection findCommonFriends(long userId, long otherId) { return jdbc.query(FIND_COMMON_FRIENDS_QUERY, mapper, userId, otherId); } - - @Override - public List findRecommendations(long userId) { - List similarUserIds = jdbc.query( - FIND_MOST_SIMILAR_USER_QUERY, - (rs, rowNum) -> rs.getLong("other_id"), - userId, userId - ); - if (similarUserIds.isEmpty()) { - return List.of(); - } - - Long similarUserId = similarUserIds.getFirst(); - return jdbc.query(FIND_RECOMMENDATIONS_QUERY, filmMapper, similarUserId, userId); - } } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java index b764df4..f44bea4 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java @@ -1,10 +1,8 @@ package ru.yandex.practicum.filmorate.storage.user; -import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.User; import java.util.Collection; -import java.util.List; import java.util.Optional; public interface UserStorage { @@ -26,6 +24,4 @@ public interface UserStorage { Collection findFriends(long userId); Collection findCommonFriends(long userId, long otherId); - - List findRecommendations(long userId); } \ No newline at end of file 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 bffa5f2..40d5a28 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 @@ -5,7 +5,6 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; 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.Genre; import ru.yandex.practicum.filmorate.model.Mpa; @@ -136,41 +135,6 @@ void shouldDeleteFilm() { assertTrue(filmStorage.findById(film.getId()).isEmpty()); } - // Без ON DELETE CASCADE удаление упало бы на внешних ключах film_genres и film_likes - @Test - void shouldDeleteFilmWithGenresAndLikes() { - Film film = newFilm("С жанром и лайком"); - film.getGenres().add(genre(1)); - film = filmStorage.add(film); - User anna = createUser("anna"); - filmStorage.addLike(film.getId(), anna.getId()); - - filmStorage.delete(film.getId()); - - assertTrue(filmStorage.findById(film.getId()).isEmpty()); - assertTrue(filmStorage.findPopular(10, null, null).isEmpty()); - } - - @Test - void shouldNotCountLikesOfDeletedUser() { - Film first = createFilm("Без лайков"); - Film liked = createFilm("С лайком"); - User boris = createUser("boris"); - filmStorage.addLike(liked.getId(), boris.getId()); - assertEquals(liked.getId(), filmStorage.findPopular(10, null, null).get(0).getId()); - - userStorage.delete(boris.getId()); - - // Лайков не осталось ни у кого — фильмы идут по порядку id - List ids = filmStorage.findPopular(10, null, null).stream().map(Film::getId).toList(); - assertEquals(List.of(first.getId(), liked.getId()), ids); - } - - @Test - void shouldThrowWhenDeletingUnknownFilm() { - assertThrows(NotFoundException.class, () -> filmStorage.delete(9999)); - } - @Test void shouldAddAndRemoveLike() { Film film = createFilm("Любимый"); @@ -292,4 +256,85 @@ 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()); + } } \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java index 16ba8c9..24adb41 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java @@ -6,11 +6,7 @@ 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.User; -import ru.yandex.practicum.filmorate.storage.film.FilmDbStorage; -import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper; -import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; import java.time.LocalDate; @@ -19,20 +15,16 @@ import static org.junit.jupiter.api.Assertions.*; // Поднимается только база и JdbcTemplate, хранилище и маппер подключаем сами -// FilmDbStorage нужен для лайков, на которых строятся рекомендации. @JdbcTest @AutoConfigureTestDatabase -@Import({UserDbStorage.class, UserRowMapper.class, FilmRowMapper.class, - FilmDbStorage.class, GenreRowMapper.class}) +@Import({UserDbStorage.class, UserRowMapper.class}) class UserDbStorageTest { private final UserDbStorage userStorage; - private final FilmDbStorage filmStorage; @Autowired - UserDbStorageTest(UserDbStorage userStorage, FilmDbStorage filmStorage) { + UserDbStorageTest(UserDbStorage userStorage) { this.userStorage = userStorage; - this.filmStorage = filmStorage; } // Пользователей в тестовой базе нет: создаём сами, id берём из того, что вернул add @@ -45,19 +37,6 @@ private User createUser(String login) { return userStorage.add(user); } - private Film createFilm(String name) { - Film film = new Film(); - film.setName(name); - film.setDescription("Описание"); - film.setReleaseDate(LocalDate.of(2000, 1, 1)); - film.setDuration(120); - return filmStorage.add(film); - } - - private void likeFilm(Film film, User user) { - filmStorage.addLike(film.getId(), user.getId()); - } - @Test void shouldAddUserAndFindById() { User user = createUser("anna"); @@ -153,64 +132,4 @@ void shouldFindCommonFriends() { assertEquals(List.of(common), userStorage.findCommonFriends(anna.getId(), boris.getId())); } - - @Test - void shouldReturnRecommendationsFromMostSimilarUser() { - User anna = createUser("anna"); - User boris = createUser("boris"); - Film first = createFilm("Первый"); - Film second = createFilm("Второй"); - userStorage.addFriend(anna.getId(), boris.getId()); - likeFilm(first, anna); - likeFilm(first, boris); - likeFilm(second, boris); - - List recommendations = userStorage.findRecommendations(anna.getId()); - - assertEquals(List.of(second.getId()), - recommendations.stream().map(Film::getId).toList()); - } - - @Test - void shouldReturnEmptyRecommendationsWhenNoSimilarUsers() { - User anna = createUser("anna"); - createUser("boris"); - Film film = createFilm("Фильм"); - likeFilm(film, anna); - - assertTrue(userStorage.findRecommendations(anna.getId()).isEmpty()); - } - - @Test - void shouldReturnEmptyWhenSimilarUserLikedNothingNew() { - User anna = createUser("anna"); - User boris = createUser("boris"); - Film film = createFilm("Фильм"); - likeFilm(film, anna); - likeFilm(film, boris); - - assertTrue(userStorage.findRecommendations(anna.getId()).isEmpty()); - } - - @Test - void shouldPickUserWithLargestOverlap() { - User anna = createUser("anna"); - User boris = createUser("boris"); - User vera = createUser("vera"); - Film first = createFilm("Первый"); - Film second = createFilm("Второй"); - Film third = createFilm("Третий"); - - likeFilm(first, anna); - likeFilm(second, anna); - likeFilm(first, boris); - likeFilm(second, boris); - likeFilm(third, boris); - likeFilm(first, vera); - - List recommendations = userStorage.findRecommendations(anna.getId()); - - assertEquals(List.of(third.getId()), - recommendations.stream().map(Film::getId).toList()); - } } \ No newline at end of file From 185d2ed78887354b63a011c3545a6a5d9d1b1ab1 Mon Sep 17 00:00:00 2001 From: Kinza Date: Sat, 10 Oct 2026 04:04:41 +0300 Subject: [PATCH 4/5] fix: missing brace after merge --- .../filmorate/controller/UserController.java | 5 +++-- .../practicum/filmorate/service/UserService.java | 11 ++++------- .../filmorate/storage/film/FilmDbStorageTest.java | 2 ++ 3 files changed, 9 insertions(+), 9 deletions(-) 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 8d46a93..7a4718e 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java @@ -4,8 +4,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; -import ru.yandex.practicum.filmorate.model.Film; 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; @@ -84,7 +84,8 @@ public Collection getCommonFriends( 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 4b15a08..bd736dc 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java @@ -6,9 +6,9 @@ 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.Film; 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; @@ -23,17 +23,14 @@ public class UserService { private final UserStorage userStorage; private final FilmStorage filmStorage; + private final FeedService feedService; @Autowired public UserService(@Qualifier("userDbStorage") UserStorage userStorage, - @Qualifier("filmDbStorage") FilmStorage filmStorage) { + @Qualifier("filmDbStorage") FilmStorage filmStorage, + FeedService feedService) { this.userStorage = userStorage; this.filmStorage = filmStorage; - private final FeedService feedService; - - @Autowired - public UserService(@Qualifier("userDbStorage") UserStorage userStorage, FeedService feedService) { - this.userStorage = userStorage; this.feedService = feedService; } 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 948f6ec..a40724d 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 @@ -348,6 +348,8 @@ void shouldUseAllSimilarUsersWithMaxOverlap() { // Оба фильма от похожих пользователей, порядок по id assertEquals(List.of(second.getId(), third.getId()), recommendations.stream().map(Film::getId).toList()); + } + @Test void shouldReturnAllFilmsByDirectorSortedByLikes() { Director director = createDirector("Christopher Nolan"); From 6b712022a5a816a133cf75e332c9e4fbb13fb01d Mon Sep 17 00:00:00 2001 From: Kinza Date: Sun, 11 Oct 2026 01:53:13 +0300 Subject: [PATCH 5/5] fix: after review --- README.md | 36 +++++++++++------- .../filmorate/storage/film/FilmDbStorage.java | 3 +- .../storage/film/FilmDbStorageTest.java | 38 ++++++++++++++++++- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a1cf20c..fcac98f 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ - Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья. - Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков. - Отзывы к фильмам: положительные и негативные, с рейтингом полезности по лайкам и дизлайкам. -- Рекомендации фильмов: пользователю предлагаются фильмы, которые лайкнул пользователь - с максимальным пересечением по лайкам. +- Рекомендации фильмов: пользователю предлагаются фильмы, которые лайкнули + пользователи с максимальным пересечением по лайкам (при равенстве — все + такие пользователи). - Лента событий пользователя: лайки, друзья и отзывы. - Слоистая архитектура: контроллер → сервис → хранилище, выбор реализации хранилища через `@Qualifier`. - Внедрение зависимостей через конструктор (`@Autowired`). @@ -272,26 +273,33 @@ JOIN friendship f2 ON u.id = f2.friend_id AND f2.user_id = 2 ORDER BY u.id; ``` -Рекомендации для пользователя с id = 1: фильмы самого похожего пользователя, -которых нет у нашего: +Рекомендации для пользователя с id = 1: фильмы всех пользователей +с максимальным пересечением по лайкам, которых нет у нашего: ```sql --- Шаг 1: самый похожий по пересечению лайков -SELECT fl2.user_id, COUNT(*) AS common -FROM film_likes fl1 +-- Шаг 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 -ORDER BY common DESC, fl2.user_id -LIMIT 1; +HAVING COUNT(*) = ; --- Шаг 2: его фильмы, которых нет у пользователя 1 +-- Шаг 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 = +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; ``` @@ -372,8 +380,10 @@ API доведён до соответствия REST. - Отзывы: таблицы `reviews` и `review_likes`; рейтинг полезности считается в SQL через `SUM(CASE WHEN is_useful THEN 1 ELSE -1 END)`. - Режиссёры: таблицы `directors` и `film_director`, у фильма — список режиссёров. -- Рекомендации фильмов: два SQL-запроса — поиск самого похожего пользователя - по пересечению лайков и его фильмы, которых нет у пользователя. +- Рекомендации фильмов: находим всех пользователей с максимальным пересечением + по лайкам и возвращаем их фильмы, которых нет у нашего. + Запрос фильмов живёт в FilmDbStorage, поэтому жанры и режиссёры + в ответе подтягиваются тем же путём, что и у остальных списочных запросов. - Все хранилища покрыты интеграционными тестами (`@JdbcTest`). \ 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 5d4eb5d..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 @@ -254,8 +254,9 @@ public List findRecommendations(long userId) { params.add(userId); // исключаем фильмы, которые уже лайкнул наш пользователь List films = jdbc.query(sql, mapper, params.toArray()); - // Жанры подтягиваются тем же путём, что и у остальных списочных запросов + // Жанры и режиссёры подтягиваются тем же путём, что и у остальных списочных запросов loadGenresForFilms(films); + loadDirectors(films); return films; } 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 a40724d..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 @@ -5,8 +5,9 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; import org.springframework.context.annotation.Import; -import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.exception.NotFoundException; 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; @@ -147,6 +148,41 @@ void shouldDeleteFilm() { assertTrue(filmStorage.findById(film.getId()).isEmpty()); } + // Без ON DELETE CASCADE удаление упало бы на внешних ключах film_genres и film_likes + @Test + void shouldDeleteFilmWithGenresAndLikes() { + Film film = newFilm("С жанром и лайком"); + film.getGenres().add(genre(1)); + film = filmStorage.add(film); + User anna = createUser("anna"); + filmStorage.addLike(film.getId(), anna.getId()); + + filmStorage.delete(film.getId()); + + assertTrue(filmStorage.findById(film.getId()).isEmpty()); + assertTrue(filmStorage.findPopular(10, null, null).isEmpty()); + } + + @Test + void shouldNotCountLikesOfDeletedUser() { + Film first = createFilm("Без лайков"); + Film liked = createFilm("С лайком"); + User boris = createUser("boris"); + filmStorage.addLike(liked.getId(), boris.getId()); + assertEquals(liked.getId(), filmStorage.findPopular(10, null, null).get(0).getId()); + + userStorage.delete(boris.getId()); + + // Лайков не осталось ни у кого — фильмы идут по порядку id + List ids = filmStorage.findPopular(10, null, null).stream().map(Film::getId).toList(); + assertEquals(List.of(first.getId(), liked.getId()), ids); + } + + @Test + void shouldThrowWhenDeletingUnknownFilm() { + assertThrows(NotFoundException.class, () -> filmStorage.delete(9999)); + } + @Test void shouldAddAndRemoveLike() { Film film = createFilm("Любимый");