Skip to content
Original file line number Diff line number Diff line change
@@ -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<String> images
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -102,6 +104,33 @@ public GetPostResponse toGetPostResponse(StudentCouncilPost post, List<String> i
return builder.build();
}

public GetPostDetailResponse toGetPostDetailResponse(StudentCouncilPost post, List<String> images,
List<String> 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<String> images,
Long currentUserId, boolean isLiked) {
var writer = post.getWriter();
Expand Down Expand Up @@ -188,4 +217,21 @@ public CouncilPostCreatedEvent createPostCreatedEvent(StudentCouncilPost post, S
topic
);
}

private SavedPlaceInfo toSavedPlaceInfo(Place place, List<String> 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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,14 +19,14 @@
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;
import com.campus.campus.domain.councilpost.application.dto.response.NormalizedDateTime;
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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);

Expand All @@ -106,7 +112,9 @@ public GetPostResponse findById(Long postId, Long currentUserId) {
.map(PostImage::getImageUrl)
.toList();

return studentCouncilPostMapper.toGetPostResponse(post, imageUrls, currentUserId);
List<String> placeImageUrls = getPlaceImageUrls(post.getPlace());

return studentCouncilPostMapper.toGetPostDetailResponse(post, imageUrls, placeImageUrls, currentCouncilId);
}

