Обсуждения - oyboy/Jora GitHub Wiki

Краткое описание

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

Модели

Основной моделью является DiscussionComment, она же и сохраняется в базе. Связанные с ней классы созданы по аналогии с моделью Comment: есть то, что хранится в базе и то, что передаётся между сервером и клиентом. Для понимания предназначения каждой модели необходимо проследить логику работы обсуждения. Сначала пользователь отправляет комментарий с файлом. Первым делом вызывается post-метод для сохранения файла на сервер. При успехе в ответе возвращаются id этих файлов. Далее уже отправляется сам комментарий, в котором всего 3 поля: текст, хеш-проекта и id сохранённых файлов. Таким образом создаётся видимость отправки данных в реальном времени, несмотря на то, что файлы передаются не по сокетам. Вот сам объект:

@Data
public class DiscussionCommentCreatorDTO {
    private String text;
    private String projectHash;
    private List<String> attachmentIds;
}

Далее после отправки необходимо сохранить данную запись в базе. В методе сервиса на основе creator и principal создаётся основной документ DiscussionComment:

@Data
@Document
public class DiscussionComment {
    @Id
    private String id;
    private Long authorId;
    private String authorName;
    private String projectHash;
    private String text;
    private LocalDateTime createdAt;
    private List<FileAttachment> attachments;
    DiscussionCommentDTO convertToDto(){
        return new DiscussionCommentDTO(
                this.text,
                this.projectHash,
                this.authorName,
                this.authorId,
                this.createdAt,
                Optional.ofNullable(this.attachments)
                        .orElse(Collections.emptyList())
                        .stream()
                        .map(attachment -> attachment.convertToDto(this.projectHash))
                        .collect(Collectors.toList())
        );
    }
}

После отправки необходимо обновить представление, поэтому для ответа желательно вернуть не сам документ, а только его dto-представление:

@Data
@AllArgsConstructor
public class DiscussionCommentDTO {
    private String text;
    private String project_hash;
    private String authorName;
    private Long authorId;
    private LocalDateTime createdAt;
    private List<FileAttachmentDTO> fileAttachmentDTOS;
}

Классы имеют много общих полей, поэтому позднее они будут переделаны. Файловые вложения являются под-документами, поэтому их не нужно аннотировать как @Document, и также имеют dto-представление:

@Data
public class FileAttachment {
    @Id
    private String id;
    private String discussionCommentId;
    private String fileName;
    private byte[] bytes;
    private LocalDateTime uploadedAt;

    FileAttachmentDTO convertToDto(String projectHash){
        return new FileAttachmentDTO(
                this.fileName,
                "/projects/" + projectHash + "/api/discussion/download/" + this.id
        );
    }
}
@Data
@AllArgsConstructor
public class FileAttachmentDTO {
    private String fileName;
    private String downloadUrl;
}

Контроллер

Используются два контроллера. Один для MVC, другой для api. Пройдёмся кратко по методам rest-контроллера:

@MessageMapping("/projects/{project_hash}/discussion")
    @SendTo("/topic/projects/{project_hash}/discussion")
    public DiscussionCommentDTO sendComment(@Payload String jsonPayload,
                                            Principal principal){
        User currentUser = (User) ((Authentication) principal).getPrincipal();
        ObjectMapper objectMapper = new ObjectMapper();
        DiscussionCommentCreatorDTO createCommentDTO = null;
        try {
            createCommentDTO = objectMapper.readValue(jsonPayload, DiscussionCommentCreatorDTO.class);
        } catch (JsonProcessingException e) {
            System.out.println("Problem with converting json: " + e.getMessage());
        }
        DiscussionComment discussionComment = discussionService.saveDiscussionComment(createCommentDTO, currentUser);

        String hash = createCommentDTO.getProjectHash();
        return new DiscussionCommentDTO(
                discussionComment.getText(),
                hash,
                currentUser.getUsername(),
                currentUser.getId(),
                discussionComment.getCreatedAt(),
                Optional.ofNullable(discussionComment.getAttachments())
                        .orElse(Collections.emptyList()) // Если attachments null, используем пустой список
                        .stream()
                        .map(attachment -> attachment.convertToDto(hash))
                        .collect(Collectors.toList())
        );
    }

Здесь используются сразу два dto. Сначала от клиента приходит json-объект, который затем конвертируется в creatorDTO. Далее уже в комбинации с principal вызывается сохранение комментария в базе. Далее после сохранения необходимо вернуть ответ клиенту в виде уже другого dto-объекта, который помимо данных о пользователе содержит также данные о вложениях.

