Skip to content

Conversation

@Dangerwind
Copy link
Contributor

@Dangerwind Dangerwind commented Nov 30, 2025

Backend: реализация контроллера "Вебинары" в лк #938

Необходимо реализовать контроллер "Вебинары" для личного кабинета
Создать контроллер который будет отдавать вебинары доступные пользователю
Пользователь имеет возможность зарегестрироваться на вебинар, а также добавить его в свой календарь

GET /account/webinars?page=X&size=Y
Возвращает в JSON вебинары на которые подписан пользователь который в настоящий момент залогинен.
Поддерживается пагинация, по умолчанию 0 страница и 10 вебинаров на странице.

Пример ответа:
{
"component": "Account/Webinars/Index",
"props": {
"totalItems": 1, <- сколько всего нашлось у пользователя подписок на вебинары
"totalPages": 1, <- сколько всего страницы с вебинарами
"currentPage": 0, <- ккая сейчас страница выводится
"webinars": [ <- массив с найденными вебинарами на которые подписался юзер
{
"id": 1,
"webinarName": "название вебинара",
"webinarDate": "2025-11-21 14:30",
"webinarRegLink": "https://.....",
"webinarRecordLink": "https://....",
"feature": true,
"publicated": false,
"createdAt": [2025, 11, 30, 18, 34, 22, 746662000],
"updatedAt": [2025, 11, 30, 18, 34, 22, 746662000]
}
]
},
"flash" : {"............"}, <- не обязательно, но если были переданы flash сообщения то они тут
"url": "/account/webinars",
"version": "1",
"encryptHistory": false,
"clearHistory": false
}

POST /account/webinars/{webinarId}/registrations
В теле ничего нет.
Регистрирует залогиненого юзера на вебинар, вносит в его "Подписки и Вебинары" вебинар с ID webinarId
Если нет такого вебинара - отдает JSON Flash с атрибутов "error" и значением "error=error.webinar.notFound", если юзер уже подписан на вебинар то "error=error.webinar.alreadyRegistered", и если, что быть не может, залогиненного юзера нет в базе юзеров, то отдает "error=error.user.notFound"
Если все прошло нормально то пишет во flash "success": "webinar.registered.success" и редирект на /account/webinars

POST /account/webinars/{webinarId}/calendar-events
В теле ничего нет.
Добавляет в календарь залогиненого юзера вебинар с ID webinarId
Если нет такого вебинара - отдает JSON Flash с атрибутов "error" и значением "error=error.webinar.notFound", если юзер уже подписан на вебинар то "error=error.webinar.alreadyRegistered", и если, что быть не может, залогиненного юзера нет в базе юзеров, то отдает "error=error.user.notFound"
Если все прошло нормально то пишет во flash "success": "webinar.add.success" и редирект на /account/webinars

ИТОГО:

  • Регистрация на вебенар, это добавление этого вебенара в подписки как "бесплатного", вероятно потом следует делить все на платное и бесплатное и регистрировать только бесплатные, а платные регистрируются при покупке, к примеру.

  • Напрашивается отдельно реализовать работу с календарем, и, вероятно, перенести потом "добавление в календарь" отсюда в работу с календарем.

  • сделал Инициализатор начальных данных для dev.

Все покрыто тестами.

build.gradle.kts Outdated
implementation(libs.jsonunitAssertj)
implementation(libs.guava)
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.2")
// implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.2")

Choose a reason for hiding this comment

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

Обычно если что-то не нужно - то это удаляем, не оставляем закомментированные строчки.
Почему это оказалось не нужно?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Исправил!

@@ -1,3 +1,4 @@

Choose a reason for hiding this comment

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

Зачем добавлена пустая строка?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Исправил!

private final Inertia inertia;
private final FlashPropsService flashPropsService;
private final UserPageSercive userPageService;
private final UserPageService userPageService;

Choose a reason for hiding this comment

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

Почему светится как измененное? Строки на вид идентичны

Copy link
Contributor Author

Choose a reason for hiding this comment

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

тут была опечатка serCive заменено на serVice.
Тут все нормально.

@Controller
@RequiredArgsConstructor
@RequestMapping("/account")
@PreAuthorize("isAuthenticated()")

Choose a reason for hiding this comment

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

Это точно нужно? Вроде бы уже сказали в SecurityConfig.java, что нужна авторизация для доступа

Copy link
Contributor Author

@Dangerwind Dangerwind Dec 1, 2025

Choose a reason for hiding this comment

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

добавил в SecurityConfig.java запрет на /account и убрал PreAuthorize("isAuthenticated()") отсюда.
Сделано!

