From 742951514303837187d58dd60421fecba604d97f Mon Sep 17 00:00:00 2001 From: SlimusMinus Date: Tue, 4 Aug 2026 00:33:17 +0300 Subject: [PATCH] fixed GeocodingService add alter city --- .../com/krylov/refound/dto/AddressInfo.java | 5 + .../krylov/refound/dto/DownloadedFile.java | 9 ++ .../krylov/refound/service/ChatService.java | 27 ++-- .../refound/service/FavoriteService.java | 3 +- .../refound/service/FileStorageService.java | 55 ++++---- .../refound/service/GeocodingService.java | 119 ++++++++++++++++++ .../refound/service/MessageService.java | 23 ---- .../refound/service/ModerationService.java | 8 -- .../refound/service/NominatimService.java | 36 ------ .../krylov/refound/service/PostService.java | 19 +-- .../011-alter-posts-district-length.yaml | 9 ++ .../db/changelog/db.changelog-master.yaml | 3 +- 12 files changed, 192 insertions(+), 124 deletions(-) create mode 100644 src/main/java/com/krylov/refound/dto/AddressInfo.java create mode 100644 src/main/java/com/krylov/refound/dto/DownloadedFile.java create mode 100644 src/main/java/com/krylov/refound/service/GeocodingService.java delete mode 100644 src/main/java/com/krylov/refound/service/MessageService.java delete mode 100644 src/main/java/com/krylov/refound/service/ModerationService.java delete mode 100644 src/main/java/com/krylov/refound/service/NominatimService.java create mode 100644 src/main/resources/db/changelog/alter/011-alter-posts-district-length.yaml diff --git a/src/main/java/com/krylov/refound/dto/AddressInfo.java b/src/main/java/com/krylov/refound/dto/AddressInfo.java new file mode 100644 index 0000000..58a78fc --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/AddressInfo.java @@ -0,0 +1,5 @@ +package com.krylov.refound.dto; + +public record AddressInfo(String city, String district) { + +} diff --git a/src/main/java/com/krylov/refound/dto/DownloadedFile.java b/src/main/java/com/krylov/refound/dto/DownloadedFile.java new file mode 100644 index 0000000..7ad7bd2 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/DownloadedFile.java @@ -0,0 +1,9 @@ +package com.krylov.refound.dto; + +public record DownloadedFile( + String originalFilename, + String contentType, + byte[] content +) { + +} diff --git a/src/main/java/com/krylov/refound/service/ChatService.java b/src/main/java/com/krylov/refound/service/ChatService.java index 1044511..1164c8c 100644 --- a/src/main/java/com/krylov/refound/service/ChatService.java +++ b/src/main/java/com/krylov/refound/service/ChatService.java @@ -35,14 +35,13 @@ public class ChatService { throw new IllegalArgumentException("Нельзя создать чат с самим собой"); } - Long u1 = Math.min(currentUserId, otherUserId); - Long u2 = Math.max(currentUserId, otherUserId); + Long userId1 = Math.min(currentUserId, otherUserId); + Long userId2 = Math.max(currentUserId, otherUserId); - Chat chat = chatRepository.findByUsers(u1, u2) - .orElseGet(() -> { + Chat chat = chatRepository.findByUsers(userId1, userId2).orElseGet(() -> { Chat newChat = new Chat(); - newChat.setUserOneId(u1); - newChat.setUserTwoId(u2); + newChat.setUserOneId(userId1); + newChat.setUserTwoId(userId2); newChat.setPostId(postId); return chatRepository.save(newChat); }); @@ -64,13 +63,12 @@ public class ChatService { Chat chat = getChatOrThrow(chatId); checkParticipant(chat, userId); - return messageRepository.findByChatIdOrderByCreatedAtDesc(chatId, pageable) - .map(this::toMessageDto); + return messageRepository.findByChatIdOrderByCreatedAtDesc(chatId, pageable).map(this::toMessageDto); } // Отправка сообщения (вызывается из WebSocket контроллера) @Transactional - public MessageDto sendMessage(Long senderId, Long chatId, String content) { + public void sendMessage(Long senderId, Long chatId, String content) { Chat chat = getChatOrThrow(chatId); checkParticipant(chat, senderId); @@ -89,15 +87,13 @@ public class ChatService { ? chat.getUserTwoId() : chat.getUserOneId(); - long unreadCount = messageRepository - .countByChatIdAndIsReadFalseAndSenderIdNot(chatId, recipientId); + long unreadCount = messageRepository.countByChatIdAndIsReadFalseAndSenderIdNot(chatId, recipientId); messagingTemplate.convertAndSend( "/topic/user/" + recipientId + "/unread", Map.of("chatId", chatId, "unreadCount", unreadCount) ); - return dto; } @Transactional @@ -114,11 +110,9 @@ public class ChatService { messageRepository.markAsReadByChatIdAndRecipient(chatId, userId); } - // ---------- вспомогательные методы ---------- private Chat getChatOrThrow(Long chatId) { - return chatRepository.findById(chatId) - .orElseThrow(() -> new IllegalArgumentException("Чат не найден")); + return chatRepository.findById(chatId).orElseThrow(() -> new IllegalArgumentException("Чат не найден")); } private void checkParticipant(Chat chat, Long userId) { @@ -128,8 +122,7 @@ public class ChatService { } private ChatDto toDto(Chat chat, Long currentUserId) { - Long otherUserId = chat.getUserOneId().equals(currentUserId) - ? chat.getUserTwoId() : chat.getUserOneId(); + Long otherUserId = chat.getUserOneId().equals(currentUserId) ? chat.getUserTwoId() : chat.getUserOneId(); ChatDto dto = new ChatDto(); dto.setId(chat.getId()); diff --git a/src/main/java/com/krylov/refound/service/FavoriteService.java b/src/main/java/com/krylov/refound/service/FavoriteService.java index 6c0eb02..a1a86b5 100644 --- a/src/main/java/com/krylov/refound/service/FavoriteService.java +++ b/src/main/java/com/krylov/refound/service/FavoriteService.java @@ -38,8 +38,7 @@ public class FavoriteService { User user = userService.getCurrentUser(); - Post post = postRepository.findById(postId) - .orElseThrow(() -> new ApiException( + Post post = postRepository.findById(postId).orElseThrow(() -> new ApiException( ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND diff --git a/src/main/java/com/krylov/refound/service/FileStorageService.java b/src/main/java/com/krylov/refound/service/FileStorageService.java index 887b310..f80d083 100644 --- a/src/main/java/com/krylov/refound/service/FileStorageService.java +++ b/src/main/java/com/krylov/refound/service/FileStorageService.java @@ -1,6 +1,7 @@ package com.krylov.refound.service; import com.krylov.refound.config.MinioProperties; +import com.krylov.refound.dto.DownloadedFile; import com.krylov.refound.enums.ErrorCode; import com.krylov.refound.exception.ApiException; import java.io.IOException; @@ -30,28 +31,19 @@ import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.HeadObjectResponse; import software.amazon.awssdk.services.s3.model.NoSuchKeyException; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.core.exception.SdkClientException; @Service @Slf4j @RequiredArgsConstructor public class FileStorageService { - - private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB - private static final Set ALLOWED_CONTENT_TYPES = Set.of( - "image/jpeg", "image/png", "image/webp" - ); - private final S3Client s3Client; private final MinioProperties properties; - /** - * DTO для передачи данных скачанного файла. - */ - private record DownloadedFile( - String originalFilename, - String contentType, - byte[] content - ) {} + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB + private static final Set ALLOWED_CONTENT_TYPES = Set.of("image/jpeg", "image/png", "image/webp"); + private static final String ORIGINAL_FILENAME_METADATA_KEY = "original-filename"; /** * Загружает файл в MinIO в корень бакета. @@ -67,18 +59,17 @@ public class FileStorageService { public String uploadFile(MultipartFile file, String folder) { validateFile(file); + // validateFile гарантирует, что originalFilename не null и не blank String originalFilename = file.getOriginalFilename(); String extension = extractExtension(originalFilename); - String prefix = (folder != null && !folder.isBlank()) - ? folder.replaceAll("/+$", "") + "/" - : ""; + String prefix = (folder != null && !folder.isBlank()) ? folder.replaceAll("/+$", "") + "/" : ""; String objectName = prefix + UUID.randomUUID() + extension; log.info("Загрузка файла '{}'. Новое имя: {}", originalFilename, objectName); try { Map metadata = new HashMap<>(); - metadata.put("original-filename", URLEncoder.encode(originalFilename, StandardCharsets.UTF_8)); + metadata.put(ORIGINAL_FILENAME_METADATA_KEY, URLEncoder.encode(originalFilename, StandardCharsets.UTF_8)); PutObjectRequest request = PutObjectRequest.builder() .bucket(properties.bucket()) @@ -94,8 +85,8 @@ public class FileStorageService { } catch (IOException e) { log.error("Ошибка при чтении файла для загрузки: {}", e.getMessage(), e); throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка при обработке файла.", HttpStatus.INTERNAL_SERVER_ERROR); - } catch (Exception e) { - log.error("Не удалось загрузить файл: {}", e.getMessage(), e); + } catch (S3Exception | SdkClientException e) { + log.error("Не удалось загрузить файл в S3/MinIO: {}", e.getMessage(), e); throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка при загрузке файла.", HttpStatus.INTERNAL_SERVER_ERROR); } } @@ -133,14 +124,12 @@ public class FileStorageService { s3Client.deleteObject(request); log.info("Файл '{}' успешно удален.", objectName); - } catch (Exception e) { + } catch (S3Exception | SdkClientException e) { log.error("Ошибка при удалении файла '{}': {}", objectName, e.getMessage(), e); throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка при удалении файла.", HttpStatus.INTERNAL_SERVER_ERROR); } } - // ---------- вспомогательные методы ---------- - private DownloadedFile fetchFile(String objectName) { try { HeadObjectRequest headRequest = HeadObjectRequest.builder() @@ -149,8 +138,12 @@ public class FileStorageService { .build(); HeadObjectResponse headResponse = s3Client.headObject(headRequest); - String originalFilename = URLDecoder.decode( - headResponse.metadata().get("original-filename"), StandardCharsets.UTF_8); + // Метадата могла быть утеряна/отсутствовать (например, объект залит не через uploadFile) — + // в этом случае используем сам ключ объекта как имя файла, а не падаем с NPE. + String rawFilename = headResponse.metadata().get(ORIGINAL_FILENAME_METADATA_KEY); + String originalFilename = (rawFilename != null && !rawFilename.isBlank()) + ? URLDecoder.decode(rawFilename, StandardCharsets.UTF_8) + : objectName; String contentType = headResponse.contentType() != null ? headResponse.contentType() @@ -168,9 +161,7 @@ public class FileStorageService { } catch (NoSuchKeyException e) { log.warn("Файл не найден: {}", objectName); throw new ApiException(ErrorCode.NOT_FOUND, "Файл не найден.", HttpStatus.NOT_FOUND); - } catch (ApiException e) { - throw e; - } catch (Exception e) { + } catch (S3Exception | SdkClientException e) { log.error("Ошибка при скачивании файла '{}': {}", objectName, e.getMessage(), e); throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка при скачивании файла.", HttpStatus.INTERNAL_SERVER_ERROR); } @@ -182,6 +173,14 @@ public class FileStorageService { throw new ApiException(ErrorCode.VALIDATION_ERROR, "Файл не может быть пустым.", HttpStatus.BAD_REQUEST); } + // getOriginalFilename() по контракту MultipartFile может вернуть null — + // без этой проверки URLEncoder.encode(null, ...) в uploadFile упадёт с NPE. + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.isBlank()) { + log.warn("Файл без оригинального имени."); + throw new ApiException(ErrorCode.VALIDATION_ERROR, "Имя файла отсутствует.", HttpStatus.BAD_REQUEST); + } + if (file.getSize() > MAX_FILE_SIZE) { log.warn("Файл превышает допустимый размер: {} байт.", file.getSize()); throw new ApiException(ErrorCode.VALIDATION_ERROR, "Файл слишком большой (максимум 10 МБ).", HttpStatus.BAD_REQUEST); diff --git a/src/main/java/com/krylov/refound/service/GeocodingService.java b/src/main/java/com/krylov/refound/service/GeocodingService.java new file mode 100644 index 0000000..56f7fc6 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/GeocodingService.java @@ -0,0 +1,119 @@ +package com.krylov.refound.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.krylov.refound.dto.AddressInfo; +import com.krylov.refound.enums.ErrorCode; +import com.krylov.refound.exception.ApiException; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class GeocodingService { + + private final ObjectMapper mapper; + + // Поля address-объекта Nominatim, которые в сумме считаем "городом" (в порядке приоритета) + private static final List CITY_FIELDS = List.of("city", "town", "village", "municipality"); + + // HttpClient потокобезопасен и предназначен для переиспользования — не создаём его на каждый запрос + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + /** + * Определяет город и остальную часть адреса по координатам через Nominatim reverse geocoding. + */ + public AddressInfo getAddress(String lat, String lon) { + String url = "https://nominatim.openstreetmap.org/reverse" + + "?lat=" + encode(lat) + + "&lon=" + encode(lon) + + "&format=jsonv2" + + "&addressdetails=1"; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(5)) + .header("User-Agent", "MyApp/1.0") + .GET() + .build(); + + JsonNode root; + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + log.warn("Nominatim вернул статус {} для lat={}, lon={}", response.statusCode(), lat, lon); + throw new ApiException(ErrorCode.INTERNAL_ERROR, "Сервис геокодирования недоступен.", HttpStatus.SERVICE_UNAVAILABLE); + } + root = mapper.readTree(response.body()); + } catch (IOException e) { + log.error("Ошибка при обращении к Nominatim: {}", e.getMessage(), e); + throw new ApiException(ErrorCode.INTERNAL_ERROR, "Сервис геокодирования недоступен.", HttpStatus.SERVICE_UNAVAILABLE); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ApiException(ErrorCode.INTERNAL_ERROR, "Запрос к сервису геокодирования прерван.", HttpStatus.INTERNAL_SERVER_ERROR); + } + + return toAddressInfo(root); + } + + private AddressInfo toAddressInfo(JsonNode root) { + JsonNode address = root.path("address"); + String displayName = root.path("display_name").asText(""); + + String city = CITY_FIELDS.stream() + .map(field -> address.path(field).asText(null)) + .filter(value -> value != null && !value.isBlank()) + .findFirst() + .orElse(null); + + String district = buildDistrict(displayName, city); + + return new AddressInfo(city, district); + } + + /** + * display_name — это склеенные через запятую компоненты адреса от самого точного к самому общему. + * Убираем из этого списка компонент, совпавший с городом, и склеиваем остальное обратно. + */ + private String buildDistrict(String displayName, String city) { + if (displayName.isBlank()) { + return null; + } + + String[] parts = displayName.split(","); + StringBuilder result = new StringBuilder(); + boolean cityRemoved = false; + + for (String part : parts) { + String trimmed = part.trim(); + if (!cityRemoved && trimmed.equalsIgnoreCase(city)) { + cityRemoved = true; + continue; + } + if (!result.isEmpty()) { + result.append(", "); + } + result.append(trimmed); + } + + return result.isEmpty() ? null : result.toString(); + } + + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/MessageService.java b/src/main/java/com/krylov/refound/service/MessageService.java deleted file mode 100644 index ed4c64c..0000000 --- a/src/main/java/com/krylov/refound/service/MessageService.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.krylov.refound.service; - -import com.krylov.refound.dto.DialogResponse; -import com.krylov.refound.entity.Message; -import com.krylov.refound.entity.User; -import com.krylov.refound.enums.ErrorCode; -import com.krylov.refound.exception.ApiException; -import com.krylov.refound.repository.MessageRepository; -import com.krylov.refound.repository.UserRepository; -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class MessageService { - -} diff --git a/src/main/java/com/krylov/refound/service/ModerationService.java b/src/main/java/com/krylov/refound/service/ModerationService.java deleted file mode 100644 index 7030e1c..0000000 --- a/src/main/java/com/krylov/refound/service/ModerationService.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.krylov.refound.service; - -import org.springframework.stereotype.Service; - -@Service -public class ModerationService { - -} diff --git a/src/main/java/com/krylov/refound/service/NominatimService.java b/src/main/java/com/krylov/refound/service/NominatimService.java deleted file mode 100644 index 2bab0df..0000000 --- a/src/main/java/com/krylov/refound/service/NominatimService.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.krylov.refound.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class NominatimService { - private final ObjectMapper mapper; - - public String getAddress(String lat, String lon) { - String url = "https://nominatim.openstreetmap.org/reverse?lat=" + lat + "&lon=" + lon + "&format=json"; - - HttpClient client = HttpClient.newHttpClient(); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .header("User-Agent", "MyApp/1.0") - .build(); - JsonNode root; - try{ - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - root = mapper.readTree(response.body()); - } catch (InterruptedException | IOException e){ - throw new RuntimeException(e); - } - - return root.path("display_name").asText(); - } -} diff --git a/src/main/java/com/krylov/refound/service/PostService.java b/src/main/java/com/krylov/refound/service/PostService.java index 73eeb19..4caec85 100644 --- a/src/main/java/com/krylov/refound/service/PostService.java +++ b/src/main/java/com/krylov/refound/service/PostService.java @@ -3,6 +3,7 @@ package com.krylov.refound.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.krylov.refound.ai.facade.ContentModerationFacade; +import com.krylov.refound.dto.AddressInfo; import com.krylov.refound.dto.PostRequest; import com.krylov.refound.dto.PostResponse; import com.krylov.refound.entity.Image; @@ -18,22 +19,16 @@ import com.krylov.refound.repository.FavoriteRepository; import com.krylov.refound.repository.ImageRepository; import com.krylov.refound.repository.PostRepository; import com.krylov.refound.util.PostSpecification; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.time.LocalDateTime; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.UUID; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; @@ -50,7 +45,7 @@ public class PostService { private final PostRepository repository; private final PostMapper mapper; private final ImageRepository imageRepository; - private final NominatimService nominatimService; + private final GeocodingService geocodingService; private final UserService userService; private final FavoriteRepository favoriteRepository; private final FileStorageService fileStorageService; @@ -66,6 +61,10 @@ public class PostService { post.setCreatedAt(LocalDateTime.now()); post.setStatus(PostStatus.ACTIVE); post.setUser(userService.getCurrentUser()); + AddressInfo address = geocodingService.getAddress( + request.getLatitude().toString(), request.getLongitude().toString()); + post.setCity(address.city()); + post.setDistrict(address.district()); repository.save(post); if (images != null && !images.isEmpty()) { @@ -209,9 +208,11 @@ public class PostService { if (request.getLatitude() != null && request.getLongitude() != null) { post.setLatitude(request.getLatitude()); post.setLongitude(request.getLongitude()); - String address = nominatimService.getAddress( + AddressInfo address = geocodingService.getAddress( request.getLatitude().toString(), request.getLongitude().toString()); - post.setCity(address); + + post.setCity(address.city()); + post.setDistrict(address.district()); } else { post.setCity(request.getAddress()); } diff --git a/src/main/resources/db/changelog/alter/011-alter-posts-district-length.yaml b/src/main/resources/db/changelog/alter/011-alter-posts-district-length.yaml new file mode 100644 index 0000000..db9a7f4 --- /dev/null +++ b/src/main/resources/db/changelog/alter/011-alter-posts-district-length.yaml @@ -0,0 +1,9 @@ +databaseChangeLog: + - changeSet: + id: 006-alter-posts-district-length + author: you + changes: + - modifyDataType: + tableName: posts + columnName: district + newDataType: VARCHAR(500) \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index e046f6d..0659f3e 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -16,4 +16,5 @@ databaseChangeLog: - include: { file: db/changelog/add/005-add-user-avatarUrl.yaml } - include: { file: db/changelog/create/008-create-reviews.yaml } - include: { file: db/changelog/add/013-add-rules_accepted-posts.yaml} - - include: { file: db/changelog/create/009-create-statistic.yaml } \ No newline at end of file + - include: { file: db/changelog/create/009-create-statistic.yaml } + - include: { file: db/changelog/alter/011-alter-posts-district-length.yaml}