@PostMapping("/upload-files")
    public ResponseEntity<List<String>> uploadFiles(@RequestParam("files") List<MultipartFile> files) {
        List<String> fileIds = new ArrayList<>();
        for (MultipartFile file : files) {
            try {
                fileIds.add(discussionService.saveFileAttachment(file));
            } catch (IOException e) {
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
            }
        }
        return ResponseEntity.ok(fileIds);
    }

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

@GetMapping("/comments")
    public ResponseEntity<List<DiscussionCommentDTO>> getCommentsForDiscussion(@PathVariable("project_hash") String project_hash){
        List<DiscussionCommentDTO> commentDTOS = discussionService.getComments(project_hash)
                .stream()
                .map(DiscussionComment::convertToDto)
                .collect(Collectors.toList());
        System.out.println("Found " + commentDTOS.size() + " comments in project " + project_hash);
        return ResponseEntity.ok(commentDTOS);
    }

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

@GetMapping("/download/{fileId}")
    public ResponseEntity<byte[]> downloadFile(@PathVariable("fileId") String fileId) {
        FileAttachment fileAttachment = discussionService.findFileByFileId(fileId);
        if (fileAttachment == null) return ResponseEntity.badRequest().body("File deleted".getBytes());
        String encodedFileName = URLEncoder.encode(fileAttachment.getFileName(), StandardCharsets.UTF_8);
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + encodedFileName + "\"")
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .body(fileAttachment.getBytes());
    }

