Обсуждения - 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.
-
stompClient.connect({}, function(frame) {...});
: Устанавливает соединение с сервером STOMP.-
stompClient.subscribe(...)
: Подписка на тему обсуждения, связанную с конкретным проектом. При получении сообщения вызывается функцияaddCommentToList(comment)
для добавления нового комментария.
-
-
textarea.on('input', autoResizeTextarea);
: Устанавливает обработчик события на текстовое поле. ФункцияautoResizeTextarea
изменяет высоту текстового поля в соответствии с введенным текстом.
-
function addCommentToList(comment) {...}
: Создает HTML-элемент для отображения комментария и добавляет его в список комментариев на странице.- В этой функции происходит проверка, является ли комментарий авторским, и если да, то добавляется отдельный стиль.
- Если комментарий содержит файлы, создается контейнер для вложений, который отображает их в виде изображений или ссылок для скачивания.
-
loadComments();
: Вызывает функцию загрузки комментариев для текущего проекта.-
function loadComments() {...}
: Выполняет AJAX-запрос для загрузки всех комментариев по заданному проекту. При успешном ответе отображает комментарии на странице.
-
-
$(".addCommentButton").on("click", function(event) {...});
: Обработчик клика для кнопки добавления комментария.- Проверяет, заполнены ли поля комментария и файлов. Если файлы выбраны, отвечает за их загрузку через AJAX и дальнейшую отправку комментария через STOMP.
- Внутри обработчика клика происходит вызов
$.ajax({...})
для загрузки выбранных файлов на сервер. Если загрузка файлов успешна, вызываетсяsendCommentWithFiles
.
-
function sendCommentWithFiles(text, fileIds) {...}
: Формирует объект комментария и отправляет его на сервер через STOMP. Очищает поля ввода после успешной отправки.
- Обработчик для кликов на элементах вложений:
$('.commentsList').on('click', '.file-attachment a', function(event) {...});
- Отправляет GET-запрос для скачивания файла, отображает прогресс загрузки и создает ссылку для скачивания.
- Обработчики событий для закрытия модального окна при клике на иконку закрытия или на фон модального окна.
-
function escapeHtml(unsafe) {...}
: Применяется для фильтрации текста комментариев от XSS-атак, заменяя опасные символы на безопасные HTML-сущности.