var props = service.indexWebinars(userId.get(), pageable);

var flash = RequestContextUtils.getInputFlashMap(request);
if (flash != null && !flash.isEmpty()) {

Choose a reason for hiding this comment

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

Здесь можно использовать библиотеку какую-нибудь, чтобы сразу проверять на null и пустоту

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Не особо вижу смысла менять шило на мыло, но ок.
исправиль на !ObjectUtils.isEmpty(flash)

public Object registerToWebinar(@PathVariable Long webinarId,
RedirectAttributes redirectAttributes) {

var userId = userUtils.currentUserId(); // id юзера который залогинился

Choose a reason for hiding this comment

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

комментарий излишен

Copy link
Contributor Author

Choose a reason for hiding this comment

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

готово, убрал все подобные коменты!

public class AccountWebinarsController {

private final Inertia inertia;
private final AccountWebinarService service;

Choose a reason for hiding this comment

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

Сервис лучше назвать accountWebinarService, так более читабельно, ведь сервисов бывает много в контроллере.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Заменил! готово!

RedirectAttributes redirectAttributes) {

var userId = userUtils.currentUserId(); // id юзера который залогинился
service.addWebinarToCalendar(userId.get(), webinarId);

Choose a reason for hiding this comment

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

Тут предлагаю сразу сделать CalendarService и перенести туда логику работы с календарем

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Перенес логику календаря в CalendarService.
Стоит сделать отдельный issue по контроллеру календаря и убрать работу с календарем в контроллер работы с календарем.


@Controller
@RequiredArgsConstructor
@RequestMapping("/account")

Choose a reason for hiding this comment

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

Контроллер, судя по названию, про покупки и подписки, почему урл про аккаунт?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

issue было "Покупки и подписки" для личного кабинета "
/account - личный кабинет в нем /purchase - покупки ... подписки и тд.
Считаю верным такой эндпоинт.

Ничего не менял.

return inertia.render("Admin/Marketing/Index", props);
}

/*

Choose a reason for hiding this comment

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

Не храним в репозитории закомментированные куски кода, удаляем, если не нужно.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Убрал!


@DeleteMapping("/{id}/delete")
public Object deleteWebinar(@PathVariable Long id,
RedirectAttributes redirectAttributes) {

Choose a reason for hiding this comment

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

форматирование

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Исправил!

import jakarta.persistence.Converter;

@Converter(autoApply = true)
public class PurchSubsTypeConverter implements AttributeConverter<StatePurchSubsType, String> {
Copy link

@ann-p-1320 ann-p-1320 Nov 30, 2025

Choose a reason for hiding this comment

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

Какой смысл в сокращениях в названии?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Сократил так как только одно имя на полэкрана. Ну ок.
заменил на PurchaseAndSubscriptionTypeConverter.

@Getter
@Setter
@NoArgsConstructor
public class PurchSubsDTO {

Choose a reason for hiding this comment

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

Если у нас в проекте еще нет соглашений, то предлагаю называть SomeDto, а не SomeDTO

Copy link
Contributor Author

Choose a reason for hiding this comment

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

звменил на PurchaseSubscriptionDto

public class WebinarDTO {
private Long id;

@NotBlank

Choose a reason for hiding this comment

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

Валидация тут нужна потому, что dto приходит с фронта? Такие можно называть не дто, а формами, например. Для остальных полей валидация не нужна?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

заменил на WebinarDto.
dto приходит в фронта, и валидация по названию нужна (чтобы в базе не было просто случайных пустышек), остальная валидация, я считаю, не нужна, так-как это приходит из админки, и можно написать только название будущего вебинара, а потом уже уточнять/заполнять и менять даты проведения и прочие параметры.

@NotBlank
private String webinarName;

@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm")

Choose a reason for hiding this comment

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

Можно настроить формат дат где-то в одном месте, чтобы не писать это в каждой дто с датой

Copy link
Contributor Author

Choose a reason for hiding this comment

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

я предполагаю, что дату-время создания и изменения сущности лучше хранить в полном формате с секундами. А дату-время начала вебинара - дата и часы: минуты. Секунды явно ясно лишние.
Пока оставил без изменений, так-как предполагается общий рефакторинг на эту тему
вот тут ты писала про это

}


@ExceptionHandler(WebinarAlreadyExistsException.class)

Choose a reason for hiding this comment

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

Не стоит делать для каждой ошибки свой класс исключения, так как на фронт мы все равно отдаем только текст. Нужно сделать общее исключение типа CvException и им пользоваться.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Да, именно так и надо сделать. Но эти исключения уже смержили с main и они используются в разных сервисах. Потому тут надо отдельное issue на рефакторинг исключений.
Пока что оставил без изменений.

}


// это просто ошибки все остальное

Choose a reason for hiding this comment

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

По принципу "оставь код чище, чем он был до тебя" такие комменты предлагаю беспощадно удалять.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Удалил ненужные комменты.

@Profile("dev")
@AllArgsConstructor
@DependsOn({"userDataInitializer", "webinarDataInitializer"})
public class PurchaseAndSubscriptionDataInitializer {

Choose a reason for hiding this comment

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

Такой способ инициализации бд не подходит, будет отдельная ишью под это.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

убрал все такие инициалайзеры, оставил только UserDataInitializer. При рефакторинге и его уберем.

public abstract WebinarDTO map(Webinar webinar);

@Mapping(target = "id", ignore = true)
public abstract void update(WebinarDTO dto, @MappingTarget Webinar model);

Choose a reason for hiding this comment

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

Подход, когда с фронта приходит вся сущность и мы слепо обновляем все, кроме id в перспективе не очень удобен. Логика усложняется, начинают появляться развилки типа "а вот если мы обновили вот это поле на вот это, то в этом могут быть только такие значения". Для изменения лучше получать с фронта конкретное поле, которое изменяем, но это надо думать уже совместно с фронт-разработчиками.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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


private String description;

// id вебирана или встречи и тд зависит от eventType

Choose a reason for hiding this comment

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

лучше javadoc коммент

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Заменил на javadoc хотя можно и удалить такой комментарий, в целом и так понятно




//@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS")

Choose a reason for hiding this comment

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

Зачем это не удалено?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Удалил!

private String itemName; // название товара/подписки и тд

@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate purchasedAt; // наверное это дата с какого числа подписка или дат когда куплено

Choose a reason for hiding this comment

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

Лучше выяснить точно, что именно означает это поле

Copy link
Contributor Author

Choose a reason for hiding this comment

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

судя по картинке в issue - это дата покупки/подписки
Убрал лишние комментарии, добавил javadoc (хотя модно было его и не писать)

private String orderNum; // Номер заказа типа #A-1042

@NotNull
private String itemName; // название товара/подписки и тд

Choose a reason for hiding this comment

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

Очевидные комментарии лучше убрать, не очевидные - в javadoc

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Все поля и так понятны без комментариев, убрал все лишнее

.map(subs -> subs.getReferenceId())
.collect(Collectors.toSet());

List<Webinar> webinars = webinarRepository.findAllByIdInOrderByWebinarDateAsc(webinarIds);

Choose a reason for hiding this comment

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

А можем за один запрос получить вебинары?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

заменил на специфический один запрос.
Но получается для каждого типа (Вебинары, Курсы, и тд) надо в репозитории писать свой метод.
В моем старом варианте для какого-то нового типа (к примеру Курсы), в репозиториях ничего не меняется. Добавили новый тип, добавили реппозиторий для этого типа и все. Один запрос находит все id подписки данного типа, второй запрос уже по нужному типу, по id достает сущности.
Если все за один запрос, это конечно быстрее будет, но надо будет для каждой сущности нового типа добавлять в PurchaseAndSubscriptionRepository свой метод.
ок. заменил!

Choose a reason for hiding this comment

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

Добавить метод в репозиторий - не страшно, хуже, когда все тормозит, потому что много лишних запросов в бд.

var foundWebinar = webinarRepository.findById(webinarId).orElseThrow(
() -> new ResourceNotFoundException("error.webinar.notFound")
);
var foundUser = userRepository.findById(userId).orElseThrow(

Choose a reason for hiding this comment

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

Мне кажется не очень чистой история, когда мы с одной стороны предполагаем, что в качестве id user'а получим id залогиненного пользователя, и в то же время вынуждены проверять, а точно ли пользователь с таким id есть в базе. Предлагаю не передавать в метод сервиса userId, а получать его внутри метода. И, соответственно, убрать orElseThrow.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

перенес получение id в сервис, убрал лишние orElseThrow


var subscription = new PurchaseAndSubscription();
subscription.setUser(foundUser);
subscription.setOrderNum("free"); // без номера вебинар

Choose a reason for hiding this comment

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

Почему без номера? "free" в константу

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Пересмотрел логику. Теперь зарегистрироваться можно только на вебинар на который есть подписка и статус available. Платный или бесплатный это будет решаться в покупках. При регистрации на вебинар статус меняется на registrated. Все статусы в enum


subscription.setItemName("Вебинар: " + foundWebinar.getWebinarName());
subscription.setPurchasedAt(LocalDate.now());
subscription.setAmount(BigDecimal.ZERO); // типа бесплатно

Choose a reason for hiding this comment

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

Можно тоже вынести в константу и назвать как-то про "бесплатно", либо просто убрать коммент

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Логику переделал. Все константы-статусы в enum

throw new WebinarAlreadyExistsException("error.webinar.alreadyRegistered");
});

var subscription = new PurchaseAndSubscription();

Choose a reason for hiding this comment

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

лучше использовать билдер

Copy link
Contributor Author

Choose a reason for hiding this comment

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

весь этот кусок убрал, логику поменял.

event.setUser(foundUser);
event.setTitle("Вебинар: " + foundWebinar.getWebinarName());
event.setStartAt(foundWebinar.getWebinarDate());
//event.setFinishAt(foundWebinar.getWebinarDate()); // задать окончания вебинара ?

Choose a reason for hiding this comment

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

Не нужно оставлять такие комментарии, стоит сразу разобраться

Copy link
Contributor Author

Choose a reason for hiding this comment

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

готово!

LocalDateTime to = null;

// 2000 ... 2099
if (searchString.matches("^20\\d{2}$")) {

Choose a reason for hiding this comment

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

Расскажи, пожалуйста, подробнее, для чего здесь такая логика поиска. Выглядит сложно.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

тут поиск или по текстовому полю, или по LocalDateTime (только год, год-месяц, год-месяц-день, + еще и время)
ввели "2025-10" будет искать по датам в которых есть 2025-10. Вели "2023" - найдет по дате за этот год. Ввели слово "классы" будет искать это слово в текстовых полях. Готовой библиотеки для поиска и по String и по LocalDateTime одновременно не нашел, написал свой вариант.

private MockMvc mockMvc;

@Autowired
private PurchaseAndSubscriptionRepository subsRepo;

Choose a reason for hiding this comment

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

не надо сокращать, purchaseAndSubscriptionRepository

Copy link
Contributor Author

Choose a reason for hiding this comment

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

заменил все сокращения


@AfterEach
public void cleanUp() {
eventRepo.deleteAll();

Choose a reason for hiding this comment

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

Тесты могут выполняться параллельно, нужно так готовить тестовые данные, чтобы для каждого теста они были изолированы и не нужно было все отовсюду удалять каждый раз.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

если в базе что-то останется - на тесты не влияет, влияет если после тестов останется что то в базе.
Потому тут после выполнения тестов все удаляется.
В начале так же удаляется чтобы избежать каких-то неучтенных накладок, если какие то тесты вдруг (вдруг так совпадет) будут одну и ту же сущность.

@Test
void testIndexWebinars() throws Exception {

var webinar = new Webinar();

Choose a reason for hiding this comment

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

  1. Лучше использовать билдеры и методы для создания, ведь большинство параметров нам не важно. Чтобы в тесте это выглядело как "Создали один вебинар, создали второй, погнали проверять, чтобы это и вот это". По сути в конце теста чекаются только названия вебинаров, значит только их и имеет смысл делать уникальными.
  2. Нужно разбить тест на секции given, when, then, просто строкой с комментом:
    //given
    ....
    ...
    //when
    ...
    //then
    ...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

сделал билдер

public class AdminWebinarsControllerTest {

@Autowired
private MockMvc mockMvc;

Choose a reason for hiding this comment

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

Можно сделать базовый тестовый класс, вынести в него повторяющиеся поля и логику

Copy link
Contributor Author

Choose a reason for hiding this comment

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

на мой взгляд будет менее читаемо. Тут разные пути проверяются, разные коды ответа. Я не вижу тут повтряющиеся куски чтобы их можно было вынести в базовый класс и от него наследоваться. Ну или будет запутанно. На мой взгляд.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

а! или ты имеешь ввиду сделать базовый класс для всех тестов (не конкретно для этого) и вынести туда все удаления базы, все инжекции зависимостей MockMvc, jwtUtils, passwordEncoder и тд ?

Но если так - то вынести получится не так и много, у всех же репозитории свои. Только если найти похожие, к примеру все про Admin и сделать базовый класс для них. Но как-то не верен что имеет смысл.

часть тестов смержина в main и наверное тогда надо отдельный issue для переделки всего.

.withUser(testUser)
.withWebinar(webinar2)
.build());

Choose a reason for hiding this comment

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

тут when

var mvcResult = mockMvc.perform(get("/account/webinars")
.cookie(new Cookie("access_token", candidateToken))
.header("X-Inertia", "true"))
//when

Choose a reason for hiding this comment

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

тут then

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants