package com.krylov.refound.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.krylov.refound.dto.AddressInfo; import com.krylov.refound.dto.CachedPostPage; import com.krylov.refound.dto.PostRequest; import com.krylov.refound.dto.PostResponse; import com.krylov.refound.entity.Image; import com.krylov.refound.entity.Post; import com.krylov.refound.entity.User; import com.krylov.refound.enums.ErrorCode; import com.krylov.refound.enums.PostCategory; import com.krylov.refound.enums.PostStatus; import com.krylov.refound.enums.PostType; import com.krylov.refound.exception.ApiException; import com.krylov.refound.mapper.PostMapper; import com.krylov.refound.repository.ImageRepository; import com.krylov.refound.repository.PostRepository; import com.krylov.refound.service.redis.MapCacheService; import com.krylov.refound.service.redis.PostCacheKeyGenerator; import com.krylov.refound.service.redis.PostCacheService; import com.krylov.refound.util.PostSpecification; import com.krylov.refound.util.PostVisibility; import java.time.Duration; import java.time.LocalDateTime; import java.util.Arrays; import java.util.EnumSet; import java.util.List; import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @Slf4j public class PostService { // Статусы, которые показываются в публичной ленте. MODERATION и REJECTED видны // только владельцу (getPostsByUserId, getPostOnMapById), но не в общем фиде. private static final Set FEED_VISIBLE_STATUSES = PostVisibility.FEED_VISIBLE_STATUSES; private final PostRepository postRepository; private final PostMapper mapper; private final ImageRepository imageRepository; private final GeocodingService geocodingService; private final UserService userService; private final FileStorageService fileStorageService; private final StatisticsService statisticsService; private final ObjectMapper objectMapper; private final PostCacheService postCacheService; private final PostCacheKeyGenerator postCacheKeyGenerator; private final MapCacheService mapCacheService; @Transactional public PostRequest create(PostRequest request, List images) { User currentUser = userService.getCurrentUser(); Post post = mapper.toEntity(request); post.setCreatedAt(LocalDateTime.now()); post.setStatus(PostStatus.MODERATION); post.setUser(currentUser); post.setIsReward(request.getReward()); if (request.getLatitude() == null || request.getLongitude() == null) { post.setCity(request.getAddress()); } else { AddressInfo address = geocodingService.getAddress(request.getLatitude().toString(), request.getLongitude().toString()); post.setCity(address.city()); post.setDistrict(address.district()); } postRepository.save(post); log.info("Создан пост id={} пользователем userId={}, статус={}", post.getId(), currentUser.getId(), post.getStatus()); int uploaded = attachImages(post, images); log.info("К посту id={} прикреплено {} изображений", post.getId(), uploaded); statisticsService.incrementCreated(); postCacheService.invalidatePosts(); mapCacheService.clearMapMarkersCache(); return request; } public Page getFeed(String search, Pageable pageable) { // Ключ содержит версию ленты, она читается до запроса в БД String cacheKey = postCacheKeyGenerator.feedKey(search, pageable); CachedPostPage cached = postCacheService.getFeed(cacheKey); if (cached != null) { log.debug("Feed cache HIT: {}", cacheKey); return new PageImpl<>(cached.getContent(), pageable, cached.getTotalElements()); } log.debug("Feed cache MISS: {}", cacheKey); Specification spec = PostSpecification.hasSearch(search) .and((root, query, cb) -> root.get("status").in(FEED_VISIBLE_STATUSES) ); Page postsPage = postRepository.findAll(spec, pageable); Page responsePage = postsPage.map(post -> toResponse(post, true)); CachedPostPage cachedPage = new CachedPostPage( responsePage.getContent(), responsePage.getNumber(), responsePage.getSize(), responsePage.getTotalElements(), responsePage.getTotalPages() ); postCacheService.saveFeed(cacheKey, cachedPage, Duration.ofMinutes(2)); return responsePage; } /** * Возвращает посты указанного пользователя (это НЕ поиск поста по его собственному id, * несмотря на то, как метод назывался раньше — getById). Если у вас уже есть контроллер, * вызывающий старое имя getById, переименуйте вызов на getPostsByUserId. * Владелец видит все свои посты (включая MODERATION и REJECTED), остальные — только * опубликованные (ACTIVE и CLOSED). */ public List getPostsByUserId(Long userId) { boolean owner = isCurrentUser(userId); List posts = postRepository.findByUserId(userId).stream() .filter(post -> owner || FEED_VISIBLE_STATUSES.contains(post.getStatus())) .toList(); if (posts.isEmpty()) { return List.of(); } return posts.stream() .map(post -> toResponse(post, false)) .toList(); } /** * Удобный overload для контроллера, принимающего multipart-поля по отдельности. * @Transactional стоит и здесь, и на основном update() — раньше этот метод вызывал * update(id, request, images, existingUrls) через this, что было self-invocation * и обходило Spring-прокси: @Transactional на вызываемом методе не срабатывал. */ @Transactional public PostResponse update(Long id, String type, String title, String address, Double latitude, Double longitude, String description, String category, String phone, Boolean reward, String rewardText, List images, String existingImagesJson) { PostRequest request = new PostRequest(); request.setType(PostType.from(type)); request.setTitle(title); request.setDescription(description); request.setCategory(category); request.setPhone(phone); request.setLatitude(latitude); request.setLongitude(longitude); request.setAddress(address); request.setReward(reward); request.setRewardText(rewardText); List existingUrls = parseExistingImages(existingImagesJson); return doUpdate(id, request, images, existingUrls); } private PostResponse doUpdate(Long id, PostRequest request, List images, List existingUrls) { // Проверка владельца выполняется до любых изменений и загрузки файлов Post post = findOwnedPost(id); post.setStatus(PostStatus.MODERATION); post.setTitle(request.getTitle()); post.setDescription(request.getDescription()); if (request.getCategory() != null) { post.setCategory(PostCategory.fromDisplayName(request.getCategory())); } post.setPhone(request.getPhone()); post.setType(request.getType()); post.setIsReward(request.getReward()); post.setReward(request.getRewardText()); if (request.getLatitude() != null && request.getLongitude() != null) { post.setLatitude(request.getLatitude()); post.setLongitude(request.getLongitude()); AddressInfo address = geocodingService.getAddress( request.getLatitude().toString(), request.getLongitude().toString()); post.setCity(address.city()); post.setDistrict(address.district()); } else { post.setCity(request.getAddress()); } List keepUrls = existingUrls != null ? existingUrls : List.of(); List toRemove = post.getImages().stream() .filter(img -> !keepUrls.contains(img.getUrl())) .toList(); removeImages(post, toRemove); int uploaded = attachImages(post, images); Post updated = postRepository.save(post); log.info("Обновлён пост id={}: удалено изображений={}, добавлено={}, статус={}", id, toRemove.size(), uploaded, updated.getStatus()); postCacheService.invalidatePosts(); mapCacheService.clearMapMarkersCache(); log.info("redis cache invalidated in update"); return mapper.toResponse(updated); } @Transactional public void delete(Long id) { Post post = findOwnedPost(id); // Без этого файлы останутся в MinIO мусором после удаления объявления post.getImages().forEach(img -> deleteFileQuietly(img.getUrl())); postRepository.delete(post); log.info("Удалён пост id={}, изображений={}", id, post.getImages().size()); postCacheService.invalidatePosts(); mapCacheService.clearMapMarkersCache(); log.info("redis cache invalidated in delete"); } @Transactional public PostResponse updateStatus(Long id, String statusString) { Post post = findOwnedPost(id); // Пост на модерации или отклонённый владелец не может сам перевести в ACTIVE: // иначе можно обойти модерацию (edit -> MODERATION -> updateStatus(ACTIVE)) if (!FEED_VISIBLE_STATUSES.contains(post.getStatus())) { log.warn("Попытка сменить статус неопубликованного поста: postId={}, статус={}", id, post.getStatus()); throw new ApiException(ErrorCode.FORBIDDEN, "Post is not published yet", HttpStatus.FORBIDDEN); } PostStatus newStatus = parseOwnerSettableStatus(statusString); if (post.getStatus() != PostStatus.CLOSED && newStatus == PostStatus.CLOSED) { statisticsService.incrementFound(); } PostStatus previousStatus = post.getStatus(); post.setStatus(newStatus); Post updated = postRepository.save(post); log.info("Статус поста id={} изменён: {} -> {}", id, previousStatus, newStatus); postCacheService.invalidatePosts(); mapCacheService.clearMapMarkersCache(); log.info("redis cache invalidated in update status"); return mapper.toResponse(updated); } public List searchByCity(String city) { if (!StringUtils.hasText(city)) { return List.of(); } Specification spec = Specification.where(PostSpecification.cityOrDistrictLike(city)) .and((root, query, cb) -> root.get("status").in(FEED_VISIBLE_STATUSES)); List posts = postRepository.findAll(spec); if (posts.isEmpty()) { return List.of(); } return posts.stream() .map(post -> toResponse(post, true)) .toList(); } public List getCities() { return postRepository.findDistinctCities(FEED_VISIBLE_STATUSES); } /** * Находит пост и проверяет, что текущий пользователь — его владелец. * NOT_FOUND, если поста нет; FORBIDDEN, если пост чужой. */ private Post findOwnedPost(Long id) { Post post = postRepository.findById(id) .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND)); User currentUser = userService.getCurrentUser(); if (!post.getUser().getId().equals(currentUser.getId())) { log.warn("Попытка изменить чужой пост: postId={}, userId={}", id, currentUser.getId()); throw new ApiException(ErrorCode.FORBIDDEN, "Only the post owner can modify this post", HttpStatus.FORBIDDEN); } return post; } /** * true, если запрос выполняет авторизованный пользователь с указанным id. * Для анонимного запроса возвращает false. */ private boolean isCurrentUser(Long userId) { try { User currentUser = userService.getCurrentUser(); return currentUser != null && currentUser.getId().equals(userId); } catch (ApiException e) { return false; } } private PostStatus parseOwnerSettableStatus(String statusString) { PostStatus newStatus; try { newStatus = PostStatus.valueOf(statusString.toUpperCase()); } catch (IllegalArgumentException e) { throw new ApiException(ErrorCode.VALIDATION_ERROR, "Invalid status: " + statusString + ". Must be ACTIVE or CLOSED", HttpStatus.BAD_REQUEST); } if (!FEED_VISIBLE_STATUSES.contains(newStatus)) { throw new ApiException(ErrorCode.VALIDATION_ERROR, "Invalid status: " + statusString + ". Must be ACTIVE or CLOSED", HttpStatus.BAD_REQUEST); } return newStatus; } /** * Загружает файлы в MinIO и привязывает их к посту. Возвращает количество загруженных файлов. */ private int attachImages(Post post, List images) { if (images == null || images.isEmpty()) { return 0; } int count = 0; for (MultipartFile image : images) { if (image == null || image.isEmpty()) { continue; } String objectKey = fileStorageService.uploadFile(image); Image img = new Image(); img.setUrl(objectKey); // храним ключ MinIO, а не путь на диске img.setPost(post); imageRepository.save(img); post.getImages().add(img); count++; } return count; } private void removeImages(Post post, List toRemove) { toRemove.forEach(img -> deleteFileQuietly(img.getUrl())); post.getImages().removeAll(toRemove); } private void deleteFileQuietly(String objectKey) { try { fileStorageService.deleteFile(objectKey); } catch (Exception e) { // Не блокируем основную операцию из-за мусора в хранилище — это некритично, // но стоит завести алерт на такие warn-логи, если их станет много. log.warn("Не удалось удалить файл из хранилища: {}", objectKey, e); } } public PostResponse toResponse(Post post, boolean includeUserRole) { PostResponse response = mapper.toResponse(post); response.setReward(post.getIsReward()); if (includeUserRole) { response.setUserRole(post.getUser().getRole()); } return response; } private List parseExistingImages(String existingImagesJson) { if (existingImagesJson == null) { return List.of(); } try { return Arrays.asList(objectMapper.readValue(existingImagesJson, String[].class)); } catch (JsonProcessingException e) { throw new ApiException(ErrorCode.VALIDATION_ERROR, "Некорректный формат existingImages", HttpStatus.BAD_REQUEST); } } public PostResponse getPostOnMapById(Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND)); // Неопубликованный пост (MODERATION, REJECTED) виден только владельцу. // Остальным отвечаем 404, чтобы не раскрывать, что такой id существует if (!FEED_VISIBLE_STATUSES.contains(post.getStatus()) && !isCurrentUser(post.getUser().getId())) { throw new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND); } return toResponse(post, true); } }