@@ -2,7 +2,6 @@ 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 ;
@@ -21,6 +20,7 @@ import com.krylov.refound.repository.PostRepository;
import com.krylov.refound.util.PostSpecification ;
import java.time.LocalDateTime ;
import java.util.Arrays ;
import java.util.EnumSet ;
import java.util.HashSet ;
import java.util.List ;
import java.util.Map ;
@@ -28,66 +28,63 @@ import java.util.Set;
import java.util.stream.Collectors ;
import lombok.RequiredArgsConstructor ;
import lombok.extern.slf4j.Slf4j ;
import org.springframework.cache.annotation.CacheEvict ;
import org.springframework.data.domain.Page ;
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, но не в общем фиде.
private static final Set < PostStatus > FEED_VISIBLE_STATUSES = EnumSet . of ( PostStatus . ACTIVE , PostStatus . CLOSED ) ;
private final PostRepository r epository;
private final PostRepository postR epository;
private final PostMapper mapper ;
private final ImageRepository imageRepository ;
private final GeocodingService geocodingService ;
private final UserService userService ;
private final FavoriteRepository favoriteRepository ;
private final FileStorageService fileStorageService ;
private final ContentModerationFacade contentModerationFacade ;
private final StatisticsService statisticsService ;
private final ObjectMapper objectMapper ;
@Transactional
public PostRequest create ( PostRequest request , List < MultipartFile > images ) {
contentModerationFacade . moderate ( request . getTitle ( ) , request . getDescription ( ) , images ) ;
User currentUser = userService . getCurrentUser ( ) ;
Post post = mapper . toEntity ( request ) ;
post . setCreatedAt ( LocalDateTime . now ( ) ) ;
post . setStatus ( PostStatus . ACTIVE ) ;
post . setUser ( userService . getC urrentUser( ) );
AddressInfo address = geocodingService . getAddress (
reque st . g etLatitude ( ) . toString ( ) , request . getLongitude ( ) . toString ( ) ) ;
post . setCity ( address . city ( ) ) ;
post . setDis trict ( address . dis trict ( ) ) ;
repository . save ( post ) ;
if ( images ! = null & & ! images . isEmpty ( ) ) {
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 . setStatus ( PostStatus . MODERATION ) ;
post . setUser ( c urrentUser) ;
if ( request . getLatitude ( ) = = null | | request . getLongitude ( ) = = null ) {
po st . s etCity ( request . getAddress ( ) ) ;
} else {
AddressInfo address = geocodingService . getAddress ( request . getLatitude ( ) . toS tring ( ) , request . getLongitude ( ) . toS tring ( ) ) ;
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 ( ) ;
return request ;
}
public Page < PostResponse > getFeed ( String search , Pageable pageable ) {
Specification < Post > spec = PostSpecification . hasSearch ( search ) ;
Page < Post > postsPage = r epository. findAll ( spec , pageable ) ;
Specification < Post > spec = PostSpecification . hasSearch ( search ) . and ( ( root , query , cb ) - > root . get ( " status " ) . in ( FEED_VISIBLE_STATUSES ) ) ;
Page < Post > postsPage = postR epository. findAll ( spec , pageable ) ;
List < Long > postIds = postsPage . getContent ( ) . stream ( )
. map ( Post : : getId )
@@ -97,78 +94,47 @@ public class PostService {
return Page . empty ( pageable ) ;
}
Map < Long , Long > likeCounts = favoriteRepository . countLikesByPostIds ( postIds ) . stream ( )
. collect ( Collectors . toMap (
row - > ( Long ) row [ 0 ] ,
row - > ( Long ) row [ 1 ]
) ) ;
Map < Long , Long > likeCounts = countLikes ( postIds ) ;
Set < Long > userFavoriteIds = currentUserFavoriteIds ( postIds ) ;
Set < Long > userFavoriteIds = new HashSet < > ( ) ;
try {
User currentUser = userService . getCurrentUser ( ) ;
userFavoriteIds = new HashSet < > (
favoriteRepository . findFavoritePostIds ( currentUser . getId ( ) , postIds )
) ;
} catch ( Exception e ) {
// User not authenticated - that's OK
}
final Set < Long > finalUserFavoriteIds = userFavoriteIds ;
return postsPage . map ( post - > {
PostResponse response = mapper . toResponse ( post ) ;
response . setLikeCount ( likeCounts . getOrDefault ( post . getId ( ) , 0L ) ) ;
response . setFavorite ( finalUserFavoriteIds . contains ( post . getId ( ) ) ) ;
response . setUserRole ( post . getUser ( ) . getRole ( ) ) ;
return response ;
} ) ;
return postsPage . map ( post - > toResponse ( post , likeCounts , userFavoriteIds , true ) ) ;
}
public List < PostResponse > getById ( Long id ) {
List < Post > posts = repository . findByUserId ( id ) ;
/**
* Возвращает все посты указанного пользователя (это Н Е поиск поста по е г о собственному id,
* несмотря на то, как метод назывался раньше — getById). Если у вас уже есть контроллер,
* вызывающий старое имя getById, переименуйте вызов на getPostsByUserId.
*/
public List < PostResponse > getPostsByUserId ( Long userId ) {
List < Post > posts = postRepository . findByUserId ( userId ) . stream ( )
. filter ( post - > post . getStatus ( ) = = PostStatus . ACTIVE | | post . getStatus ( ) = = PostStatus . CLOSED )
. toList ( ) ;
if ( posts . isEmpty ( ) ) {
return List . of ( ) ;
}
List < Long > postIds = posts . stream ( )
. map ( Post : : getId )
. toList ( ) ;
Map < Long , Long > likeCounts = favoriteRepository . countLikesByPostIds ( postIds ) . stream ( )
. collect ( Collectors . toMap (
row - > ( Long ) row [ 0 ] ,
row - > ( Long ) row [ 1 ]
) ) ;
Map < Long , Long > likeCounts = countLikes ( postIds ) ;
Set < Long > userFavoriteIds = currentUserFavoriteIds ( postIds ) ;
Set < Long > userFavoriteIds = new HashSet < > ( ) ;
try {
User currentUser = userService . getCurrentUser ( ) ;
userFavoriteIds = new HashSet < > (
favoriteRepository . findFavoritePostIds ( currentUser . getId ( ) , postIds )
) ;
} catch ( Exception e ) {
// User not authenticated
}
final Set < Long > finalUserFavoriteIds = userFavoriteIds ;
return posts . stream ( )
. map ( post - > {
PostResponse response = mapper . toResponse ( post ) ;
response . setLikeCount ( likeCounts . getOrDefault ( post . getId ( ) , 0L ) ) ;
response . setFavorite ( finalUserFavoriteIds . contains ( post . getId ( ) ) ) ;
return response ;
} )
. map ( post - > toResponse ( post , likeCounts , userFavoriteIds , false ) )
. toList ( ) ;
}
public PostResponse update (
Long id ,
String type ,
String title ,
String address ,
Double latitude ,
Double longitude ,
String description ,
String category ,
String phone ,
List < MultipartFile > images ,
String existingImagesJson ) {
/**
* Удобный 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 , List < MultipartFile > images , String existingImagesJson ) {
PostRequest request = new PostRequest ( ) ;
request . setType ( PostType . from ( type ) ) ;
@@ -182,21 +148,14 @@ public class PostService {
List < String > existingUrls = parseExistingImages ( existingImagesJson ) ;
return u pdate( id , request , images , existingUrls ) ;
return doU pdate( id , request , images , existingUrls ) ;
}
// старый метод остаётся как есть — рефакторить будем позже
public PostResponse update ( Long id , P ostRequest request , List < MultipartFile > images ,
List < String > existingUrls ) {
Post post = repository . findById ( id )
. orElseThrow ( ( ) - > new ApiException (
ErrorCode . NOT_FOUND ,
" Post not found " ,
HttpStatus . NOT_FOUND
) ) ;
contentModerationFacade . moderate ( request . getTitle ( ) , request . getDescription ( ) , images ) ;
private PostResponse doUpdate ( Long id , PostRequest request , List < MultipartFile > images , List < String > existingUrls ) {
Post post = p ostRepository . findById ( id )
. orElseThrow ( ( ) - > new ApiException ( ErrorCode . NOT_FOUND , " Post not found " , HttpStatus . NOT_FOUND ) ) ;
post . setStatus ( PostStatus . MODERATION ) ;
post . setTitle ( request . getTitle ( ) ) ;
post . setDescription ( request . getDescription ( ) ) ;
if ( request . getCategory ( ) ! = null ) {
@@ -223,33 +182,189 @@ public class PostService {
. filter ( img - > ! keepUrls . contains ( img . getUrl ( ) ) )
. toList ( ) ;
toR emove. forEach ( img - > {
try {
fileStorageService . deleteFile ( img . getUrl ( ) ) ;
} catch ( Exception e ) {
log . warn ( " Н е удалось удалить файл из хранилища: {}" , img . getUrl ( ) , e ) ;
}
} ) ;
r emoveImages ( post , toRemove ) ;
post . getImages ( ) . removeAll ( toRemove ) ;
int uploaded = attachImages ( post , images ) ;
if ( images ! = null & & ! images . isEmpty ( ) ) {
for ( MultipartFile image : images ) {
if ( image = = null | | image . isEmpty ( ) ) {
continue ;
}
Post updated = postRepository . save ( post ) ;
log . info ( " Обновлён пост id={}: удалено изображений={}, добавлено={}, статус={} " , id , toRemove . size ( ) , uploaded , updated . getStatus ( ) ) ;
String objectKey = fileStorageService . uploadFile ( image ) ;
return mapper . toResponse ( updated ) ;
}
Image img = new Image ( ) ;
img . setUrl ( objectKey ) ;
img . setPost ( post ) ;
post . getImages ( ) . add ( img ) ;
}
@Transactional
public void delete ( Long id ) {
Post post = postRepository . findById ( id )
. orElseThrow ( ( ) - > new ApiException ( ErrorCode . NOT_FOUND , " Post not found " , HttpStatus . NOT_FOUND ) ) ;
// Сначала избранное — иначе DataIntegrityViolationException (FK) при удалении поста
favoriteRepository . deleteByPostIn ( List . of ( post ) ) ;
// Без этого файлы останутся в MinIO мусором после удаления объявления
post . getImages ( ) . forEach ( img - > deleteFileQuietly ( img . getUrl ( ) ) ) ;
postRepository . delete ( post ) ;
log . info ( " Удалён пост id={}, изображений={} " , id , post . getImages ( ) . size ( ) ) ;
}
@Transactional
public PostResponse updateStatus ( Long id , String statusString ) {
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 update status " , HttpStatus . FORBIDDEN ) ;
}
Post updated = repository . save ( post ) ;
return mapper . toResponse ( updated ) ;
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 ) ;
List < Long > postIds = List . of ( id ) ;
PostResponse response = mapper . toResponse ( updated ) ;
response . setLikeCount ( countLikes ( postIds ) . getOrDefault ( id , 0L ) ) ;
response . setFavorite ( currentUserFavoriteIds ( postIds ) . contains ( id ) ) ;
return response ;
}
public List < PostResponse > searchByCity ( String city ) {
if ( ! StringUtils . hasText ( city ) ) {
return List . of ( ) ;
}
Specification < Post > spec = Specification . where ( PostSpecification . cityOrDistrictLike ( city ) )
. and ( ( root , query , cb ) - > root . get ( " status " ) . in ( FEED_VISIBLE_STATUSES ) ) ;
List < Post > posts = postRepository . findAll ( spec ) ;
if ( posts . isEmpty ( ) ) {
return List . of ( ) ;
}
List < Long > postIds = posts . stream ( )
. map ( Post : : getId )
. toList ( ) ;
Map < Long , Long > likeCounts = countLikes ( postIds ) ;
Set < Long > userFavoriteIds = currentUserFavoriteIds ( postIds ) ;
return posts . stream ( )
. map ( post - > toResponse ( post , likeCounts , userFavoriteIds , true ) )
. toList ( ) ;
}
public List < String > getCities ( ) {
return postRepository . findDistinctCities ( FEED_VISIBLE_STATUSES ) ;
}
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 < MultipartFile > 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 < Image > 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 ) ;
}
}
private Map < Long , Long > countLikes ( List < Long > postIds ) {
if ( postIds . isEmpty ( ) ) {
return Map . of ( ) ;
}
return favoriteRepository . countLikesByPostIds ( postIds ) . stream ( )
. collect ( Collectors . toMap (
row - > ( Long ) row [ 0 ] ,
row - > ( Long ) row [ 1 ]
) ) ;
}
/**
* Возвращает id постов, лайкнутых текущим пользователем, либо пустой набор,
* если пользователь не авторизован.
*/
private Set < Long > currentUserFavoriteIds ( List < Long > postIds ) {
if ( postIds . isEmpty ( ) ) {
return Set . of ( ) ;
}
try {
User currentUser = userService . getCurrentUser ( ) ;
return new HashSet < > ( favoriteRepository . findFavoritePostIds ( currentUser . getId ( ) , postIds ) ) ;
} catch ( ApiException e ) {
if ( e . getErrorCode ( ) = = ErrorCode . UNAUTHORIZED ) {
// Ожидаемо для анонимных пользователей — не считаем ошибкой
log . debug ( " Избранное не подгружено: пользователь не авторизован " ) ;
} else {
// Подозрительный случай: в SecurityContext есть аутентификация с логином,
// но пользователя с таким логином нет в БД (например, удалён после выдачи токена)
log . warn ( " Избранное не подгружено: {} " , e . getMessage ( ) ) ;
}
return Set . of ( ) ;
}
}
private PostResponse toResponse ( Post post , Map < Long , Long > likeCounts , Set < Long > userFavoriteIds , boolean includeUserRole ) {
PostResponse response = mapper . toResponse ( post ) ;
response . setLikeCount ( likeCounts . getOrDefault ( post . getId ( ) , 0L ) ) ;
response . setFavorite ( userFavoriteIds . contains ( post . getId ( ) ) ) ;
if ( includeUserRole ) {
response . setUserRole ( post . getUser ( ) . getRole ( ) ) ;
}
return response ;
}
private List < String > parseExistingImages ( String existingImagesJson ) {
@@ -259,95 +374,7 @@ public class PostService {
try {
return Arrays . asList ( objectMapper . readValue ( existingImagesJson , String [ ] . class ) ) ;
} catch ( JsonProcessingException e ) {
throw new ApiException (
ErrorCode . VALIDATION_ERROR ,
" Некорректный формат existingImages " ,
HttpStatus . BAD_REQUEST
) ;
throw new ApiException ( ErrorCode . VALIDATION_ERROR , " Некорректный формат existingImages " , HttpStatus . BAD_REQUEST ) ;
}
}
@CacheEvict ( value = { " posts " , " post " } , allEntries = true )
public void delete ( Long id ) {
Post post = repository . findById ( id )
. orElseThrow ( ( ) - > new ApiException (
ErrorCode . NOT_FOUND ,
" Post not found " ,
HttpStatus . NOT_FOUND
) ) ;
// без этого файлы останутся в MinIO мусором после удаления объявления
post . getImages ( ) . forEach ( img - > {
try {
fileStorageService . deleteFile ( img . getUrl ( ) ) ;
} catch ( Exception e ) {
log . warn ( " Н е удалось удалить файл из хранилища: {}" , img . getUrl ( ) , e ) ;
}
} ) ;
repository . delete ( post ) ;
}
@Transactional
public PostResponse updateStatus ( Long id , String statusString ) {
// Find the post
Post post = repository . 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 ( ) ) ) {
throw new ApiException (
ErrorCode . FORBIDDEN ,
" Only the post owner can update status " ,
HttpStatus . FORBIDDEN
) ;
}
// Parse status string to enum
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 ( post . getStatus ( ) ! = PostStatus . CLOSED & & statusString . equals ( PostStatus . CLOSED . toString ( ) ) ) {
statisticsService . incrementFound ( ) ;
}
// Update status
post . setStatus ( newStatus ) ;
Post updated = repository . save ( post ) ;
// Convert to response with like count
PostResponse response = mapper . toResponse ( updated ) ;
// Count likes for this post
List < Long > postIds = List . of ( id ) ;
Map < Long , Long > likeCounts = favoriteRepository . countLikesByPostIds ( postIds ) . stream ( )
. collect ( Collectors . toMap (
row - > ( Long ) row [ 0 ] ,
row - > ( Long ) row [ 1 ]
) ) ;
response . setLikeCount ( likeCounts . getOrDefault ( id , 0L ) ) ;
// Check if current user has favorited this post
try {
List < Long > favoriteIds = favoriteRepository . findFavoritePostIds ( currentUser . getId ( ) , postIds ) ;
response . setFavorite ( favoriteIds . contains ( id ) ) ;
} catch ( Exception e ) {
response . setFavorite ( false ) ;
}
return response ;
}
}
}