Комментарии - oyboy/Jora GitHub Wiki
Это набор текста, который оставляют пользователи к каждой задаче. К ним нельзя добавить какие-либо файлы, но можно посмотреть были ли они и кем когда-то прочитаны. Для этих целей созданы несколько моделей, каждая из которых служит определённой цели
@Data
public class CreateCommentDTO {
private String text;
private Long taskId;
}
Это самый простой набор данных, который необходим для создания основной сущности. Он служит для отправки данных от клиента. В комбинации с Principal создаётся основной объект, который хранит данные как о комментарии, так и о пользователе:
@Data
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 500)
@Size(max = 500, message = "Содержание комментария не должно превышать 500 символов")
private String text;
@ManyToOne
@JoinColumn(name = "task_id")
private Task task;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
private LocalDateTime createdAt;
CommentDTO convertToDTO(){
return new CommentDTO(
this.text,
this.user.getUsername(),
this.user.getId(),
this.createdAt,
this.id
);
}
}
Далее клиенту необходимо вернуть ответ, но Comment не годится для таких целей, так как имеет в себе ссылки на другие сложные модели, которые в свою очередь также имеют ссылки. Это приводит к зацикливанию и переполнению стека. Чтобы избежать такого, создаётся специальный объект для передачи данных (dto - data transfer object):
@Data
@AllArgsConstructor
public class CommentDTO {
private String text;
private String username;
private Long userId;
private LocalDateTime createdAt;
private Long commentId;
}
Чтобы добавить функцию прочтения, необходимо где-то хранить данные о том, кто и когда какой комментарий прочитал. Для этого созданы соответствующие модели:
@Data
@Entity
@NoArgsConstructor
public class UserCommentDTO {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private Long commentId;
private Long taskId;
private LocalDateTime readAt;
public UserCommentDTO(Long userId, Long commentId, Long taskId){
this.userId = userId;
this.commentId = commentId;
this.taskId = taskId;
}
}
@Data
@AllArgsConstructor
public class CommentReader {
private String username;
private String email;
private Long userId;
private LocalDateTime readAt;
}
@MessageMapping("/projects/{project_hash}/tasks/{task_id}/comment")
@SendTo("/topic/projects/{project_hash}/tasks/{task_id}/comment")
public CommentDTO sendCommentWebSocket(@Payload String jsonPayload,
Principal principal) {
User currentUser = (User) ((Authentication) principal).getPrincipal();
ObjectMapper objectMapper = new ObjectMapper();
CreateCommentDTO createCommentDTO = null;
try {
createCommentDTO = objectMapper.readValue(jsonPayload, CreateCommentDTO.class);
} catch (JsonProcessingException e) {
System.out.println("Problem with converting json: " + e.getMessage());
}
Comment savedComment = commentService.saveComment(createCommentDTO, currentUser);
commentService.saveUnreadComments(savedComment, currentUser);
return new CommentDTO(
savedComment.getText(),
savedComment.getUser().getUsername(),
savedComment.getUser().getId(),
savedComment.getCreatedAt(),
savedComment.getId()
);
}
Это метод для отправки сообщений по сокету. Первым делом на сервер приходит json-объект, который затем конвертируется в CreateCommentDTO. Стоит обратить внимание на то, что _taskId_передаётся именно в json, а не извлекается из пути при помощи @PathVariable, т.к. это приводит к конфликту с @Payload и выбросу ряда неприятных исключений. Вероятно конфликта можно избежать, если @PathVariable будет извлекаться объект типа String, поскольку проблема возникала именно с taskId, имевшем тип Long. Также вместо @AuthenticationPrincipal нужно использовать именно Principal, т.к. первое не поддерживается в работе с WebSocket. После успешной конвертации необходимо сохранить Comment в базе, сохранить для остальных пользователей этот комментарий как непрочитанный и вернуть ответ в виде dto.
@GetMapping
public ResponseEntity<List<CommentDTO>> getCommentsByTaskId(@PathVariable("task_id") Long task_id) {
List<CommentDTO> comments = commentService.getCommentsByTaskId(task_id).stream()
.map(Comment::convertToDTO).
collect(Collectors.toList());
return ResponseEntity.ok(comments);
}
Метод подтягивает список комментариев из базы и конвертирует их в dto.
@PostMapping("/read")
public ResponseEntity<UserCommentDTO> markAsRead(@RequestBody UserCommentDTO userCommentDTO){
try{
commentService.markAsRead(userCommentDTO);
} catch (TransactionRequiredException ex){} //Если записи нет, то ничего не делать
return ResponseEntity.ok().body(userCommentDTO);
}
Пост-запросом пользователь помечает комментарий как прочтённый. Для этого на сервер поступает уже собранный объект, у которого потом в сервисе изменяется одно поле. После изменения он обратно возвращается клиенту.
@GetMapping("/unreadCount")
public Long getUncreadCount(@AuthenticationPrincipal User user,
@PathVariable("task_id") Long task_id){
return commentService.getUnreadCommentsCount(user, task_id);
}
Возвращает количество непрочитанных сообщений для пользователя в определённой задаче. Это необходимо для того, чтобы тот видел, где у него есть непрочитанные записи.
@GetMapping("{commentId}/readers")
public List<CommentReader> getReadersForComment(@PathVariable("commentId") Long commentId){
return commentService.getReadersForComment(commentId);
}
При клике на комментарий создаётся get-запрос, который возвращает список, кто и когда прочитал сообщение, в представлении CommentReader
@Service
@Slf4j
public class CommentService {
@Autowired
private CommentRepository commentRepository;
@Autowired
UserCommentRepository userCommentRepository;
@Autowired
private TaskRepository taskRepository;
@Autowired
private UserTaskRepository userTaskRepository;
public List<Comment> getCommentsByTaskId(Long taskId) {
return commentRepository.findAllByTaskId(taskId);
}
public Comment saveComment(CreateCommentDTO createCommentDTO, User user) {
log.info("Received commentData: {}", createCommentDTO);
Task task = taskRepository.findById(createCommentDTO.getTaskId()).orElse(null);
Comment comment = new Comment();
comment.setText(createCommentDTO.getText());
comment.setTask(task);
comment.setUser(user);
comment.setCreatedAt(LocalDateTime.now());
log.info("Saving new comment: {}", comment);
commentRepository.save(comment);
return comment;
}
@Transactional
public void markAsRead(UserCommentDTO userCommentDTO){
log.info("Marking comment ID {} as read.", userCommentDTO.getCommentId());
userCommentRepository.updateCommentAsRead(
userCommentDTO.getUserId(),
userCommentDTO.getTaskId(),
userCommentDTO.getCommentId()
);
}
public void saveUnreadComments(Comment savedComment, User currentUser){
log.info("Saving unread comments");
List<User> usersForTask = userTaskRepository.getUsersByTaskId(savedComment.getTask().getId());
for (User u : usersForTask){
if (u.equals(currentUser)) continue; //Для отправителя не нужно сохранять непрочитанное сообщение
UserCommentDTO userCommentDTO = new UserCommentDTO(u.getId(), savedComment.getId(), savedComment.getTask().getId());
log.info("Saving user-comment dto: {}", userCommentDTO);
userCommentRepository.save(userCommentDTO);
}
}
public Long getUnreadCommentsCount(User user, Long taskId){
return userCommentRepository.getUnreadCommentsCount(user.getId(), taskId);
}
public List<CommentReader> getReadersForComment(Long commentId){
log.info("Finding readers in {} comment", commentId);
List<CommentReader> foundUsers = userCommentRepository.getReadersForComment(commentId);
log.info("Found: {}", foundUsers);
return foundUsers;
}
//Автоудаление прочитанных комментариев
@Transactional
@Scheduled(fixedRate = 172_800_000) //1 секунда = 1000 / 172_800_800 = 48 часов
public void cleanReadComments() {
log.info("removing read comments from db");
userCommentRepository.deleteReadComments();
}
}
Здесь стоит обратить внимание на два метода, помеченных как @Transactional. Это необходимо, поскольку здесь идёт прямое изменение записи в базе без загрузки из неё. Более того, это должно ускорить работу с данными, поскольку данные о прочтении приходят регулярно.
public interface UserCommentRepository extends CrudRepository<UserCommentDTO, Long> {
@Query("SELECT COUNT(*) " +
"FROM UserCommentDTO uc " +
"WHERE uc.userId = :userId AND uc.readAt IS NULL AND uc.taskId = :taskId")
Long getUnreadCommentsCount(@Param("userId") Long userId, @Param("taskId") Long taskId);
@Modifying //Прямое обращение к базе без извлечения поля
@Query("UPDATE UserCommentDTO uc " +
"SET uc.readAt = CURRENT_TIMESTAMP " +
"WHERE uc.userId = :userId " +
"AND uc.taskId = :taskId " +
"AND uc.commentId = :commentId " +
"AND uc.readAt IS NULL")
void updateCommentAsRead(@Param("userId") Long userId,
@Param("taskId") Long taskId,
@Param("commentId") Long commentId);
@Query("SELECT new com.main.Jora.comments.CommentReader(u.username, u.email, u.id, uc.readAt) " +
"FROM User u " +
"JOIN UserCommentDTO uc " +
"ON uc.userId = u.id " +
"WHERE uc.commentId = :commentId AND uc.readAt IS NOT NULL")
List<CommentReader> getReadersForComment(@Param("commentId") Long commentId);
@Modifying
@Query(value = """
WITH temp_id_table AS (
SELECT cr1.comment_id
FROM user_commentdto AS cr1
WHERE cr1.read_at IS NOT NULL
GROUP BY cr1.comment_id, cr1.task_id
HAVING COUNT(cr1.user_id) = (
SELECT COUNT(cr2.user_id)
FROM user_commentdto AS cr2
WHERE cr1.comment_id = cr2.comment_id
GROUP BY cr2.comment_id
)
)
DELETE FROM user_commentdto
WHERE comment_id IN (SELECT * FROM temp_id_table);
""", nativeQuery = true) //nativeQuery - определяет, что запрос написан на нативном SQL, поскольку HQL вызывал ряд ошибок
void deleteReadComments();
}
Поскольку для комментариев реализован RestController, необходима отдельная логика для отправки ресурсов по http-запросам в json-формате. Этот скрипт предназначен для динамического взаимодействия со страницей задач. Он обеспечивает функционал для:
- Отображения комментариев к задачам в реальном времени, используя WebSocket.
- Добавления новых комментариев к задачам через асинхронное взаимодействие.
- Проверки на наличие новых комментариев для задач при загрузке страницы.
- Показа пользователей, прочитавших комментарий
-
let stompClient = null;
: Объявляет переменную для клиента STOMP (WebSocket). -
const projectHash = $("#projectHash").val();
: Сохраняет уникальный идентификатор проекта. -
const currentUserId = Number($('#currentUserId').val());
: Сохраняет ID текущего пользователя (преобразуя его в число). -
const csrfToken = $('input[name="_csrf"]').val();
: Получает токен CSRF для защиты от атак.
-
$('.task').each(...):
Перебирает все задачи и для каждой задачи вызывает функциюcheckForUnreadComments(taskId)
, которая проверяет, есть ли непрочитанные комментарии. Если да, добавляется класс для визуального выделения.
-
$(".showCommentsButton").on("click", function() {...});
: Обработчик клика по кнопке для показа комментариев.- Когда кнопка нажата, создается WebSocket соединение с сервером, и клиент STOMP подключается.
- Подписывается на получение комментариев для данной задачи через
stompClient.subscribe
. - При получении нового комментария вызывается
createCommentElement
для добавления комментария в интерфейс. - Запускается функция
loadComments(taskId)
, чтобы загрузить существующие комментарии при открытии.
-
$(".addCommentButton").on("click", function() {...});
: Обработчик клика для кнопки добавления комментария.- Проверяет введенный текст, формирует объект комментария и отправляет его на сервер через STOMP.
- Очищает поле ввода после успешной отправки.
Выполняет AJAX-запрос для загрузки всех комментариев по заданной задаче. При успешном ответе добавляет все комментарии на страницу.
Создает и возвращает HTML-элемент для отображения комментария. Настраивает обработчик клика для отображения пользователей, прочитавших комментарий (если пользователь является автором комментария).
Выполняет AJAX-запрос для получения списка пользователей, прочитавших определенный комментарий, и вызывает showReaders
.
Отображает в модальном окне список пользователей, которые прочитали комментарий. Прежде чем открыть модальное окно, проверяется, есть ли прочитавшие пользователи.
Отправляет запрос, помечая комментарий как прочитанный для данного пользователя. Проверяет статус ответа и обновляет количество непрочитанных комментариев.
Выполняет AJAX-запрос для получения количества непрочитанных комментариев для указанной задачи. Обновляет стили кнопки в зависимости от наличия непрочитанных комментариев.