fixed GeocodingService add alter city

This commit is contained in:
SlimusMinus
2026-08-04 00:33:17 +03:00
parent 619ce7086b
commit 7429515143
12 changed files with 192 additions and 124 deletions

View File

@@ -0,0 +1,5 @@
package com.krylov.refound.dto;
public record AddressInfo(String city, String district) {
}

View File

@@ -0,0 +1,9 @@
package com.krylov.refound.dto;
public record DownloadedFile(
String originalFilename,
String contentType,
byte[] content
) {
}

View File

@@ -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());

View File

@@ -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

View File

@@ -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<String> 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<String> 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<String, String> 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);

View File

@@ -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<String> 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<String> 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);
}
}

View File

@@ -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 {
}

View File

@@ -1,8 +0,0 @@
package com.krylov.refound.service;
import org.springframework.stereotype.Service;
@Service
public class ModerationService {
}

View File

@@ -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<String> 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();
}
}

View File

@@ -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());
}