@Transactional(readOnly = true)
Expand Down Expand Up @@ -164,7 +172,7 @@ public void delete(Long councilId, Long postId) {

List<PostImage> postImages = postImageRepository.findAllByPost(post);

List<String> deleteTargets = new ArrayList<>();
Set<String> deleteTargets = new HashSet<>();

if (post.getThumbnailImageUrl() != null) {
deleteTargets.add(post.getThumbnailImageUrl());
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -248,11 +256,21 @@ public GetPostResponse update(Long councilId, Long postId, PostRequest dto) {
return studentCouncilPostMapper.toGetPostResponse(post, imageUrls, councilId);
}

private List<String> 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<PostImage> oldImages, PostRequest dto) {
List<String> newUrls = dto.imageUrls() == null ? List.of() : dto.imageUrls();

List<String> deleteTargets = new ArrayList<>();
Set<String> deleteTargets = new HashSet<>();

// 썸네일 변경 시 이전 썸네일
if (oldThumbnailUrl != null && !oldThumbnailUrl.equals(dto.thumbnailImageUrl())) {
Expand All @@ -273,7 +291,7 @@ private void cleanupUnusedImages(String oldThumbnailUrl, List<PostImage> oldImag

try {
presignedUrlService.deleteImage(imageUrl);
} catch (PostOciImageDeleteFailedException e) {
} catch (Exception e) {
log.warn("OCI 파일 삭제 실패 (파일이 없을 수 있음): {}", imageUrl, e);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
);
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -169,8 +170,8 @@ public CommonResponse<Void> deletePost(@CurrentCouncilId Long councilId, @PathVa

@GetMapping("/{postId}")
@Operation(summary = "학생회 게시글 상세 조회")
public CommonResponse<GetPostResponse> getPost(@PathVariable Long postId, @CurrentCouncilId Long councilId) {
GetPostResponse responseDto = postService.findById(postId, councilId);
public CommonResponse<GetPostDetailResponse> getPost(@PathVariable Long postId, @CurrentCouncilId Long councilId) {
GetPostDetailResponse responseDto = postService.findById(postId, councilId);

return CommonResponse.success(StudentCouncilPostResponseCode.POST_READ_SUCCESS, responseDto);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public class PlaceService {
private final ExecutorService executorService;
private final GeoCoderClient geoCoderClient;

public List<SavedPlaceInfo> search(double lat, double lng, String keyword) {
public List<SavedPlaceInfo> searchByLocationAndKeyword(double lat, double lng, String keyword) {
String searchWord = keyword;

try {
Expand All @@ -80,36 +80,13 @@ public List<SavedPlaceInfo> search(double lat, double lng, String keyword) {
//네이버에서 특정 장소 기본정보 받아오기
NaverSearchResponse naverSearchResponse = naverMapClient.searchPlaces(searchWord, 5);

List<SearchCandidateResponse> 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<String> placeKeys = candidates.stream()
.map(SearchCandidateResponse::placeKey)
.distinct()
.toList();

Map<String, List<String>> images = placeImagesRepository.findAllByPlaceKeyIn(placeKeys).stream()
.collect(Collectors.groupingBy(
PlaceImages::getPlaceKey,
Collectors.mapping(PlaceImages::getImageUrl, Collectors.toList())
));
return processSearchResults(naverSearchResponse);
}

List<CompletableFuture<SavedPlaceInfo>> futures = candidates.stream()
.map(response -> CompletableFuture.supplyAsync(() -> convertToSavedPlaceInfo(response, images),
executorService)
.completeOnTimeout(fallback(response), 4, TimeUnit.SECONDS)
.exceptionally(ex -> fallback(response)))
.toList();
public List<SavedPlaceInfo> 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) {
Expand Down Expand Up @@ -174,6 +151,39 @@ public LikeResponse likePlace(SavedPlaceInfo placeInfo, Long userId) {
return placeMapper.toLikeResponse(place);
}

private List<SavedPlaceInfo> processSearchResults(NaverSearchResponse naverSearchResponse) {
List<SearchCandidateResponse> 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<String> placeKeys = candidates.stream()
.map(SearchCandidateResponse::placeKey)
.distinct()
.toList();

Map<String, List<String>> images = placeImagesRepository.findAllByPlaceKeyIn(placeKeys).stream()
.collect(Collectors.groupingBy(
PlaceImages::getPlaceKey,
Collectors.mapping(PlaceImages::getImageUrl, Collectors.toList())
));

List<CompletableFuture<SavedPlaceInfo>> 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<String, List<String>> images) {
List<String> cached = images.getOrDefault(response.placeKey(), List.of());
List<String> placeImages = !cached.isEmpty()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public class PlaceController {

@GetMapping("/search")
@Operation(summary = "현위치 기반 가까운 순으로 장소 키워드 검색", description = "검색 결과 5개 검색되도록 함")
public CommonResponse<List<SavedPlaceInfo>> getPlaceInfo(
public CommonResponse<List<SavedPlaceInfo>> getPlaceInfoWithLocationAndKeyword(
@Parameter(
description = "검색할 키워드",
example = "스타벅스"
Expand All @@ -55,7 +55,16 @@ public CommonResponse<List<SavedPlaceInfo>> getPlaceInfo(
)
@RequestParam double lng
) {
List<SavedPlaceInfo> searchResponse = placeService.search(lat, lng, keyword);
List<SavedPlaceInfo> searchResponse = placeService.searchByLocationAndKeyword(lat, lng, keyword);

return CommonResponse.success(PlaceResponseCode.PLACE_SEARCH_SUCCESS, searchResponse);
}

@GetMapping("/search/keyword")
@Operation(summary = "키워드 기반 장소 검색")
public CommonResponse<List<SavedPlaceInfo>> getPlaceInfoWithKeyword(@RequestParam String keyword) {
List<SavedPlaceInfo> searchResponse = placeService.searchByKeyword(keyword);

return CommonResponse.success(PlaceResponseCode.PLACE_SEARCH_SUCCESS, searchResponse);
}

Expand All @@ -80,6 +89,7 @@ public ResponseEntity<AddressResponse> getAddress(
public CommonResponse<LikeResponse> likePlace(@Valid @RequestBody SavedPlaceInfo request,
@CurrentUserId Long userId) {
LikeResponse response = placeService.likePlace(request, userId);

return CommonResponse.success(PlaceResponseCode.PLACE_SAVE_SUCCESS, response);
}

Expand Down Expand Up @@ -121,6 +131,7 @@ public CommonResponse<List<PartnershipResponse>> getPartnershipPlaces(
)
@RequestParam(defaultValue = "5") int size) {
List<PartnershipResponse> response = partnershipPlaceService.getPartnershipPlaces(userId, cursor, size, lat, lng);

return CommonResponse.success(PlaceResponseCode.CHECK_PARTNERSHIP_PLACES_SUCCESS, response);
}

Expand Down