diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetPostDetailResponse.java b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetPostDetailResponse.java new file mode 100644 index 00000000..47741d51 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetPostDetailResponse.java @@ -0,0 +1,36 @@ +package com.campus.campus.domain.councilpost.application.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import com.campus.campus.domain.councilpost.domain.entity.PostCategory; +import com.campus.campus.domain.councilpost.domain.entity.ThumbnailIcon; +import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.Builder; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public record GetPostDetailResponse( + Long id, + Long writerId, + String writerName, + Boolean isWriter, + + PostCategory category, + String title, + String content, + SavedPlaceInfo place, + String detailedLocation, + LocalDate startDate, + LocalDate endDate, + LocalDateTime startDateTime, + + String thumbnailImageUrl, + ThumbnailIcon thumbnailIcon, + + List images +) { +} diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java b/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java index 5e1ca0c5..334b67cb 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java @@ -11,6 +11,7 @@ import com.campus.campus.domain.councilpost.application.dto.request.PostRequest; import com.campus.campus.domain.councilpost.application.dto.response.GetActivePartnershipListForUserResponse; import com.campus.campus.domain.councilpost.application.dto.response.GetLikedPostResponse; +import com.campus.campus.domain.councilpost.application.dto.response.GetPostDetailResponse; import com.campus.campus.domain.councilpost.application.dto.response.GetPostForUserResponse; import com.campus.campus.domain.councilpost.application.dto.response.GetPostListForCouncilResponse; import com.campus.campus.domain.councilpost.application.dto.response.GetPostResponse; @@ -20,6 +21,7 @@ import com.campus.campus.domain.councilpost.domain.entity.LikePost; import com.campus.campus.domain.councilpost.domain.entity.PostImage; import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; +import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.user.domain.entity.User; @@ -102,6 +104,33 @@ public GetPostResponse toGetPostResponse(StudentCouncilPost post, List i return builder.build(); } + public GetPostDetailResponse toGetPostDetailResponse(StudentCouncilPost post, List images, + List placeImageUrls, Long currentCouncilId) { + var writer = post.getWriter(); + var builder = GetPostDetailResponse.builder() + .id(post.getId()) + .writerId(writer.getId()) + .writerName(writer.getCouncilName()) + .isWriter(post.isWrittenByCouncil(currentCouncilId)) + .category(post.getCategory()) + .title(post.getTitle()) + .content(post.getContent()) + .place(toSavedPlaceInfo(post.getPlace(), placeImageUrls)) + .detailedLocation(post.getDetailedLocation()) + .thumbnailImageUrl(post.getThumbnailImageUrl()) + .thumbnailIcon(post.getThumbnailIcon()) + .images(images != null ? images : Collections.emptyList()); + + if (post.isEvent()) { + builder.startDateTime(post.getStartDateTime()); + } else { + builder.startDate(post.getDisplayStartDate()); + builder.endDate(post.getDisplayEndDate()); + } + + return builder.build(); + } + public GetPostForUserResponse toGetPostForUserResponse(StudentCouncilPost post, List images, Long currentUserId, boolean isLiked) { var writer = post.getWriter(); @@ -188,4 +217,21 @@ public CouncilPostCreatedEvent createPostCreatedEvent(StudentCouncilPost post, S topic ); } + + private SavedPlaceInfo toSavedPlaceInfo(Place place, List imageUrls) { + if (place == null) { + return null; + } + + return new SavedPlaceInfo( + place.getPlaceName(), + place.getPlaceKey(), + place.getAddress(), + place.getPlaceCategory(), + place.getNaverPlaceUrl(), + place.getPhone(), + place.getCoordinate(), + imageUrls != null ? imageUrls : Collections.emptyList() + ); + } } diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java b/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java index d91e749b..c5235bb7 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java @@ -2,7 +2,10 @@ import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; @@ -16,6 +19,7 @@ import com.campus.campus.domain.council.domain.entity.StudentCouncil; import com.campus.campus.domain.council.domain.repository.StudentCouncilRepository; import com.campus.campus.domain.councilpost.application.dto.request.PostRequest; +import com.campus.campus.domain.councilpost.application.dto.response.GetPostDetailResponse; import com.campus.campus.domain.councilpost.application.dto.response.GetPostListForCouncilResponse; import com.campus.campus.domain.councilpost.application.dto.response.GetPostResponse; import com.campus.campus.domain.councilpost.application.dto.response.GetUpcomingEventListForCouncilResponse; @@ -23,7 +27,6 @@ import com.campus.campus.domain.councilpost.application.exception.NotPostWriterException; import com.campus.campus.domain.councilpost.application.exception.PostImageLimitExceededException; import com.campus.campus.domain.councilpost.application.exception.PostNotFoundException; -import com.campus.campus.domain.councilpost.application.exception.PostOciImageDeleteFailedException; import com.campus.campus.domain.councilpost.application.exception.ThumbnailRequiredException; import com.campus.campus.domain.councilpost.application.mapper.StudentCouncilPostMapper; import com.campus.campus.domain.councilpost.domain.entity.PostCategory; @@ -33,6 +36,8 @@ import com.campus.campus.domain.councilpost.domain.repository.StudentCouncilPostRepository; import com.campus.campus.domain.place.application.service.PlaceService; import com.campus.campus.domain.place.domain.entity.Place; +import com.campus.campus.domain.place.domain.entity.PlaceImages; +import com.campus.campus.domain.place.domain.repository.PlaceImagesRepository; import com.campus.campus.global.oci.application.service.PresignedUrlService; import lombok.RequiredArgsConstructor; @@ -48,6 +53,7 @@ public class StudentCouncilPostService { private final StudentCouncilPostRepository postRepository; private final StudentCouncilRepository studentCouncilRepository; private final PostImageRepository postImageRepository; + private final PlaceImagesRepository placeImagesRepository; private final PresignedUrlService presignedUrlService; private final StudentCouncilPostMapper studentCouncilPostMapper; private final ApplicationEventPublisher eventPublisher; @@ -96,7 +102,7 @@ public GetPostResponse create(Long councilId, PostRequest dto) { } @Transactional(readOnly = true) - public GetPostResponse findById(Long postId, Long currentUserId) { + public GetPostDetailResponse findById(Long postId, Long currentCouncilId) { StudentCouncilPost post = postRepository.findByIdWithFullInfo(postId) .orElseThrow(PostNotFoundException::new); @@ -106,7 +112,9 @@ public GetPostResponse findById(Long postId, Long currentUserId) { .map(PostImage::getImageUrl) .toList(); - return studentCouncilPostMapper.toGetPostResponse(post, imageUrls, currentUserId); + List placeImageUrls = getPlaceImageUrls(post.getPlace()); + + return studentCouncilPostMapper.toGetPostDetailResponse(post, imageUrls, placeImageUrls, currentCouncilId); } @Transactional(readOnly = true) @@ -164,7 +172,7 @@ public void delete(Long councilId, Long postId) { List postImages = postImageRepository.findAllByPost(post); - List deleteTargets = new ArrayList<>(); + Set deleteTargets = new HashSet<>(); if (post.getThumbnailImageUrl() != null) { deleteTargets.add(post.getThumbnailImageUrl()); @@ -180,7 +188,7 @@ public void delete(Long councilId, Long postId) { for (String imageUrl : deleteTargets) { try { presignedUrlService.deleteImage(imageUrl); - } catch (PostOciImageDeleteFailedException e) { + } catch (Exception e) { log.warn("OCI 파일 삭제 실패: {}", imageUrl, e); } } @@ -248,11 +256,21 @@ public GetPostResponse update(Long councilId, Long postId, PostRequest dto) { return studentCouncilPostMapper.toGetPostResponse(post, imageUrls, councilId); } + private List getPlaceImageUrls(Place place) { + if (place == null || place.getPlaceKey() == null) { + return Collections.emptyList(); + } + + return placeImagesRepository.findByPlaceKey(place.getPlaceKey()).stream() + .map(PlaceImages::getImageUrl) + .toList(); + } + //이미지 삭제 private void cleanupUnusedImages(String oldThumbnailUrl, List oldImages, PostRequest dto) { List newUrls = dto.imageUrls() == null ? List.of() : dto.imageUrls(); - List deleteTargets = new ArrayList<>(); + Set deleteTargets = new HashSet<>(); // 썸네일 변경 시 이전 썸네일 if (oldThumbnailUrl != null && !oldThumbnailUrl.equals(dto.thumbnailImageUrl())) { @@ -273,7 +291,7 @@ private void cleanupUnusedImages(String oldThumbnailUrl, List oldImag try { presignedUrlService.deleteImage(imageUrl); - } catch (PostOciImageDeleteFailedException e) { + } catch (Exception e) { log.warn("OCI 파일 삭제 실패 (파일이 없을 수 있음): {}", imageUrl, e); } } diff --git a/src/main/java/com/campus/campus/domain/councilpost/domain/entity/PostCategory.java b/src/main/java/com/campus/campus/domain/councilpost/domain/entity/PostCategory.java index a2a306f1..3471934a 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/domain/entity/PostCategory.java +++ b/src/main/java/com/campus/campus/domain/councilpost/domain/entity/PostCategory.java @@ -31,7 +31,7 @@ public NormalizedDateTime validateAndNormalize(PostRequest dto) { } return new NormalizedDateTime( dto.startDateTime().with(LocalTime.MIN), - dto.endDateTime().with(LocalTime.MAX) + dto.endDateTime().with(LocalTime.of(23, 59, 59)) ); } }; diff --git a/src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java b/src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java index 51e95235..9a6e8ec8 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java +++ b/src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RestController; import com.campus.campus.domain.councilpost.application.dto.request.PostRequest; +import com.campus.campus.domain.councilpost.application.dto.response.GetPostDetailResponse; import com.campus.campus.domain.councilpost.application.dto.response.GetPostListForCouncilResponse; import com.campus.campus.domain.councilpost.application.dto.response.GetPostResponse; import com.campus.campus.domain.councilpost.application.dto.response.GetUpcomingEventListForCouncilResponse; @@ -169,8 +170,8 @@ public CommonResponse deletePost(@CurrentCouncilId Long councilId, @PathVa @GetMapping("/{postId}") @Operation(summary = "학생회 게시글 상세 조회") - public CommonResponse getPost(@PathVariable Long postId, @CurrentCouncilId Long councilId) { - GetPostResponse responseDto = postService.findById(postId, councilId); + public CommonResponse getPost(@PathVariable Long postId, @CurrentCouncilId Long councilId) { + GetPostDetailResponse responseDto = postService.findById(postId, councilId); return CommonResponse.success(StudentCouncilPostResponseCode.POST_READ_SUCCESS, responseDto); } diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java index 2c224b09..6743b63d 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java @@ -60,7 +60,7 @@ public class PlaceService { private final ExecutorService executorService; private final GeoCoderClient geoCoderClient; - public List search(double lat, double lng, String keyword) { + public List searchByLocationAndKeyword(double lat, double lng, String keyword) { String searchWord = keyword; try { @@ -80,36 +80,13 @@ public List search(double lat, double lng, String keyword) { //네이버에서 특정 장소 기본정보 받아오기 NaverSearchResponse naverSearchResponse = naverMapClient.searchPlaces(searchWord, 5); - List candidates = naverSearchResponse.items().stream() - .map(item -> { - String name = stripHtml(item.title()); - String address = item.roadAddress(); - String placeKey = PlaceKeyGenerator.generate(name, address); - String naverPlaceUrl = buildNaverPlaceUrl(item); - return new SearchCandidateResponse(item, name, address, placeKey, naverPlaceUrl); - }) - .toList(); - - List placeKeys = candidates.stream() - .map(SearchCandidateResponse::placeKey) - .distinct() - .toList(); - - Map> images = placeImagesRepository.findAllByPlaceKeyIn(placeKeys).stream() - .collect(Collectors.groupingBy( - PlaceImages::getPlaceKey, - Collectors.mapping(PlaceImages::getImageUrl, Collectors.toList()) - )); + return processSearchResults(naverSearchResponse); + } - List> futures = candidates.stream() - .map(response -> CompletableFuture.supplyAsync(() -> convertToSavedPlaceInfo(response, images), - executorService) - .completeOnTimeout(fallback(response), 4, TimeUnit.SECONDS) - .exceptionally(ex -> fallback(response))) - .toList(); + public List searchByKeyword(String keyword) { + NaverSearchResponse naverSearchResponse = naverMapClient.searchPlaces(keyword, 5); - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); - return futures.stream().map(CompletableFuture::join).toList(); + return processSearchResults(naverSearchResponse); } public Place findOrCreatePlace(SavedPlaceInfo place) { @@ -174,6 +151,39 @@ public LikeResponse likePlace(SavedPlaceInfo placeInfo, Long userId) { return placeMapper.toLikeResponse(place); } + private List processSearchResults(NaverSearchResponse naverSearchResponse) { + List candidates = naverSearchResponse.items().stream() + .map(item -> { + String name = stripHtml(item.title()); + String address = item.roadAddress(); + String placeKey = PlaceKeyGenerator.generate(name, address); + String naverPlaceUrl = buildNaverPlaceUrl(item); + return new SearchCandidateResponse(item, name, address, placeKey, naverPlaceUrl); + }) + .toList(); + + List placeKeys = candidates.stream() + .map(SearchCandidateResponse::placeKey) + .distinct() + .toList(); + + Map> images = placeImagesRepository.findAllByPlaceKeyIn(placeKeys).stream() + .collect(Collectors.groupingBy( + PlaceImages::getPlaceKey, + Collectors.mapping(PlaceImages::getImageUrl, Collectors.toList()) + )); + + List> futures = candidates.stream() + .map(response -> CompletableFuture.supplyAsync(() -> convertToSavedPlaceInfo(response, images), + executorService) + .completeOnTimeout(fallback(response), 4, TimeUnit.SECONDS) + .exceptionally(ex -> fallback(response))) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + return futures.stream().map(CompletableFuture::join).toList(); + } + private SavedPlaceInfo convertToSavedPlaceInfo(SearchCandidateResponse response, Map> images) { List cached = images.getOrDefault(response.placeKey(), List.of()); List placeImages = !cached.isEmpty() diff --git a/src/main/java/com/campus/campus/domain/place/presentation/PlaceController.java b/src/main/java/com/campus/campus/domain/place/presentation/PlaceController.java index b91de506..8e5fc9de 100644 --- a/src/main/java/com/campus/campus/domain/place/presentation/PlaceController.java +++ b/src/main/java/com/campus/campus/domain/place/presentation/PlaceController.java @@ -38,7 +38,7 @@ public class PlaceController { @GetMapping("/search") @Operation(summary = "현위치 기반 가까운 순으로 장소 키워드 검색", description = "검색 결과 5개 검색되도록 함") - public CommonResponse> getPlaceInfo( + public CommonResponse> getPlaceInfoWithLocationAndKeyword( @Parameter( description = "검색할 키워드", example = "스타벅스" @@ -55,7 +55,16 @@ public CommonResponse> getPlaceInfo( ) @RequestParam double lng ) { - List searchResponse = placeService.search(lat, lng, keyword); + List searchResponse = placeService.searchByLocationAndKeyword(lat, lng, keyword); + + return CommonResponse.success(PlaceResponseCode.PLACE_SEARCH_SUCCESS, searchResponse); + } + + @GetMapping("/search/keyword") + @Operation(summary = "키워드 기반 장소 검색") + public CommonResponse> getPlaceInfoWithKeyword(@RequestParam String keyword) { + List searchResponse = placeService.searchByKeyword(keyword); + return CommonResponse.success(PlaceResponseCode.PLACE_SEARCH_SUCCESS, searchResponse); } @@ -80,6 +89,7 @@ public ResponseEntity getAddress( public CommonResponse likePlace(@Valid @RequestBody SavedPlaceInfo request, @CurrentUserId Long userId) { LikeResponse response = placeService.likePlace(request, userId); + return CommonResponse.success(PlaceResponseCode.PLACE_SAVE_SUCCESS, response); } @@ -121,6 +131,7 @@ public CommonResponse> getPartnershipPlaces( ) @RequestParam(defaultValue = "5") int size) { List response = partnershipPlaceService.getPartnershipPlaces(userId, cursor, size, lat, lng); + return CommonResponse.success(PlaceResponseCode.CHECK_PARTNERSHIP_PLACES_SUCCESS, response); }