Метод, вызываемый пользователем, когда он желает скачать себе файл. Всё, на что стоит обратить внимание, так это на вероятный IllegalArgumentException, который создаётся в случае отправки файлов с нелатинским названием. Чтобы его избежать, необходимо изменить кодировку его названия. Также пара слов о ResponseEntity:

  • ResponseEntity.ok(): Начинает создание ответа с успешным статусом (HTTP 200 OK)
  • header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename="" + encodedFileName + """). Добавляет заголовок контента Content-Disposition к ответу. Этот заголовок указывает браузеру, что контент должен быть скачан как файл (атрибут attachment). Имя файла задается в параметре filename.
  • contentType(MediaType.APPLICATION_OCTET_STREAM). Устанавливает тип контента в application/octet-stream, что обозначает бинарные данные. Это значит, что браузер или клиент будут обрабатывать ответ как файл, не зная его точного типа
  • body(fileAttachment.getBytes(). Тело ответа, содержимым которого является массив байт

Сервис

@Service
@Slf4j
@Transactional
public class DiscussionService {
    @Autowired
    private GridFsTemplate gridFsTemplate;
    @Autowired
    GridFsOperations operations;
    @Autowired
    DiscussionRepository discussionRepository;
    @Autowired
    ProjectRepository projectRepository;
    @Autowired
    FileAttachmentRepository fileAttachmentRepository;
    public DiscussionComment saveDiscussionComment(DiscussionCommentCreatorDTO createCommentDTO, User user){
        //Создание документа
        DiscussionComment comment = new DiscussionComment();
        comment.setAuthorId(user.getId());
        comment.setAuthorName(user.getUsername());
        comment.setText(createCommentDTO.getText());
        comment.setProjectHash(createCommentDTO.getProjectHash());
        comment.setCreatedAt(LocalDateTime.now());

        //Связывание влоежний с документом
        List<FileAttachment> attachments = new ArrayList<>();
        if (createCommentDTO.getAttachmentIds() != null) {
            for (String attachmentId : createCommentDTO.getAttachmentIds()) {
                GridFSFile gridFsFile = gridFsTemplate.findOne(new Query(Criteria.where("_id").is(attachmentId)));
                FileAttachment fileAttachment = new FileAttachment();
                fileAttachment.setId(attachmentId);
                fileAttachment.setFileName(gridFsFile.getFilename());
                fileAttachment.setUploadedAt(LocalDateTime.now());

                attachments.add(fileAttachment);
            }
        }
        comment.setAttachments(attachments);

        log.info("Saving discussion comment: {}", comment.getText());
        discussionRepository.save(comment);
        return comment;
    }
    //Сохранение файла в mongo grid. После сохранения возвращается его id
    public String saveFileAttachment(MultipartFile file) throws IOException {
        log.info("Saving attachment: {}", file.getOriginalFilename());
        InputStream inputStream = file.getInputStream();
        ObjectId fileId = gridFsTemplate.store(inputStream, file.getOriginalFilename());
        return fileId.toHexString();
    }
    //Получение комментариев для данного обсуждения
    public List<DiscussionComment> getComments(String project_hash){
        return discussionRepository.getDiscussionCommentsByProjectHash(project_hash);
    }
    //Поиск файла для его загрузки
    public FileAttachment findFileByFileId(String id) {
        log.info("Поиск файла по id: {}", id);
        try {
            ObjectId objectId = new ObjectId(id);
            GridFSFile gridFsFile = gridFsTemplate.findOne(new Query(Criteria.where("_id").is(objectId)));
            if (gridFsFile != null) {
                GridFsResource resource = gridFsTemplate.getResource(gridFsFile);
                FileAttachment fileAttachment = new FileAttachment();
                fileAttachment.setId(id);
                fileAttachment.setFileName(resource.getFilename());
                fileAttachment.setBytes(resource.getInputStream().readAllBytes());
                return fileAttachment;
            } else {
                log.error("Файл с ID {} не найден", id);
            }
        } catch (IOException io) {
            log.error(io.getMessage());
        }
        return null;
    }
}

Репозиторий

При работе с mongo нужно обратить внимание, что id у моделей имеет тип String. Это связано с тем, что mongo сам генерирует уникальные идентификаторы, избавляя разработчика от необходимости думать о его способе генерации (подразумевается @GeneratedValue). Также вместо интерфейса CrudRepository расширяется уже MongoRepository:

@Repository
public interface DiscussionRepository extends MongoRepository<DiscussionComment, String> {

    List<DiscussionComment> getDiscussionCommentsByProjectHash(String projectHash);
}

Скрипт

Объявление переменных:

  • let projectHash = $('#projectHash').val();: Сохраняет уникальный идентификатор проекта.
  • let currentUserId = Number($('#currentUserId').val());: Сохраняет ID текущего пользователя (преобразует его в число).
  • const csrfToken = $('input[name="_csrf"]').val();: Получает токен CSRF для защиты от атак.
  • let socket = new SockJS('/ws');: Создает соединение WebSocket с сервером через SockJS.
  • stompClient = Stomp.over(socket);: Создает клиент STOMP для работы с WebSocket.

Подключение к WebSocket и подписка на обсуждения:

  • stompClient.connect({}, function(frame) {...});: Устанавливает соединение с сервером STOMP.
    • stompClient.subscribe(...): Подписка на тему обсуждения, связанную с конкретным проектом. При получении сообщения вызывается функция addCommentToList(comment) для добавления нового комментария.

Автоматическое изменение размера текстового поля для ввода комментариев:

  • textarea.on('input', autoResizeTextarea);: Устанавливает обработчик события на текстовое поле. Функция autoResizeTextarea изменяет высоту текстового поля в соответствии с введенным текстом.

Добавление комментария в список:

  • function addCommentToList(comment) {...}: Создает HTML-элемент для отображения комментария и добавляет его в список комментариев на странице.
    • В этой функции происходит проверка, является ли комментарий авторским, и если да, то добавляется отдельный стиль.
    • Если комментарий содержит файлы, создается контейнер для вложений, который отображает их в виде изображений или ссылок для скачивания.

Загрузка комментариев при инициализации:

  • loadComments();: Вызывает функцию загрузки комментариев для текущего проекта.
    • function loadComments() {...}: Выполняет AJAX-запрос для загрузки всех комментариев по заданному проекту. При успешном ответе отображает комментарии на странице.

Отправка комментариев через WebSocket:

  • $(".addCommentButton").on("click", function(event) {...});: Обработчик клика для кнопки добавления комментария.
    • Проверяет, заполнены ли поля комментария и файлов. Если файлы выбраны, отвечает за их загрузку через AJAX и дальнейшую отправку комментария через STOMP.

Функция uploadFiles:

  • Внутри обработчика клика происходит вызов $.ajax({...}) для загрузки выбранных файлов на сервер. Если загрузка файлов успешна, вызывается sendCommentWithFiles.

Функция sendCommentWithFiles:

  • function sendCommentWithFiles(text, fileIds) {...}: Формирует объект комментария и отправляет его на сервер через STOMP. Очищает поля ввода после успешной отправки.

Скачивание файлов:

  • Обработчик для кликов на элементах вложений: $('.commentsList').on('click', '.file-attachment a', function(event) {...});
    • Отправляет GET-запрос для скачивания файла, отображает прогресс загрузки и создает ссылку для скачивания.

Закрытие модального окна:

  • Обработчики событий для закрытия модального окна при клике на иконку закрытия или на фон модального окна.

Функция escapeHtml:

  • function escapeHtml(unsafe) {...}: Применяется для фильтрации текста комментариев от XSS-атак, заменяя опасные символы на безопасные HTML-сущности.
⚠️ **GitHub.com Fallback** ⚠️