diff --git a/README.md b/README.md
index 50b4159..3a007d4 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,145 @@
# java-filmorate
-Учебный backend-проект на Java и Spring Boot: сервис для работы с фильмами,
-оценками пользователей и топом популярных фильмов.
+Учебный backend-проект на Java и Spring Boot: сервис для работы с фильмами, лайками,
+отзывами и рекомендациями. Данные хранятся в реляционной базе H2, доступ к ним — через `JdbcTemplate`.
-Проект развивается поэтапно. На текущем этапе данные хранятся в реляционной базе H2,
-доступ к ним — через `JdbcTemplate`, у фильмов появились жанры и возрастной рейтинг MPA.
+## Команда
-## Автор
-**Ксения Пылькина** *(Ksenia Pylkina)*
+| Участник | GitHub | Зона ответственности |
+|---|---|---|
+| **Ксения Пылькина** *(тимлид)* | [ksupyl](https://github.com/ksupyl) | этапы 1–3; общие фильмы, удаление фильмов и пользователей |
+| **Кирилл Казанцев** | [Kirill-Kazantcev](https://github.com/Kirill-Kazantcev) | популярные фильмы по жанру и году, рекомендации |
+| **Роман Егоров** | [Roman32-gif](https://github.com/Roman32-gif) | режиссёры, поиск |
+| **Дарья Приндина** | [DariaPrindina](https://github.com/DariaPrindina) | отзывы, лента событий |
-## Ключевые возможности на данном этапе
+## Реализация по этапам
+
+Этапы 1–3 — индивидуальные спринты, этап 4 — групповой проект, собранный на их основе.
+
+
+Этап 1 — Каркас Spring Boot приложения и базовый REST API
+
+На этом этапе был заложен фундамент будущего сервиса. Проект переведён на Spring Boot,
+реализованы базовые модели данных и контроллеры.
+
+**Что сделано:**
+- Настроен `pom.xml`, подключены стартеры Spring Boot, Lombok и плагин Checkstyle.
+- Созданы модели `Film` и `User` с аннотациями Lombok (`@Data`).
+- Реализованы REST-контроллеры `FilmController` и `UserController`.
+- In-memory хранение данных с ручной генерацией ID.
+- Валидация через аннотации `jakarta.validation.constraints`.
+- Кастомная бизнес-валидация (дата релиза, пробелы в логине).
+- Логирование запросов и ошибок.
+- Unit-тесты для проверки логики валидации.
+
+
+
+
+Этап 2 — Слоистая архитектура, социальные функции, полный REST
+
+На этом этапе логика вынесена из контроллеров, добавлены друзья и лайки,
+API доведён до соответствия REST.
+
+**Что сделано:**
+- Созданы интерфейсы `FilmStorage` и `UserStorage` и их реализации
+ `InMemoryFilmStorage` и `InMemoryUserStorage` (`@Component`).
+- Созданы `FilmService` (`@Service`) и `UserService` (`@Service`)
+ с бизнес-логикой лайков и друзей.
+- Контроллеры переработаны: зависят от сервисов, внедрение через конструктор.
+- Сервисы зависят от интерфейсов хранилищ — легко заменить реализацию в будущем.
+- Добавлены все новые эндпоинты (друзья, лайки, топ фильмов, получение по ID).
+- Централизованная обработка ошибок через `@RestControllerAdvice`: 400, 404, 500.
+- Подключён Logbook для логирования HTTP-запросов и ответов.
+- Unit-тесты обновлены под новую архитектуру.
+
+
+
+
+Этап 3 — База данных H2, DAO, жанры и рейтинги
+
+На этом этапе данные переехали из памяти в базу данных,
+у фильмов появились жанры и возрастной рейтинг MPA.
-- CRUD-операции для `Film` и `User` (создание, обновление, получение по ID и списком).
+**Что сделано:**
+- Подключены H2 и `spring-boot-starter-jdbc`; схема базы — `schema.sql`, справочники — `data.sql`.
+- Спроектированы 7 таблиц: `films`, `users`, `mpa`, `genres`
+ и таблицы-связки `film_genres`, `film_likes`, `friendship`.
+- Созданы хранилища на `JdbcTemplate`: `FilmDbStorage`, `UserDbStorage`,
+ `GenreDbStorage`, `MpaDbStorage` и мапперы `RowMapper` для каждой сущности.
+- Хранилища жанров и рейтингов, как и остальные, скрыты за интерфейсами (`GenreStorage`, `MpaStorage`).
+- Сервисы переключены на хранилища в базе через `@Qualifier`;
+ in-memory реализации и их unit-тесты после перехода на базу удалены.
+- Добавлены модели `Genre` и `Mpa`, сервисы и контроллеры `/genres` и `/mpa`.
+- Жанры фильма сохраняются пакетно (`batchUpdate`), а для списка фильмов загружаются одним запросом
+ только по этим фильмам (`IN (...)`).
+- Дружба стала односторонней: одна запись в `friendship` — один пользователь добавил другого.
+- Популярные фильмы считаются в SQL: `LEFT JOIN` с лайками, `GROUP BY`, сортировка по числу лайков.
+- При создании и обновлении фильма проверяются рейтинг и жанры: неизвестный id — 404.
+- Интеграционные тесты всех хранилищ (`@JdbcTest`, база в памяти).
+
+
+
+
+Этап 4 — Групповой проект: восемь новых функций
+
+Команда из четырёх человек добавила восемь функций. Работали по GitHub Flow: задача на доске
+GitHub Projects, своя ветка на каждую функцию, pull request в `develop`, два апрува от команды
+и Squash and merge. Каждый pull request проверял CI с тестами Postman по своей функции.
+
+**Что сделано:**
+- **Популярные фильмы по жанру и году** *(Кирилл Казанцев)*: фильтры добавляются в `WHERE`
+ динамически; жанр проверяется подзапросом, чтобы фильм с несколькими жанрами не задваивался.
+- **Общие фильмы** *(Ксения Пылькина)*: тот же запрос, что у популярных, с условием
+ «фильм лайкнул пользователь» для каждого из двух id.
+- **Удаление фильмов и пользователей** *(Ксения Пылькина)*: связанные записи — лайки, жанры,
+ дружбу, отзывы, ленту — удаляет база через `ON DELETE CASCADE`.
+- **Режиссёры** *(Роман Егоров)*: таблицы `directors` и `film_director`, у фильма — список режиссёров,
+ фильмы режиссёра сортируются по году выпуска или по лайкам.
+- **Отзывы** *(Дарья Приндина)*: таблицы `reviews` и `review_likes`; рейтинг полезности
+ считается в SQL через `SUM(CASE WHEN is_useful THEN 1 ELSE -1 END)`.
+- **Лента событий** *(Дарья Приндина)*: таблица `feed_events`; событие пишут сервисы лайков,
+ друзей и отзывов в той же транзакции, что и само действие.
+- **Рекомендации** *(Кирилл Казанцев)*: находим всех пользователей с максимальным пересечением
+ по лайкам и возвращаем их фильмы, которых нет у нашего. Запрос фильмов живёт в `FilmDbStorage`,
+ поэтому жанры и режиссёры в ответе подтягиваются тем же путём, что и у остальных списочных запросов.
+- **Поиск** *(Роман Егоров)*: подстрока без учёта регистра в названии и/или имени режиссёра,
+ результаты по убыванию популярности.
+- Перед сдачей: ошибки клиента (нет обязательного параметра, неверный тип, битый JSON,
+ нет id при обновлении) отдают 400 вместо 500, неизвестный адрес — 404; записи в несколько
+ таблиц выполняются в транзакции; обработка ошибок покрыта тестами на `MockMvc`.
+- Все хранилища покрыты интеграционными тестами (`@JdbcTest`).
+
+
+
+## Ключевые возможности
+
+- CRUD-операции для фильмов, пользователей и режиссёров; при удалении фильма или пользователя
+ связанные записи удаляются каскадно.
- Хранение данных в базе H2 (файловый режим — данные переживают перезапуск),
доступ через `JdbcTemplate` и `RowMapper`.
- Справочники жанров и рейтингов MPA в базе, эндпоинты `/genres` и `/mpa`.
-- У фильма есть рейтинг и список жанров; жанры в ответе — без дублей, по порядку id.
+- У фильма есть рейтинг, список жанров и список режиссёров; в ответе — без дублей, по порядку id.
- Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья.
-- Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков.
+- Лайки фильмам и топ-N популярных фильмов с фильтрами по жанру и году выпуска.
+- Общие фильмы двух пользователей — те, что лайкнули оба.
+- Фильмы режиссёра с сортировкой по году выпуска или количеству лайков.
+- Поиск фильмов по подстроке в названии и/или имени режиссёра.
+- Отзывы к фильмам: положительные и негативные, с рейтингом полезности по лайкам и дизлайкам.
+- Рекомендации фильмов: пользователю предлагаются фильмы, которые лайкнули
+ пользователи с максимальным пересечением по лайкам (при равенстве — все
+ такие пользователи).
+- Лента событий пользователя: лайки, друзья и отзывы.
- Слоистая архитектура: контроллер → сервис → хранилище, выбор реализации хранилища через `@Qualifier`.
- Внедрение зависимостей через конструктор (`@Autowired`).
- Строгая валидация входящих данных через `spring-boot-starter-validation`.
- Кастомная бизнес-валидация (дата релиза, пробелы в логине, автозамена имени на логин,
- существование рейтинга и жанров).
-- Централизованная обработка ошибок (`@RestControllerAdvice`): 400, 404, 500.
+ существование рейтинга, жанров и режиссёров).
+- Записи сразу в несколько таблиц выполняются в одной транзакции (`@Transactional`).
+- Централизованная обработка ошибок (`@RestControllerAdvice`): ошибки клиента — 400, 404, 405 и 415,
+ непредвиденные — 500 с общим сообщением, подробности только в логе.
- Логирование HTTP-запросов и ответов через Logbook.
-- Unit-тесты бизнес-логики и интеграционные тесты хранилищ (`@JdbcTest`).
+- Интеграционные тесты хранилищ (`@JdbcTest`), тесты ленты событий и обработки ошибок
+ (`@SpringBootTest`, `MockMvc`).
## Технологии
@@ -37,7 +151,7 @@
- **H2 Database** (встроенная реляционная база данных)
- **Logbook 3.7.2** (логирование HTTP-запросов)
- **Lombok** (генерация boilerplate-кода и логгеров)
-- **JUnit 5**, **Spring Boot Test**, **Mockito** (unit- и интеграционные тесты)
+- **JUnit 5**, **Spring Boot Test** (`@JdbcTest`, `@SpringBootTest`, `MockMvc`)
- **Maven** (сборка проекта)
- **Checkstyle** (контроль стиля кода)
@@ -48,7 +162,7 @@
3. Дождаться загрузки зависимостей Maven.
4. Запустить метод `main` в классе `FilmorateApplication`.
-После запуска сервер будет доступен на порту **8080**.
+После запуска сервер будет доступен на порту **8080**. Тесты запускаются командой `mvn test`.
База данных создаётся автоматически при старте: таблицы — из `schema.sql`,
справочники жанров и рейтингов — из `data.sql`. Данные хранятся в папке `db`
@@ -67,19 +181,43 @@
- `GET /films/{id}` — получить фильм по ID.
- `POST /films` — добавить новый фильм.
- `PUT /films` — обновить существующий фильм.
+- `DELETE /films/{id}` — удалить фильм; связанные с ним записи удаляются каскадно.
- `PUT /films/{id}/like/{userId}` — поставить лайк фильму.
- `DELETE /films/{id}/like/{userId}` — убрать лайк.
-- `GET /films/popular?count={count}` — топ-N фильмов по лайкам (по умолчанию 10).
+- `GET /films/popular?count={count}&genreId={genreId}&year={year}` — топ-N фильмов по лайкам
+ (по умолчанию 10); жанр и год — необязательные фильтры.
+- `GET /films/common?userId={userId}&friendId={friendId}` — общие фильмы двух пользователей,
+ по убыванию популярности.
+- `GET /films/director/{directorId}?sortBy={year|likes}` — фильмы режиссёра по году выпуска
+ или по количеству лайков (по умолчанию `likes`).
+- `GET /films/search?query={query}&by={title,director}` — поиск по подстроке без учёта регистра
+ в названии и/или имени режиссёра, по убыванию популярности.
### Пользователи (`/users`)
- `GET /users` — получить список всех пользователей.
- `GET /users/{id}` — получить пользователя по ID.
- `POST /users` — создать нового пользователя.
- `PUT /users` — обновить данные пользователя.
+- `DELETE /users/{id}` — удалить пользователя; связанные с ним записи удаляются каскадно.
- `PUT /users/{id}/friends/{friendId}` — добавить в друзья (дружба односторонняя).
- `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`.
+
+### Отзывы (`/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` — получить список всех жанров.
@@ -89,6 +227,13 @@
- `GET /mpa` — получить список всех рейтингов.
- `GET /mpa/{id}` — получить рейтинг по ID.
+### Режиссёры (`/directors`)
+- `GET /directors` — получить список всех режиссёров.
+- `GET /directors/{id}` — получить режиссёра по ID.
+- `POST /directors` — создать нового режиссёра.
+- `PUT /directors` — обновить данные режиссёра.
+- `DELETE /directors/{id}` — удалить режиссёра.
+
## Схема базы данных
```mermaid
@@ -100,6 +245,13 @@ erDiagram
users ||--o{ film_likes : "user_id"
users ||--o{ friendship : "user_id"
users ||--o{ friendship : "friend_id"
+ films ||--o{film_director : "film_id"
+ directors ||--o{film_director : "director_id"
+ users ||--o{ reviews : "user_id"
+ films ||--o{ reviews : "film_id"
+ reviews ||--o{ review_likes : "review_id"
+ users ||--o{ review_likes : "user_id"
+ users ||--o{ feed_events : "user_id"
films {
BIGINT id PK
@@ -136,6 +288,34 @@ erDiagram
BIGINT user_id PK, FK
BIGINT friend_id PK, FK
}
+ directors {
+ BIGINT id PK
+ VARCHAR name
+ }
+ film_director {
+ BIGINT film_id PK, FK
+ BIGINT director_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 — дизлайк"
+ }
+ feed_events {
+ BIGINT event_id PK
+ BIGINT user_id FK
+ VARCHAR event_type "LIKE, REVIEW, FRIEND"
+ VARCHAR operation "ADD, REMOVE, UPDATE"
+ BIGINT entity_id "id фильма, друга или отзыва"
+ BIGINT created_at "время события, мс"
+ }
```
| Таблица | Что хранит |
@@ -147,6 +327,11 @@ erDiagram
| `film_genres` | связь «фильм — жанр», многие ко многим |
| `film_likes` | лайки: какой пользователь лайкнул какой фильм |
| `friendship` | дружба: одна строка — `user_id` добавил в друзья `friend_id`; подтверждённая (взаимная) дружба — две встречные строки |
+| `directors` | режиссёры |
+| `film_director` | связь "фильм — режиссёр", многие ко многим |
+| `reviews` | отзывы пользователей к фильмам; рейтинг полезности не хранится, а считается по `review_likes` |
+| `review_likes` | оценки отзывов: одна строка — один пользователь, лайк или дизлайк |
+| `feed_events` | лента: что пользователь лайкнул, кого добавил в друзья, какие отзывы написал |
У таблиц-связок составной первичный ключ, поэтому одна и та же пара не запишется дважды.
При удалении фильма или пользователя связанные с ним записи удаляются каскадно.
@@ -176,6 +361,21 @@ WHERE fg.film_id = 1
ORDER BY g.id;
```
+Поиск «нол» по имени режиссёра и названию, по убыванию популярности.
+`COUNT(DISTINCT ...)` — потому что у фильма с двумя режиссёрами каждый лайк иначе посчитался бы дважды:
+
+```sql
+SELECT f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name AS mpa_name
+FROM films f
+LEFT JOIN mpa m ON f.mpa_id = m.id
+LEFT JOIN film_director fd ON f.id = fd.film_id
+LEFT JOIN directors d ON fd.director_id = d.id
+LEFT JOIN film_likes fl ON f.id = fl.film_id
+WHERE LOWER(d.name) LIKE '%нол%' OR LOWER(f.name) LIKE '%нол%'
+GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name
+ORDER BY COUNT(DISTINCT fl.user_id) DESC, f.id;
+```
+
Друзья пользователя с id = 1 — те, кого он добавил:
```sql
@@ -196,69 +396,34 @@ JOIN friendship f2 ON u.id = f2.friend_id AND f2.user_id = 2
ORDER BY u.id;
```
-
-
-## Реализация по этапам
-
-
-Этап 1 — Каркас Spring Boot приложения и базовый REST API
-
-На этом этапе был заложен фундамент будущего сервиса. Проект переведён на Spring Boot,
-реализованы базовые модели данных и контроллеры.
-
-**Что сделано:**
-- Настроен `pom.xml`, подключены стартеры Spring Boot, Lombok и плагин Checkstyle.
-- Созданы модели `Film` и `User` с аннотациями Lombok (`@Data`).
-- Реализованы REST-контроллеры `FilmController` и `UserController`.
-- In-memory хранение данных с ручной генерацией ID.
-- Валидация через аннотации `jakarta.validation.constraints`.
-- Кастомная бизнес-валидация (дата релиза, пробелы в логине).
-- Логирование запросов и ошибок.
-- Unit-тесты для проверки логики валидации.
-
-
-
-
-Этап 2 — Слоистая архитектура, социальные функции, полный REST
-
-На этом этапе логика вынесена из контроллеров, добавлены друзья и лайки,
-API доведён до соответствия REST.
+Рекомендации для пользователя с id = 1: фильмы всех пользователей
+с максимальным пересечением по лайкам, которых нет у нашего:
-**Что сделано:**
-- Созданы интерфейсы `FilmStorage` и `UserStorage` и их реализации
- `InMemoryFilmStorage` и `InMemoryUserStorage` (`@Component`).
-- Созданы `FilmService` (`@Service`) и `UserService` (`@Service`)
- с бизнес-логикой лайков и друзей.
-- Контроллеры переработаны: зависят от сервисов, внедрение через конструктор.
-- Сервисы зависят от интерфейсов хранилищ — легко заменить реализацию в будущем.
-- Добавлены все новые эндпоинты (друзья, лайки, топ фильмов, получение по ID).
-- Централизованная обработка ошибок через `@RestControllerAdvice`: 400, 404, 500.
-- Подключён Logbook для логирования HTTP-запросов и ответов.
-- Unit-тесты обновлены под новую архитектуру.
+```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;
+```
-
-
-Этап 3 — База данных H2, DAO, жанры и рейтинги
-
-На этом этапе данные переехали из памяти в базу данных,
-у фильмов появились жанры и возрастной рейтинг MPA.
-
-**Что сделано:**
-- Подключены H2 и `spring-boot-starter-jdbc`; схема базы — `schema.sql`, справочники — `data.sql`.
-- Спроектированы 7 таблиц: `films`, `users`, `mpa`, `genres`
- и таблицы-связки `film_genres`, `film_likes`, `friendship`.
-- Созданы хранилища на `JdbcTemplate`: `FilmDbStorage`, `UserDbStorage`,
- `GenreDbStorage`, `MpaDbStorage` и мапперы `RowMapper` для каждой сущности.
-- Хранилища жанров и рейтингов, как и остальные, скрыты за интерфейсами (`GenreStorage`, `MpaStorage`).
-- Сервисы переключены на хранилища в базе через `@Qualifier`;
- in-memory реализации оставлены для unit-тестов.
-- Добавлены модели `Genre` и `Mpa`, сервисы и контроллеры `/genres` и `/mpa`.
-- Жанры фильма сохраняются пакетно (`batchUpdate`), а для списка фильмов загружаются одним запросом
- только по этим фильмам (`IN (...)`).
-- Дружба стала односторонней: одна запись в `friendship` — один пользователь добавил другого.
-- Популярные фильмы считаются в SQL: `LEFT JOIN` с лайками, `GROUP BY`, сортировка по числу лайков.
-- При создании и обновлении фильма проверяются рейтинг и жанры: неизвестный id — 404.
-- Интеграционные тесты всех хранилищ (`@JdbcTest`, база в памяти); unit-тесты сервисов обновлены.
-
-
\ No newline at end of file
diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java
new file mode 100644
index 0000000..cf4b906
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java
@@ -0,0 +1,53 @@
+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.Director;
+import ru.yandex.practicum.filmorate.service.DirectorService;
+
+import java.util.List;
+
+@Slf4j
+@RestController
+@RequestMapping("/directors")
+public class DirectorController {
+
+ private final DirectorService directorService;
+
+ @Autowired
+ public DirectorController(DirectorService directorService) {
+ this.directorService = directorService;
+ }
+
+ @GetMapping
+ public List getDirectors() {
+ log.info("GET /directors");
+ return directorService.getAllDirectors();
+ }
+
+ @GetMapping("/{id}")
+ public Director getDirectorById(@PathVariable long id) {
+ log.info("GET /directors/{}", id);
+ return directorService.getDirectorById(id);
+ }
+
+ @PostMapping
+ public Director createDirector(@Valid @RequestBody Director director) {
+ log.info("POST /directors, body:{}", director);
+ return directorService.addDirector(director);
+ }
+
+ @PutMapping
+ public Director updateDirector(@Valid @RequestBody Director director) {
+ log.info("PUT /directors, body:{}", director);
+ return directorService.updateDirector(director);
+ }
+
+ @DeleteMapping("/{id}")
+ public void deleteDirector(@PathVariable long id) {
+ log.info("DELETE /directors/{}", id);
+ directorService.deleteDirector(id);
+ }
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java
index 21c3dda..3eebc6b 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java
@@ -8,6 +8,7 @@
import ru.yandex.practicum.filmorate.service.FilmService;
import java.util.Collection;
+import java.util.List;
@RestController
@RequestMapping("/films")
@@ -45,6 +46,12 @@ public Film update(@Valid @RequestBody Film film) {
return filmService.update(film);
}
+ @DeleteMapping("/{id}")
+ public void delete(@PathVariable long id) {
+ log.info("DELETE /films/{}", id);
+ filmService.delete(id);
+ }
+
@PutMapping("/{id}/like/{userId}")
public Film addLike(@PathVariable long id, @PathVariable long userId) {
log.info("PUT /films/{}/like/{}", id, userId);
@@ -59,8 +66,30 @@ public Film removeLike(@PathVariable long id, @PathVariable long userId) {
@GetMapping("/popular")
public Collection getPopular(
- @RequestParam(defaultValue = "10") int count) {
- log.info("GET /films/popular?count={}", count);
- return filmService.getPopular(count);
+ @RequestParam(defaultValue = "10") int count,
+ @RequestParam(required = false) Integer genreId,
+ @RequestParam(required = false) Integer year) {
+ log.info("GET /films/popular?count={}&genreId={}&year={}", count, genreId, year);
+ return filmService.getPopular(count, genreId, year);
+ }
+
+ @GetMapping("/common")
+ public Collection getCommon(@RequestParam long userId, @RequestParam long friendId) {
+ log.info("GET /films/common?userId={}&friendId={}", userId, friendId);
+ return filmService.getCommon(userId, friendId);
+ }
+
+ @GetMapping("/director/{directorId}")
+ public Collection getFilmsByDirector(@PathVariable long directorId,
+ @RequestParam(defaultValue = "likes") String sortBy) {
+ log.info("GET /films/director/{}?sortBy={}", directorId, sortBy);
+ return filmService.findByDirectorSorted(directorId, sortBy);
+ }
+
+ @GetMapping("/search")
+ public Collection searchFilms(@RequestParam String query,
+ @RequestParam List by) {
+ log.info("GET /films/search?query={}&by={}", query, by);
+ return filmService.searchFilms(query, by);
}
}
\ No newline at end of file
diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java
new file mode 100644
index 0000000..e84423a
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java
@@ -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 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);
+ }
+}
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 ba0d0ec..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,10 +4,13 @@
import lombok.extern.slf4j.Slf4j;
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;
import java.util.Collection;
+import java.util.List;
@RestController
@RequestMapping("/users")
@@ -45,6 +48,12 @@ public User update(@Valid @RequestBody User user) {
return userService.update(user);
}
+ @DeleteMapping("/{id}")
+ public void delete(@PathVariable long id) {
+ log.info("DELETE /users/{}", id);
+ userService.delete(id);
+ }
+
@PutMapping("/{id}/friends/{friendId}")
public User addFriend(@PathVariable long id, @PathVariable long friendId) {
log.info("PUT /users/{}/friends/{}", id, friendId);
@@ -70,4 +79,16 @@ 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);
+ }
+
+ @GetMapping("/{id}/feed")
+ public List getFeed(@PathVariable long id) {
+ log.info("GET /users/{}/feed", id);
+ return userService.getFeed(id);
+ }
}
\ No newline at end of file
diff --git a/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java b/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java
index a7128b6..8ef6b47 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java
@@ -2,12 +2,19 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.HttpMediaTypeNotSupportedException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.Map;
+import java.util.stream.Collectors;
@RestControllerAdvice
@Slf4j
@@ -27,18 +34,70 @@ public Map handleNotFoundException(NotFoundException e) {
return Map.of("error", e.getMessage());
}
+ // Нарушены аннотации валидации (@NotBlank, @Positive и т.п.): в ответе — поле и что с ним не так
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
- public Map handleMethodArgumentNotValidException(
- MethodArgumentNotValidException e) {
+ public Map handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
+ String message = e.getBindingResult().getFieldErrors().stream()
+ .map(error -> error.getField() + ": " + error.getDefaultMessage())
+ .collect(Collectors.joining("; "));
+ log.warn("400: {}", message);
+ return Map.of("error", message);
+ }
+
+ // Нет обязательного параметра, например GET /films/search без by
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public Map handleMissingParameter(MissingServletRequestParameterException e) {
log.warn("400: {}", e.getMessage());
- return Map.of("error", e.getMessage());
+ return Map.of("error", "Не указан обязательный параметр '" + e.getParameterName() + "'");
+ }
+
+ // Параметр не того типа, например GET /films/abc вместо числового id
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public Map handleTypeMismatch(MethodArgumentTypeMismatchException e) {
+ log.warn("400: {}", e.getMessage());
+ return Map.of("error", "Некорректное значение параметра '" + e.getName() + "': " + e.getValue());
}
+ // Тело запроса не читается как JSON нужной структуры
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public Map handleNotReadableBody(HttpMessageNotReadableException e) {
+ log.warn("400: {}", e.getMessage());
+ return Map.of("error", "Некорректное тело запроса");
+ }
+
+ // Запрос на адрес, которого нет в API
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ public Map handleNoResource(NoResourceFoundException e) {
+ log.warn("404: {}", e.getMessage());
+ return Map.of("error", "Адрес /" + e.getResourcePath() + " не найден");
+ }
+
+ // Адрес есть, но с другим HTTP-методом, например POST /films/1
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
+ public Map handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
+ log.warn("405: {}", e.getMessage());
+ return Map.of("error", "Метод " + e.getMethod() + " для этого адреса не поддерживается");
+ }
+
+ // Тело пришло не в JSON, например с Content-Type: text/plain
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
+ public Map handleMediaTypeNotSupported(HttpMediaTypeNotSupportedException e) {
+ log.warn("415: {}", e.getMessage());
+ return Map.of("error", "Тело запроса должно быть в формате JSON");
+ }
+
+ // Непредвиденная ошибка: подробности только в лог, клиенту — общее сообщение без SQL и стектрейсов
@ExceptionHandler
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map handleException(Exception e) {
log.error("500: {}", e.getMessage(), e);
- return Map.of("error", e.getMessage());
+ return Map.of("error", "Внутренняя ошибка сервера");
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Director.java b/src/main/java/ru/yandex/practicum/filmorate/model/Director.java
new file mode 100644
index 0000000..3fe4162
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/model/Director.java
@@ -0,0 +1,15 @@
+package ru.yandex.practicum.filmorate.model;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+// Равенство по id, как у Genre и Mpa: дубль режиссёра в запросе схлопнется в один
+@Data
+@EqualsAndHashCode(of = "id")
+public class Director {
+ private Long id;
+
+ @NotBlank(message = "Имя режиссёра не может быть пустым")
+ private String name;
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Event.java b/src/main/java/ru/yandex/practicum/filmorate/model/Event.java
new file mode 100644
index 0000000..86331a3
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/model/Event.java
@@ -0,0 +1,13 @@
+package ru.yandex.practicum.filmorate.model;
+
+import lombok.Data;
+
+@Data
+public class Event {
+ private Long eventId;
+ private Long timestamp;
+ private Long userId;
+ private EventType eventType;
+ private Operation operation;
+ private Long entityId;
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/EventType.java b/src/main/java/ru/yandex/practicum/filmorate/model/EventType.java
new file mode 100644
index 0000000..5de71e3
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/model/EventType.java
@@ -0,0 +1,7 @@
+package ru.yandex.practicum.filmorate.model;
+
+public enum EventType {
+ LIKE,
+ REVIEW,
+ FRIEND
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Film.java b/src/main/java/ru/yandex/practicum/filmorate/model/Film.java
index 08a9aca..2b3a240 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/model/Film.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/model/Film.java
@@ -1,6 +1,5 @@
package ru.yandex.practicum.filmorate.model;
-import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
@@ -8,7 +7,6 @@
import lombok.Data;
import java.time.LocalDate;
-import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
@@ -34,15 +32,5 @@ public class Film {
private Mpa mpa;
private Set genres = new LinkedHashSet<>();
-
- @JsonIgnore
- private Set likes = new HashSet<>();
-
- public void addLike(Long userId) {
- likes.add(userId);
- }
-
- public boolean removeLike(Long userId) {
- return likes.remove(userId);
- }
+ private Set directors = new LinkedHashSet<>();
}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Operation.java b/src/main/java/ru/yandex/practicum/filmorate/model/Operation.java
new file mode 100644
index 0000000..797ccbd
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/model/Operation.java
@@ -0,0 +1,7 @@
+package ru.yandex.practicum.filmorate.model;
+
+public enum Operation {
+ ADD,
+ REMOVE,
+ UPDATE
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Review.java b/src/main/java/ru/yandex/practicum/filmorate/model/Review.java
new file mode 100644
index 0000000..e8a4c41
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/model/Review.java
@@ -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;
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/User.java b/src/main/java/ru/yandex/practicum/filmorate/model/User.java
index 86cda7c..33003b4 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/model/User.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/model/User.java
@@ -1,6 +1,5 @@
package ru.yandex.practicum.filmorate.model;
-import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@@ -8,8 +7,6 @@
import lombok.Data;
import java.time.LocalDate;
-import java.util.HashSet;
-import java.util.Set;
@Data
public class User {
@@ -27,15 +24,4 @@ public class User {
@NotNull(message = "Дата рождения не может быть пустой")
@PastOrPresent(message = "Дата рождения не может быть в будущем")
private LocalDate birthday;
-
- @JsonIgnore
- private Set friends = new HashSet<>();
-
- public void addFriend(Long friendId) {
- friends.add(friendId);
- }
-
- public boolean removeFriend(Long friendId) {
- return friends.remove(friendId);
- }
}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java b/src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java
new file mode 100644
index 0000000..4c7f827
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java
@@ -0,0 +1,46 @@
+package ru.yandex.practicum.filmorate.service;
+
+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.Director;
+import ru.yandex.practicum.filmorate.storage.director.DirectorStorage;
+
+import java.util.List;
+
+@Service
+public class DirectorService {
+
+ private final DirectorStorage directorStorage;
+
+ @Autowired
+ public DirectorService(@Qualifier("directorDbStorage") DirectorStorage directorStorage) {
+ this.directorStorage = directorStorage;
+ }
+
+ public Director addDirector(Director director) {
+ return directorStorage.addDirector(director);
+ }
+
+ public Director updateDirector(Director director) {
+ if (director.getId() == null) {
+ throw new ValidationException("Не указан id режиссёра");
+ }
+ return directorStorage.updateDirector(director);
+ }
+
+ public void deleteDirector(long id) {
+ directorStorage.removeDirector(id);
+ }
+
+ public Director getDirectorById(long id) {
+ return directorStorage.findDirectorById(id)
+ .orElseThrow(() -> new NotFoundException("Режиссёр с id=" + id + " не найден"));
+ }
+
+ public List getAllDirectors() {
+ return directorStorage.findAllDirectors();
+ }
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/FeedService.java b/src/main/java/ru/yandex/practicum/filmorate/service/FeedService.java
new file mode 100644
index 0000000..392d84a
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/service/FeedService.java
@@ -0,0 +1,36 @@
+package ru.yandex.practicum.filmorate.service;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import ru.yandex.practicum.filmorate.model.Event;
+import ru.yandex.practicum.filmorate.model.EventType;
+import ru.yandex.practicum.filmorate.model.Operation;
+import ru.yandex.practicum.filmorate.storage.feed.FeedStorage;
+
+import java.time.Instant;
+import java.util.List;
+
+@Service
+public class FeedService {
+
+ private final FeedStorage feedStorage;
+
+ @Autowired
+ public FeedService(FeedStorage feedStorage) {
+ this.feedStorage = feedStorage;
+ }
+
+ public void addEvent(long userId, EventType eventType, Operation operation, long entityId) {
+ Event event = new Event();
+ event.setTimestamp(Instant.now().toEpochMilli());
+ event.setUserId(userId);
+ event.setEventType(eventType);
+ event.setOperation(operation);
+ event.setEntityId(entityId);
+ feedStorage.add(event);
+ }
+
+ public List findByUserId(long userId) {
+ return feedStorage.findByUserId(userId);
+ }
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java
index 160de6e..6fa0537 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java
@@ -4,16 +4,22 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import ru.yandex.practicum.filmorate.exception.NotFoundException;
import ru.yandex.practicum.filmorate.exception.ValidationException;
+import ru.yandex.practicum.filmorate.model.Director;
+import ru.yandex.practicum.filmorate.model.EventType;
import ru.yandex.practicum.filmorate.model.Film;
import ru.yandex.practicum.filmorate.model.Genre;
+import ru.yandex.practicum.filmorate.model.Operation;
import ru.yandex.practicum.filmorate.storage.film.FilmStorage;
import ru.yandex.practicum.filmorate.storage.user.UserStorage;
import java.time.LocalDate;
import java.util.Collection;
import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
import java.util.Set;
@Service
@@ -30,16 +36,22 @@ public class FilmService {
// Нужны для проверки рейтинга и жанра из запроса
private final MpaService mpaService;
private final GenreService genreService;
+ private final DirectorService directorService;
+ private final FeedService feedService;
@Autowired
public FilmService(@Qualifier("filmDbStorage") FilmStorage filmStorage,
@Qualifier("userDbStorage") UserStorage userStorage,
MpaService mpaService,
- GenreService genreService) {
+ GenreService genreService,
+ DirectorService directorService,
+ FeedService feedService) {
this.filmStorage = filmStorage;
this.userStorage = userStorage;
this.mpaService = mpaService;
this.genreService = genreService;
+ this.directorService = directorService;
+ this.feedService = feedService;
}
// Получение фильма по id с проверкой существования
@@ -54,15 +66,19 @@ private void getUserOrThrow(long id) {
.orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден"));
}
+ // Лайк и событие в ленте пишутся вместе: либо оба, либо ни одного
+ @Transactional
public Film addLike(long filmId, long userId) {
Film film = getFilmOrThrow(filmId);
getUserOrThrow(userId); // проверка существования пользователя — иначе 404
filmStorage.addLike(filmId, userId);
+ feedService.addEvent(userId, EventType.LIKE, Operation.ADD, filmId);
log.debug("Пользователь id={} поставил лайк фильму id={}", userId, filmId);
return film;
}
+ @Transactional
public Film removeLike(long filmId, long userId) {
Film film = getFilmOrThrow(filmId);
getUserOrThrow(userId); // проверка существования пользователя — иначе 404
@@ -73,18 +89,36 @@ public Film removeLike(long filmId, long userId) {
);
}
+ feedService.addEvent(userId, EventType.LIKE, Operation.REMOVE, filmId);
log.debug("Пользователь id={} убрал лайк с фильма id={}", userId, filmId);
return film;
}
- public Collection getPopular(int count) {
+ public Collection getPopular(int count, Integer genreId, Integer year) {
// Валидация: count должен быть положительным числом
if (count <= 0) {
throw new ValidationException(
"Количество фильмов должно быть положительным числом, получено: " + count
);
}
- return filmStorage.findPopular(count);
+ // Жанр, если указан, должен существовать — иначе 404
+ if (genreId != null) {
+ genreService.findById(genreId);
+ }
+ // Валидация: год, если указан, должен быть положительным
+ if (year != null && year <= 0) {
+ throw new ValidationException(
+ "Год должен быть положительным числом, получено: " + year
+ );
+ }
+ return filmStorage.findPopular(count, genreId, year);
+ }
+
+ // Общие фильмы двух пользователей — те, что лайкнули оба, по убыванию популярности
+ public Collection getCommon(long userId, long friendId) {
+ getUserOrThrow(userId); // оба пользователя должны существовать — иначе 404
+ getUserOrThrow(friendId);
+ return filmStorage.findCommon(userId, friendId);
}
// Валидация даты релиза — не может быть раньше дня рождения кино
@@ -100,6 +134,9 @@ private void validateReleaseDate(Film film) {
// Рейтинг и жанры из запроса должны существовать в справочниках — иначе 404
private void validateMpaAndGenres(Film film) {
if (film.getMpa() != null) {
+ if (film.getMpa().getId() == null) {
+ throw new ValidationException("Не указан id рейтинга MPA");
+ }
mpaService.findById(film.getMpa().getId()); // бросит NotFoundException
}
if (film.getGenres() == null || film.getGenres().isEmpty()) {
@@ -111,25 +148,58 @@ private void validateMpaAndGenres(Film film) {
knownIds.add(genre.getId());
}
for (Genre genre : film.getGenres()) {
+ if (genre.getId() == null) {
+ throw new ValidationException("Не указан id жанра");
+ }
if (!knownIds.contains(genre.getId())) {
throw new NotFoundException("Жанр с id=" + genre.getId() + " не найден");
}
}
}
+ // Режиссёры из запроса должны существовать — иначе 404
+ private void validateDirectors(Film film) {
+ if (film.getDirectors() == null || film.getDirectors().isEmpty()) {
+ return;
+ }
+ Set knownIds = new HashSet<>();
+ for (Director director : directorService.getAllDirectors()) {
+ knownIds.add(director.getId());
+ }
+ for (Director director : film.getDirectors()) {
+ if (director.getId() == null) {
+ throw new ValidationException("Не указан id режиссёра");
+ }
+ if (!knownIds.contains(director.getId())) {
+ throw new NotFoundException("Режиссёр с id=" + director.getId() + " не найден");
+ }
+ }
+ }
+
public Film add(Film film) {
validateReleaseDate(film);
validateMpaAndGenres(film);
+ validateDirectors(film);
return filmStorage.add(film);
}
public Film update(Film film) {
+ if (film.getId() == null) {
+ throw new ValidationException("Не указан id фильма");
+ }
getFilmOrThrow(film.getId()); // проверка существования — иначе 404
validateReleaseDate(film);
validateMpaAndGenres(film);
+ validateDirectors(film);
return filmStorage.update(film);
}
+ // Лайки, жанры и прочие связи фильма база удаляет сама — каскадом из schema.sql
+ public void delete(long id) {
+ filmStorage.delete(id); // нет фильма — хранилище бросит NotFoundException, это 404
+ log.debug("Фильм id={} удалён", id);
+ }
+
public Collection findAll() {
return filmStorage.findAll();
}
@@ -137,4 +207,35 @@ public Collection findAll() {
public Film findById(long id) {
return getFilmOrThrow(id);
}
+
+ // Сортировка без учёта регистра: year или likes, остальное — 400
+ public Collection findByDirectorSorted(long directorId, String sortBy) {
+ String sort = sortBy.toLowerCase(Locale.ROOT);
+ if (!sort.equals("year") && !sort.equals("likes")) {
+ throw new ValidationException("Сортировка возможна только по year или likes, получено: " + sortBy);
+ }
+ directorService.getDirectorById(directorId); // проверка существования режиссёра — иначе 404
+ return filmStorage.findFilmByDirector(directorId, sort);
+ }
+
+ private void validateSearchParameters(String query, List by) {
+ if (query == null || query.isBlank()) {
+ throw new ValidationException("Параметр 'query' не может быть пустым");
+ }
+ if (by == null || by.isEmpty()) {
+ throw new ValidationException("Параметр 'by' не может быть пустым");
+ }
+ for (String field : by) {
+ if (field == null || !field.equalsIgnoreCase("title") && !field.equalsIgnoreCase("director")) {
+ throw new ValidationException("Недопустимое значение в параметре 'by': " + field);
+ }
+ }
+ }
+
+ public Collection searchFilms(String query, List by) {
+ validateSearchParameters(query, by);
+ // Проверка выше не учитывает регистр, а хранилище ждёт title и director строчными
+ List fields = by.stream().map(field -> field.toLowerCase(Locale.ROOT)).toList();
+ return filmStorage.searchFilms(query, fields);
+ }
}
\ No newline at end of file
diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java b/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java
new file mode 100644
index 0000000..2df51b0
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java
@@ -0,0 +1,132 @@
+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 org.springframework.transaction.annotation.Transactional;
+import ru.yandex.practicum.filmorate.exception.NotFoundException;
+import ru.yandex.practicum.filmorate.exception.ValidationException;
+import ru.yandex.practicum.filmorate.model.EventType;
+import ru.yandex.practicum.filmorate.model.Operation;
+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;
+ private final FeedService feedService;
+
+ @Autowired
+ public ReviewService(ReviewStorage reviewStorage,
+ @Qualifier("filmDbStorage") FilmStorage filmStorage,
+ @Qualifier("userDbStorage") UserStorage userStorage,
+ FeedService feedService) {
+ this.reviewStorage = reviewStorage;
+ this.filmStorage = filmStorage;
+ this.userStorage = userStorage;
+ this.feedService = feedService;
+ }
+
+ 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 + " не найден"));
+ }
+
+ // Отзыв и событие в ленте пишутся вместе: либо оба, либо ни одного
+ @Transactional
+ public Review add(Review review) {
+ checkUserExists(review.getUserId());
+ checkFilmExists(review.getFilmId());
+ Review created = reviewStorage.add(review);
+ feedService.addEvent(created.getUserId(), EventType.REVIEW, Operation.ADD, created.getReviewId());
+ log.debug("Пользователь id={} оставил отзыв id={} к фильму id={}",
+ created.getUserId(), created.getReviewId(), created.getFilmId());
+ return created;
+ }
+
+ // Автор, фильм и рейтинг полезности не меняются — только текст и тип отзыва
+ @Transactional
+ public Review update(Review review) {
+ if (review.getReviewId() == null) {
+ throw new ValidationException("Не указан id отзыва");
+ }
+ getReviewOrThrow(review.getReviewId());
+ Review updated = reviewStorage.update(review);
+ feedService.addEvent(updated.getUserId(), EventType.REVIEW, Operation.UPDATE, updated.getReviewId());
+ return updated;
+ }
+
+ @Transactional
+ public void delete(long id) {
+ Review review = getReviewOrThrow(id);
+ reviewStorage.delete(id);
+ feedService.addEvent(review.getUserId(), EventType.REVIEW, Operation.REMOVE, id);
+ log.debug("Отзыв id={} удалён", id);
+ }
+
+ public Review findById(long id) {
+ return getReviewOrThrow(id);
+ }
+
+ public Collection 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);
+ }
+
+ 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);
+ }
+}
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 640cf50..8c13bd2 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java
@@ -4,22 +4,35 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import ru.yandex.practicum.filmorate.exception.NotFoundException;
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;
+import java.util.List;
@Service
@Slf4j
public class UserService {
private final UserStorage userStorage;
+ private final FilmStorage filmStorage;
+ private final FeedService feedService;
@Autowired
- public UserService(@Qualifier("userDbStorage") UserStorage userStorage) {
+ public UserService(@Qualifier("userDbStorage") UserStorage userStorage,
+ @Qualifier("filmDbStorage") FilmStorage filmStorage,
+ FeedService feedService) {
this.userStorage = userStorage;
+ this.filmStorage = filmStorage;
+ this.feedService = feedService;
}
private void validateDifferentUsers(long userId, long otherId) {
@@ -36,7 +49,9 @@ private User getUserOrThrow(long id) {
.orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден"));
}
- // Дружба односторонняя: обратная запись для friendId не создаётся
+ // Дружба односторонняя: обратная запись для friendId не создаётся.
+ // Дружба и событие в ленте пишутся вместе: либо оба, либо ни одного
+ @Transactional
public User addFriend(long userId, long friendId) {
validateDifferentUsers(userId, friendId);
@@ -44,10 +59,12 @@ public User addFriend(long userId, long friendId) {
getUserOrThrow(friendId); // проверка существования друга — иначе 404
userStorage.addFriend(userId, friendId);
+ feedService.addEvent(userId, EventType.FRIEND, Operation.ADD, friendId);
log.debug("Пользователь id={} добавил в друзья id={}", userId, friendId);
return user;
}
+ @Transactional
public User removeFriend(long userId, long friendId) {
validateDifferentUsers(userId, friendId);
@@ -55,6 +72,7 @@ public User removeFriend(long userId, long friendId) {
getUserOrThrow(friendId); // проверка существования друга — иначе 404
userStorage.removeFriend(userId, friendId);
+ feedService.addEvent(userId, EventType.FRIEND, Operation.REMOVE, friendId);
log.debug("Пользователь id={} удалил из друзей id={}", userId, friendId);
return user;
}
@@ -72,6 +90,11 @@ public Collection getCommonFriends(long userId, long otherId) {
return userStorage.findCommonFriends(userId, otherId);
}
+ public List getFeed(long userId) {
+ getUserOrThrow(userId);
+ return feedService.findByUserId(userId);
+ }
+
// Логин не может содержать пробелы
private void validateLogin(User user) {
if (user.getLogin().contains(" ")) {
@@ -95,6 +118,9 @@ public User add(User user) {
}
public User update(User user) {
+ if (user.getId() == null) {
+ throw new ValidationException("Не указан id пользователя");
+ }
getUserOrThrow(user.getId());
validateLogin(user);
@@ -102,6 +128,12 @@ public User update(User user) {
return userStorage.update(user);
}
+ // Дружбу, лайки и прочие связи пользователя база удаляет сама — каскадом из schema.sql
+ public void delete(long id) {
+ userStorage.delete(id); // нет пользователя — хранилище бросит NotFoundException, это 404
+ log.debug("Пользователь id={} удалён", id);
+ }
+
public Collection findAll() {
return userStorage.findAll();
}
@@ -109,4 +141,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/director/DirectorDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorage.java
new file mode 100644
index 0000000..f510957
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorage.java
@@ -0,0 +1,73 @@
+package ru.yandex.practicum.filmorate.storage.director;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+import ru.yandex.practicum.filmorate.exception.NotFoundException;
+import ru.yandex.practicum.filmorate.model.Director;
+import ru.yandex.practicum.filmorate.storage.mapper.DirectorRowMapper;
+
+import java.sql.PreparedStatement;
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public class DirectorDbStorage implements DirectorStorage {
+
+ private static final String INSERT_QUERY = "INSERT INTO directors (name) VALUES (?)";
+ private static final String UPDATE_QUERY = "UPDATE directors SET name = ? WHERE id = ?";
+ private static final String DELETE_QUERY = "DELETE FROM directors WHERE id = ?";
+ private static final String SELECT_BY_ID_QUERY = "SELECT id, name FROM directors WHERE id = ?";
+ private static final String SELECT_ALL_QUERY = "SELECT id, name FROM directors ORDER BY id";
+
+ private final JdbcTemplate jdbcTemplate;
+ private final DirectorRowMapper directorRowMapper;
+
+ @Autowired
+ public DirectorDbStorage(JdbcTemplate jdbcTemplate, DirectorRowMapper directorRowMapper) {
+ this.jdbcTemplate = jdbcTemplate;
+ this.directorRowMapper = directorRowMapper;
+ }
+
+ @Override
+ public Director addDirector(Director director) {
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+ jdbcTemplate.update(connection -> {
+ PreparedStatement ps = connection.prepareStatement(INSERT_QUERY, new String[]{"id"});
+ ps.setString(1, director.getName());
+ return ps;
+ }, keyHolder);
+ director.setId(keyHolder.getKeyAs(Long.class));
+ return director;
+ }
+
+ @Override
+ public Director updateDirector(Director director) {
+ // update возвращает число изменённых строк: 0 — режиссёра с таким id нет
+ int updatedRows = jdbcTemplate.update(UPDATE_QUERY, director.getName(), director.getId());
+ if (updatedRows == 0) {
+ throw new NotFoundException("Режиссёр с id=" + director.getId() + " не найден");
+ }
+ return director;
+ }
+
+ // Связи с фильмами база удаляет сама — каскадом из schema.sql
+ @Override
+ public void removeDirector(long id) {
+ if (jdbcTemplate.update(DELETE_QUERY, id) == 0) {
+ throw new NotFoundException("Режиссёр с id=" + id + " не найден");
+ }
+ }
+
+ @Override
+ public Optional findDirectorById(long id) {
+ return jdbcTemplate.query(SELECT_BY_ID_QUERY, directorRowMapper, id).stream().findFirst();
+ }
+
+ @Override
+ public List findAllDirectors() {
+ return jdbcTemplate.query(SELECT_ALL_QUERY, directorRowMapper);
+ }
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorStorage.java
new file mode 100644
index 0000000..78ddb80
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorStorage.java
@@ -0,0 +1,19 @@
+package ru.yandex.practicum.filmorate.storage.director;
+
+import ru.yandex.practicum.filmorate.model.Director;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface DirectorStorage {
+
+ Director addDirector(Director director);
+
+ Director updateDirector(Director director);
+
+ void removeDirector(long id);
+
+ Optional findDirectorById(long id);
+
+ List findAllDirectors();
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorage.java
new file mode 100644
index 0000000..a87b0e0
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorage.java
@@ -0,0 +1,53 @@
+package ru.yandex.practicum.filmorate.storage.feed;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+import ru.yandex.practicum.filmorate.model.Event;
+import ru.yandex.practicum.filmorate.storage.mapper.EventRowMapper;
+
+import java.sql.PreparedStatement;
+import java.sql.Statement;
+import java.util.List;
+
+@Repository
+public class FeedDbStorage implements FeedStorage {
+
+ private static final String INSERT_QUERY =
+ "INSERT INTO feed_events (user_id, event_type, operation, entity_id, created_at) VALUES (?, ?, ?, ?, ?)";
+ private static final String FIND_BY_USER_QUERY =
+ "SELECT event_id, user_id, event_type, operation, entity_id, created_at FROM feed_events "
+ + "WHERE user_id = ? ORDER BY event_id";
+
+ private final JdbcTemplate jdbc;
+ private final EventRowMapper mapper;
+
+ @Autowired
+ public FeedDbStorage(JdbcTemplate jdbc, EventRowMapper mapper) {
+ this.jdbc = jdbc;
+ this.mapper = mapper;
+ }
+
+ @Override
+ public Event add(Event event) {
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+ jdbc.update(connection -> {
+ PreparedStatement ps = connection.prepareStatement(INSERT_QUERY, Statement.RETURN_GENERATED_KEYS);
+ ps.setLong(1, event.getUserId());
+ ps.setString(2, event.getEventType().name());
+ ps.setString(3, event.getOperation().name());
+ ps.setLong(4, event.getEntityId());
+ ps.setLong(5, event.getTimestamp());
+ return ps;
+ }, keyHolder);
+ event.setEventId(keyHolder.getKeyAs(Long.class));
+ return event;
+ }
+
+ @Override
+ public List findByUserId(long userId) {
+ return jdbc.query(FIND_BY_USER_QUERY, mapper, userId);
+ }
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedStorage.java
new file mode 100644
index 0000000..96471ca
--- /dev/null
+++ b/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedStorage.java
@@ -0,0 +1,12 @@
+package ru.yandex.practicum.filmorate.storage.feed;
+
+import ru.yandex.practicum.filmorate.model.Event;
+
+import java.util.List;
+
+public interface FeedStorage {
+
+ Event add(Event event);
+
+ List findByUserId(long userId);
+}
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 df032fd..4381a98 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
@@ -5,23 +5,26 @@
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
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.storage.mapper.DirectorRowMapper;
import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper;
import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.Statement;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
-import java.util.ArrayList;
import java.util.Set;
-import java.util.Map;
-import java.util.HashMap;
@Repository
public class FilmDbStorage implements FilmStorage {
@@ -31,13 +34,30 @@ public class FilmDbStorage implements FilmStorage {
"SELECT f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name AS mpa_name "
+ "FROM films f "
+ "LEFT JOIN mpa m ON f.mpa_id = m.id ";
+ // Группировка по всем колонкам SELECT_FILMS — для запросов с JOIN film_likes:
+ // чтобы посчитать лайки или убрать повторы строк одного фильма
+ private static final String GROUP_BY_FILM =
+ "GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name ";
private static final String FIND_ALL_QUERY = SELECT_FILMS + "ORDER BY f.id";
private static final String FIND_BY_ID_QUERY = SELECT_FILMS + "WHERE f.id = ?";
- private static final String FIND_POPULAR_QUERY = SELECT_FILMS
- + "LEFT JOIN film_likes fl ON f.id = fl.film_id "
- + "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 "
- + "LIMIT ?";
+
+ // Базовый запрос для популярных фильмов: WHERE-условия добавляются динамически
+ private static final String FIND_POPULAR_BASE_QUERY = SELECT_FILMS
+ + "LEFT JOIN film_likes fl ON f.id = fl.film_id ";
+ // Хвост запроса популярных фильмов: группировка и сортировка по числу лайков
+ private static final String FIND_POPULAR_TAIL_QUERY = " " + GROUP_BY_FILM
+ + "ORDER BY COUNT(fl.user_id) DESC, f.id";
+ // Лимит отдельно: общие фильмы собираются тем же запросом, что и популярные, но без лимита
+ private static final String LIMIT_CLAUSE = " LIMIT ?";
+
+ // Условия фильтрации популярных фильмов: по жанру и по году выпуска
+ private static final String FIND_POPULAR_GENRE_FILTER =
+ "f.id IN (SELECT film_id FROM film_genres WHERE genre_id = ?)";
+ private static final String FIND_POPULAR_YEAR_FILTER =
+ "YEAR(f.release_date) = ?";
+ private static final String LIKED_BY_USER_FILTER =
+ "f.id IN (SELECT film_id FROM film_likes WHERE user_id = ?)";
+
private static final String INSERT_QUERY =
"INSERT INTO films (name, description, release_date, duration, mpa_id) VALUES (?, ?, ?, ?, ?)";
private static final String UPDATE_QUERY =
@@ -62,19 +82,73 @@ public class FilmDbStorage implements FilmStorage {
"MERGE INTO film_likes (film_id, user_id) KEY (film_id, user_id) VALUES (?, ?)";
private static final String REMOVE_LIKE_QUERY =
"DELETE FROM film_likes WHERE film_id = ? AND user_id = ?";
+ private static final String INSERT_FILM_DIRECTOR_QUERY =
+ "INSERT INTO film_director (film_id, director_id) VALUES (?, ?)";
+ private static final String DELETE_DIRECTOR_FILM_QUERY =
+ "DELETE FROM film_director WHERE film_id = ?";
+ private static final String FIND_DIRECTORS_BY_FILM_QUERY =
+ "SELECT d.id, d.name FROM film_director fd "
+ + "JOIN directors d ON fd.director_id = d.id "
+ + "WHERE fd.film_id = ? "
+ + "ORDER BY d.id";
+ // Режиссёры нескольких фильмов одним запросом — так же, как жанры
+ private static final String FIND_DIRECTORS_BY_FILMS_QUERY =
+ "SELECT fd.film_id, d.id, d.name FROM film_director fd "
+ + "JOIN directors d ON fd.director_id = d.id "
+ + "WHERE fd.film_id IN (%s) "
+ + "ORDER BY fd.film_id, d.id";
+ // Фильмы режиссёра с числом лайков; сортировка добавляется в findFilmByDirector
+ private static final String FIND_FILMS_BY_DIRECTOR_QUERY = SELECT_FILMS
+ + "JOIN film_director fd ON f.id = fd.film_id "
+ + "LEFT JOIN film_likes fl ON f.id = fl.film_id "
+ + "WHERE fd.director_id = ? "
+ + GROUP_BY_FILM;
+ // Поиск: условия по названию и режиссёру добавляются в searchFilms
+ private static final String FIND_FILMS_BY_SEARCH_QUERY = SELECT_FILMS
+ + "LEFT JOIN film_director fd ON f.id = fd.film_id "
+ + "LEFT JOIN directors d ON fd.director_id = d.id "
+ + "LEFT JOIN film_likes fl ON f.id = fl.film_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_FILM
+ + "ORDER BY f.id";
private final JdbcTemplate jdbc;
private final FilmRowMapper mapper;
private final GenreRowMapper genreMapper;
+ private final DirectorRowMapper directorMapper;
@Autowired
- public FilmDbStorage(JdbcTemplate jdbc, FilmRowMapper mapper, GenreRowMapper genreMapper) {
+ public FilmDbStorage(JdbcTemplate jdbc, FilmRowMapper mapper, GenreRowMapper genreMapper,
+ DirectorRowMapper directorMapper) {
this.jdbc = jdbc;
this.mapper = mapper;
this.genreMapper = genreMapper;
+ this.directorMapper = directorMapper;
}
+ // Фильм, жанры и режиссёры пишутся в три таблицы: либо всё, либо ничего
@Override
+ @Transactional
public Film add(Film film) {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbc.update(connection -> {
@@ -89,15 +163,18 @@ public Film add(Film film) {
Long id = keyHolder.getKeyAs(Long.class);
saveGenres(id, film.getGenres());
+ saveDirectors(id, film.getDirectors());
// Перечитываем из базы: так в ответе будут названия рейтинга и жанров, а не только их id
return findById(id).orElseThrow();
}
@Override
+ @Transactional
public Film update(Film film) {
jdbc.update(UPDATE_QUERY, film.getName(), film.getDescription(), Date.valueOf(film.getReleaseDate()),
film.getDuration(), getMpaId(film), film.getId());
saveGenres(film.getId(), film.getGenres());
+ saveDirectors(film.getId(), film.getDirectors());
return findById(film.getId()).orElseThrow();
}
@@ -113,6 +190,7 @@ public Film delete(long id) {
public Collection findAll() {
List films = jdbc.query(FIND_ALL_QUERY, mapper);
loadGenresForFilms(films);
+ loadDirectorsForFilms(films);
return films;
}
@@ -120,13 +198,73 @@ public Collection findAll() {
public Optional findById(long id) {
Optional film = jdbc.query(FIND_BY_ID_QUERY, mapper, id).stream().findFirst();
film.ifPresent(this::loadGenres);
+ film.ifPresent(this::loadDirectors);
return film;
}
@Override
- public List findPopular(int count) {
- List films = jdbc.query(FIND_POPULAR_QUERY, mapper, count);
+ public List findPopular(int count, Integer genreId, Integer year) {
+ List conditions = new ArrayList<>();
+ List