diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/dto/request/PostRequest.java b/src/main/java/com/campus/campus/domain/councilpost/application/dto/request/PostRequest.java index f7ac0beb..400eccf3 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/dto/request/PostRequest.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/dto/request/PostRequest.java @@ -5,9 +5,11 @@ 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.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -22,7 +24,26 @@ public record PostRequest( @NotBlank String content, - String place, + @Schema(example = + """ + "placeName": "숙명여자대학교", + "placeKey": "string", + "address": "서울특별시 용산구 청파로47길 99", + "category": "교육,학문>대학교", + "link": "https://map.naver.com/v5/search/%EC%88%99%EB%AA%85%EC%97%AC%EC%9E%90%EB%8C%80%ED%95%99%EA%B5%90+%EC%A0%9C1%EC%BA%A0%ED%8D%BC%EC%8A%A4?c=37.545947,126.964578,15,0,0,0,dh", + "telephone": "010-1234-1234", + "coordinate": { + "latitude": 0.1, + "longitude": 0.1 + }, + "imgUrls": [ + "string" + ] + """, + description = "/search API에서 반환된 결과 중 하나를 선택") + @NotNull + @Valid + SavedPlaceInfo place, @Schema(example = "2025-04-10T18:00") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetPostResponse.java b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetPostResponse.java index 1bb451c8..76c9a213 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetPostResponse.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetPostResponse.java @@ -22,7 +22,7 @@ public record GetPostResponse( PostCategory category, String title, String content, - String place, + String placeName, LocalDate startDate, LocalDate endDate, LocalDateTime startDateTime, diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponse.java b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponse.java index 345f1924..585bef2d 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponse.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponse.java @@ -9,7 +9,7 @@ public record PostListItemResponse( Long id, PostCategory category, String title, - String place, + String placeName, LocalDateTime endDateTime, String thumbnailImageUrl, ThumbnailIcon thumbnailIcon, diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/exception/AcademicInfoNotSetException.java b/src/main/java/com/campus/campus/domain/councilpost/application/exception/AcademicInfoNotSetException.java new file mode 100644 index 00000000..82168b1b --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilpost/application/exception/AcademicInfoNotSetException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.councilpost.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class AcademicInfoNotSetException extends ApplicationException { + public AcademicInfoNotSetException() { + super(ErrorCode.ACADEMIC_INFO_NOT_SET); + } +} diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/councilpost/application/exception/ErrorCode.java index b0801aaa..675a5d47 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/exception/ErrorCode.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/exception/ErrorCode.java @@ -1,34 +1,38 @@ package com.campus.campus.domain.councilpost.application.exception; import com.campus.campus.global.common.exception.ErrorCodeInterface; + import lombok.AllArgsConstructor; import lombok.Getter; + import org.springframework.http.HttpStatus; @Getter @AllArgsConstructor public enum ErrorCode implements ErrorCodeInterface { - POST_NOT_FOUND(2401, HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다."), - NOT_POST_WRITER(2402, HttpStatus.FORBIDDEN, "작성자만 해당 작업을 수행할 수 있습니다."), - THUMBNAIL_REQUIRED(2403, HttpStatus.BAD_REQUEST, "썸네일(이미지 또는 아이콘)은 반드시 필요합니다."), - POST_IMAGE_LIMIT_EXCEEDED(2404, HttpStatus.BAD_REQUEST, "게시글 이미지는 최대 10개까지 등록할 수 있습니다."), - POST_OCI_IMAGE_DELETE_FAILED(2405, HttpStatus.INTERNAL_SERVER_ERROR, "OCI 이미지 삭제에 실패했습니다."), + POST_NOT_FOUND(2401, HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다."), + NOT_POST_WRITER(2402, HttpStatus.FORBIDDEN, "작성자만 해당 작업을 수행할 수 있습니다."), + THUMBNAIL_REQUIRED(2403, HttpStatus.BAD_REQUEST, "썸네일(이미지 또는 아이콘)은 반드시 필요합니다."), + POST_IMAGE_LIMIT_EXCEEDED(2404, HttpStatus.BAD_REQUEST, "게시글 이미지는 최대 10개까지 등록할 수 있습니다."), + POST_OCI_IMAGE_DELETE_FAILED(2405, HttpStatus.INTERNAL_SERVER_ERROR, "OCI 이미지 삭제에 실패했습니다."), - // EVENT 관련 - EVENT_START_DATETIME_REQUIRED(2406, HttpStatus.BAD_REQUEST, "행사는 시작 일시가 필요합니다."), - EVENT_END_DATETIME_NOT_ALLOWED(2407, HttpStatus.BAD_REQUEST, "행사는 종료 일시를 가질 수 없습니다."), + // EVENT 관련 + EVENT_START_DATETIME_REQUIRED(2406, HttpStatus.BAD_REQUEST, "행사는 시작 일시가 필요합니다."), + EVENT_END_DATETIME_NOT_ALLOWED(2407, HttpStatus.BAD_REQUEST, "행사는 종료 일시를 가질 수 없습니다."), - // PARTNERSHIP 관련 - PARTNERSHIP_DATE_REQUIRED(2408, HttpStatus.BAD_REQUEST, "제휴는 시작일과 종료일이 필요합니다."), + // PARTNERSHIP 관련 + PARTNERSHIP_DATE_REQUIRED(2408, HttpStatus.BAD_REQUEST, "제휴는 시작일과 종료일이 필요합니다."), - // 학생의 학생회 게시글 조회 관련 - POST_ACCESS_DENIED(2409, HttpStatus.FORBIDDEN, "해당 게시글에 접근할 권한이 없습니다."), - COLLEGE_NOT_SET(2410, HttpStatus.BAD_REQUEST, "단과대 정보가 설정되지 않았습니다."), - MAJOR_NOT_SET(2411, HttpStatus.BAD_REQUEST, "학과 정보가 설정되지 않았습니다."); + // 학생의 학생회 게시글 조회 관련 + POST_ACCESS_DENIED(2409, HttpStatus.FORBIDDEN, "해당 게시글에 접근할 권한이 없습니다."), + COLLEGE_NOT_SET(2410, HttpStatus.BAD_REQUEST, "단과대 정보가 설정되지 않았습니다."), + MAJOR_NOT_SET(2411, HttpStatus.BAD_REQUEST, "학과 정보가 설정되지 않았습니다."), + ACADEMIC_INFO_NOT_SET(2412, HttpStatus.BAD_REQUEST, "학적 정보가 설정되지 않았습니다."), + PLACE_INFO_NOT_FOUND(2413, HttpStatus.NOT_FOUND, "해당 게시글의 장소 정보를 찾을 수 없습니다."); - private final int code; - private final HttpStatus status; - private final String message; + private final int code; + private final HttpStatus status; + private final String message; } diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/exception/PlaceInfoNotFoundException.java b/src/main/java/com/campus/campus/domain/councilpost/application/exception/PlaceInfoNotFoundException.java new file mode 100644 index 00000000..35bfa891 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilpost/application/exception/PlaceInfoNotFoundException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.councilpost.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class PlaceInfoNotFoundException extends ApplicationException { + public PlaceInfoNotFoundException() { + super(ErrorCode.PLACE_INFO_NOT_FOUND); + } +} 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 d04cc829..35404149 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 @@ -7,18 +7,19 @@ import org.springframework.stereotype.Component; import com.campus.campus.domain.council.domain.entity.StudentCouncil; +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.GetPostResponse; -import com.campus.campus.domain.councilpost.application.dto.response.LikePostResponse; +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; import com.campus.campus.domain.councilpost.application.dto.response.GetUpcomingEventListForCouncilResponse; -import com.campus.campus.domain.councilpost.application.dto.response.GetActivePartnershipListForUserResponse; +import com.campus.campus.domain.councilpost.application.dto.response.LikePostResponse; import com.campus.campus.domain.councilpost.application.dto.response.PostListItemResponse; -import com.campus.campus.domain.councilpost.application.dto.request.PostRequest; -import com.campus.campus.domain.councilpost.application.dto.response.GetPostForUserResponse; 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.domain.entity.Place; import com.campus.campus.domain.user.domain.entity.User; import lombok.RequiredArgsConstructor; @@ -31,8 +32,10 @@ public PostListItemResponse toPostListItemResponse(StudentCouncilPost post, bool post.getId(), post.getCategory(), post.getTitle(), - post.getPlace(), - post.isEvent() ? post.getStartDateTime() : post.getEndDateTime(), + post.getPlace().getPlaceName(), + post.isEvent() + ? post.getStartDateTime() + : post.getEndDateTime(), post.getThumbnailImageUrl(), post.getThumbnailIcon(), isLiked @@ -44,7 +47,7 @@ public GetPostListForCouncilResponse toGetPostListForCouncilResponse(StudentCoun post.getId(), post.getCategory(), post.getTitle(), - post.getPlace(), + post.getPlace().getPlaceName(), post.isEvent() ? post.getStartDateTime() : post.getEndDateTime(), post.getThumbnailImageUrl(), post.getThumbnailIcon() @@ -56,7 +59,7 @@ public GetUpcomingEventListForCouncilResponse toGetUpcomingEventListForCouncilRe post.getId(), post.getCategory(), post.getTitle(), - post.getPlace(), + post.getPlace().getPlaceName(), post.getStartDateTime(), post.getThumbnailIcon() ); @@ -66,7 +69,7 @@ public GetActivePartnershipListForUserResponse toGetActivePartnershipListForUser return new GetActivePartnershipListForUserResponse( post.getId(), post.getTitle(), - post.getPlace(), + post.getPlace().getPlaceName(), post.getThumbnailImageUrl() ); } @@ -81,7 +84,7 @@ public GetPostResponse toGetPostResponse(StudentCouncilPost post, List i .category(post.getCategory()) .title(post.getTitle()) .content(post.getContent()) - .place(post.getPlace()) + .placeName(post.getPlace().getPlaceName()) .thumbnailImageUrl(post.getThumbnailImageUrl()) .thumbnailIcon(post.getThumbnailIcon()) .images(images != null ? images : Collections.emptyList()); @@ -106,7 +109,7 @@ public GetPostForUserResponse toGetPostForUserResponse(StudentCouncilPost post, .category(post.getCategory()) .title(post.getTitle()) .content(post.getContent()) - .place(post.getPlace()) + .place(post.getPlace().getPlaceName()) .thumbnailImageUrl(post.getThumbnailImageUrl()) .thumbnailIcon(post.getThumbnailIcon()) .isLiked(isLiked) @@ -134,20 +137,20 @@ public GetLikedPostResponse toGetLikedPostResponse(StudentCouncilPost post) { return new GetLikedPostResponse( post.getId(), post.getTitle(), - post.getPlace(), + post.getPlace().getPlaceName(), post.isEvent() ? post.getStartDateTime() : post.getEndDateTime(), post.getThumbnailImageUrl() ); } - public StudentCouncilPost createStudentCouncilPost(StudentCouncil writer, PostRequest dto, + public StudentCouncilPost createStudentCouncilPost(StudentCouncil writer, Place place, PostRequest dto, LocalDateTime startDateTime, LocalDateTime endDateTime) { return StudentCouncilPost.builder() .writer(writer) .category(dto.category()) .title(dto.title()) .content(dto.content()) - .place(dto.place()) + .place(place) .startDateTime(startDateTime) .endDateTime(endDateTime) .thumbnailImageUrl(dto.thumbnailImageUrl()) 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 189d088a..15ffbe11 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 @@ -14,11 +14,11 @@ import com.campus.campus.domain.council.application.exception.StudentCouncilNotFoundException; 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.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.dto.request.PostRequest; 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; @@ -30,6 +30,9 @@ import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; import com.campus.campus.domain.councilpost.domain.repository.PostImageRepository; import com.campus.campus.domain.councilpost.domain.repository.StudentCouncilPostRepository; +import com.campus.campus.domain.partnership.application.service.PartnershipService; +import com.campus.campus.domain.place.application.service.PlaceService; +import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.global.oci.application.service.PresignedUrlService; import lombok.RequiredArgsConstructor; @@ -48,6 +51,9 @@ public class StudentCouncilPostService { private static final int MAX_IMAGE_COUNT = 10; private static final long UPCOMING_EVENT_WINDOW_HOURS = 72L; + private final PlaceService placeService; + private final StudentCouncilPostRepository studentCouncilPostRepository; + private final PartnershipService partnershipService; @Transactional public GetPostResponse create(Long councilId, PostRequest dto) { @@ -65,8 +71,11 @@ public GetPostResponse create(Long councilId, PostRequest dto) { NormalizedDateTime normalized = dto.category().validateAndNormalize(dto); + //Place 객체 생성 + Place place = placeService.findOrCreatePlace(dto); + StudentCouncilPost post = studentCouncilPostMapper.createStudentCouncilPost( - writer, dto, normalized.startDateTime(), normalized.endDateTime() + writer, place, dto, normalized.startDateTime(), normalized.endDateTime() ); postRepository.save(post); @@ -203,10 +212,15 @@ public GetPostResponse update(Long councilId, Long postId, PostRequest dto) { String oldThumbnailUrl = post.getThumbnailImageUrl(); List oldImages = postImageRepository.findAllByPost(post); + Place place = post.getPlace(); + if (dto.place() != null && (place == null || !dto.place().placeName().equals(place.getPlaceName()))) { + place = placeService.findOrCreatePlace(dto); + } + post.update( dto.title(), dto.content(), - dto.place(), + place, normalized.startDateTime(), normalized.endDateTime(), dto.thumbnailImageUrl(), diff --git a/src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java b/src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java index 60b34db8..d0de71fd 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java +++ b/src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java @@ -4,6 +4,7 @@ import java.time.LocalDateTime; import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.global.entity.BaseEntity; import jakarta.persistence.Column; @@ -45,7 +46,9 @@ public class StudentCouncilPost extends BaseEntity { @Column(columnDefinition = "TEXT") private String content; - private String place; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "place_id") + private Place place; private LocalDateTime startDateTime; private LocalDateTime endDateTime; @@ -58,7 +61,7 @@ public class StudentCouncilPost extends BaseEntity { public void update( String title, String content, - String place, + Place place, LocalDateTime startDateTime, LocalDateTime endDateTime, String thumbnailImageUrl, diff --git a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/PostImageRepository.java b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/PostImageRepository.java index 34b1b7a7..861cce6a 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/PostImageRepository.java +++ b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/PostImageRepository.java @@ -3,6 +3,8 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.campus.campus.domain.councilpost.domain.entity.PostImage; import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; @@ -14,4 +16,12 @@ public interface PostImageRepository extends JpaRepository { void deleteByPost(StudentCouncilPost post); List findAllByPostOrderByIdAsc(StudentCouncilPost post); + + @Query(""" + select pi.imageUrl + from PostImage pi + where pi.post = :post + order by pi.id asc + """) + List findImageUrlsByPost(@Param("post") StudentCouncilPost post); } diff --git a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java index 1898f73b..d14dfc7c 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java +++ b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java @@ -205,4 +205,74 @@ Page findUpcomingMajorEvents( @Param("limit") LocalDateTime limit, Pageable pageable ); + + @EntityGraph(attributePaths = {"writer", "writer.school", "writer.college", "writer.major", "place"}) + @Query(""" + SELECT p + FROM StudentCouncilPost p + JOIN p.writer w + LEFT JOIN w.school s + LEFT JOIN w.college c + LEFT JOIN w.major m + WHERE w.deletedAt IS NULL + AND p.category = :category + AND p.startDateTime <= :now + AND p.endDateTime >= :now + AND ( + (w.councilType = :majorType AND m.majorId = :majorId) + OR (w.councilType = :collegeType AND c.collegeId = :collegeId) + OR (w.councilType = :schoolType AND s.schoolId = :schoolId) + ) + AND (:cursor IS NULL OR p.id < :cursor) + ORDER BY p.id DESC + """) + List findByUserScopeWithCursor( + @Param("majorId") Long majorId, + @Param("collegeId") Long collegeId, + @Param("schoolId") Long schoolId, + @Param("category") PostCategory category, + @Param("majorType") CouncilType majorType, + @Param("collegeType") CouncilType collegeType, + @Param("schoolType") CouncilType schoolType, + @Param("cursor") Long cursor, + @Param("now") LocalDateTime now, + Pageable pageable + ); + + @Query(""" + SELECT p + FROM StudentCouncilPost p + JOIN p.writer w + JOIN p.place pl + LEFT JOIN w.school s + LEFT JOIN w.college c + LEFT JOIN w.major m + WHERE w.deletedAt IS NULL + AND p.category = :category + AND p.startDateTime <= :now + AND p.endDateTime >= :now + AND pl.coordinate.latitude BETWEEN :minLat AND :maxLat + AND pl.coordinate.longitude BETWEEN :minLng AND :maxLng + AND ( + (w.councilType = :majorType AND m.majorId = :majorId) + OR (w.councilType = :collegeType AND c.collegeId = :collegeId) + OR (w.councilType = :schoolType AND s.schoolId = :schoolId) + ) + ORDER BY p.id DESC + """) + List findPinsInBounds( + @Param("majorId") Long majorId, + @Param("collegeId") Long collegeId, + @Param("schoolId") Long schoolId, + @Param("category") PostCategory category, + @Param("majorType") CouncilType majorType, + @Param("collegeType") CouncilType collegeType, + @Param("schoolType") CouncilType schoolType, + @Param("minLat") Double minLat, + @Param("maxLat") Double maxLat, + @Param("minLng") Double minLng, + @Param("maxLng") Double maxLng, + @Param("now") LocalDateTime now + ); + } 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 81ba6472..d82afc3b 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 @@ -88,13 +88,33 @@ public class StudentCouncilPostController { value = """ { "category": "PARTNERSHIP", - "title": "카페 할인", - "content": "10% 할인", - "place": "OO카페", + "title": "봉구스밥버거 제휴 할인", + "content": "중앙대 학생증 제시 시 전 메뉴 10% 할인", + "place": { + "placeName": "봉구스밥버거 중앙대후문점", + "placeKey": "cf5691fce0965a6a20d76601e88a019b9ed22733c21c52fe77ac6f6a20db9b88", + "address": "서울특별시 동작구 상도1동 645-2", + "category": "음식점>분식", + "link": "https://map.naver.com/v5/search/%EB%B4%89%EA%B5%AC%EC%8A%A4%EB%B0%A5%EB%B2%84%EA%B1%B0+%EC%A4%91%EC%95%99%EB%8C%80%ED%9B%84%EB%AC%B8%EC%A0%90?c=37.504750,126.951452,15,0,0,0,dh", + "telephone": "", + "coordinate": { + "latitude": 37.5047501, + "longitude": 126.9514519 + }, + "imgUrls": [ + "https://maps.googleapis.com/maps/api/place/photo?maxWidth=800&photo_reference=example1", + "https://maps.googleapis.com/maps/api/place/photo?maxWidth=800&photo_reference=example2", + "https://maps.googleapis.com/maps/api/place/photo?maxWidth=800&photo_reference=example3" + ] + }, "startDateTime": "2025-04-01T00:00", "endDateTime": "2025-04-30T23:59", - "thumbnailIcon": "CAFE", - "imageUrls": [] + "thumbnailIcon": "FOOD", + "thumbnailImageUrl": null, + "imageUrls": [ + "https://cdn.campus.com/post/image1.jpg", + "https://cdn.campus.com/post/image2.jpg" + ] } """ ) diff --git a/src/main/java/com/campus/campus/domain/partnership/application/dto/response/PartnershipPinResponse.java b/src/main/java/com/campus/campus/domain/partnership/application/dto/response/PartnershipPinResponse.java new file mode 100644 index 00000000..1dc74fe1 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/partnership/application/dto/response/PartnershipPinResponse.java @@ -0,0 +1,22 @@ +package com.campus.campus.domain.partnership.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PartnershipPinResponse( + + @Schema(description = "제휴글 ID", example = "10") + Long postId, + + @Schema(description = "장소 ID", example = "4") + Long placeId, + + @Schema(description = "장소 이름", example = "매머드익스프레스 중앙대점") + String placeName, + + @Schema(description = "장소 위도", example = "37.50775") + double latitude, + + @Schema(description = "장소 경도", example = "126.96059") + double longitude +) { +} diff --git a/src/main/java/com/campus/campus/domain/partnership/application/service/PartnershipService.java b/src/main/java/com/campus/campus/domain/partnership/application/service/PartnershipService.java new file mode 100644 index 00000000..3311b68b --- /dev/null +++ b/src/main/java/com/campus/campus/domain/partnership/application/service/PartnershipService.java @@ -0,0 +1,170 @@ +package com.campus.campus.domain.partnership.application.service; + +import java.time.LocalDateTime; +import java.util.AbstractMap; +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.campus.campus.domain.council.domain.entity.CouncilType; +import com.campus.campus.domain.councilpost.application.exception.AcademicInfoNotSetException; +import com.campus.campus.domain.councilpost.application.exception.PlaceInfoNotFoundException; +import com.campus.campus.domain.councilpost.application.exception.PostNotFoundException; +import com.campus.campus.domain.councilpost.domain.entity.PostCategory; +import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; +import com.campus.campus.domain.councilpost.domain.repository.PostImageRepository; +import com.campus.campus.domain.councilpost.domain.repository.StudentCouncilPostRepository; +import com.campus.campus.domain.partnership.application.dto.response.PartnershipPinResponse; +import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipResponse; +import com.campus.campus.domain.place.application.mapper.PlaceMapper; +import com.campus.campus.domain.place.domain.entity.Place; +import com.campus.campus.domain.place.domain.repository.LikedPlacesRepository; +import com.campus.campus.domain.user.application.exception.UserNotFoundException; +import com.campus.campus.domain.user.domain.entity.User; +import com.campus.campus.domain.user.domain.repository.UserRepository; +import com.campus.campus.global.util.geocoder.GeoUtil; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Service +public class PartnershipService { + + private final UserRepository userRepository; + private final LikedPlacesRepository likedPlacesRepository; + private final PostImageRepository postImageRepository; + private final PlaceMapper placeMapper; + private final StudentCouncilPostRepository studentCouncilPostRepository; + + @Transactional + public List getPartnershipPlaces(Long userId, Long cursor, int size, double userLat, + double userLng) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + validateAcademicInfo(user); + + Long majorId = user.getMajor().getMajorId(); + Long collegeId = user.getCollege().getCollegeId(); + Long schoolId = user.getSchool().getSchoolId(); + + Pageable pageable = PageRequest.of(0, size); + + //유저가 속한 학생회들의 제휴글(major/college/school) 전부 조회 + List posts = studentCouncilPostRepository.findByUserScopeWithCursor( + majorId, + collegeId, + schoolId, + PostCategory.PARTNERSHIP, + CouncilType.MAJOR_COUNCIL, + CouncilType.COLLEGE_COUNCIL, + CouncilType.SCHOOL_COUNCIL, + cursor, + LocalDateTime.now(), + pageable + ); + + return posts.stream() + .filter(post -> post.getPlace() != null && post.getPlace().getCoordinate() != null) + .map(post -> { + Place place = post.getPlace(); + + double distanceMeter = GeoUtil.distanceMeter( + userLat, userLng, + place.getCoordinate().latitude(), + place.getCoordinate().longitude() + ); + + // post + distance를 함께 묶음 + return new AbstractMap.SimpleEntry<>(post, distanceMeter); + }) + .sorted(Map.Entry.comparingByValue()) // 거리순 정렬 + .limit(size) + .map(entry -> { + StudentCouncilPost post = entry.getKey(); + double distanceMeter = entry.getValue(); + double rounded = Math.round(distanceMeter * 100.0) / 100.0; + + return placeMapper.toPartnershipResponse( + user, + post, + post.getPlace(), + isLiked(post.getPlace(), user), + getImgUrls(post), + rounded + ); + }) + .toList(); + } + + @Transactional + public List findPartnerInBounds(Long userId, Double minLat, Double maxLat, Double minLng, + Double maxLng) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + validateAcademicInfo(user); + + Long majorId = user.getMajor().getMajorId(); + Long collegeId = user.getCollege().getCollegeId(); + Long schoolId = user.getSchool().getSchoolId(); + List posts = studentCouncilPostRepository.findPinsInBounds( + majorId, collegeId, schoolId, + PostCategory.PARTNERSHIP, + CouncilType.MAJOR_COUNCIL, CouncilType.COLLEGE_COUNCIL, CouncilType.SCHOOL_COUNCIL, + minLat, maxLat, minLng, maxLng, + LocalDateTime.now() + ); + + // 엔티티 → 응답 DTO 변환 + return posts.stream() + .filter(post -> post.getPlace() != null) + .map(post -> placeMapper.toPartnershipPinResponse(post, post.getPlace())) + .toList(); + } + + @Transactional + public PartnershipResponse getPartnershipDetail(Long postId, Long userId, double userLat, + double userLng) { + StudentCouncilPost post = studentCouncilPostRepository.findById(postId) + .orElseThrow(PostNotFoundException::new); + Place place = post.getPlace(); + + if (place == null || place.getCoordinate() == null) { + throw new PlaceInfoNotFoundException(); + } + + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + double distanceMeter = GeoUtil.distanceMeter( + userLat, userLng, + place.getCoordinate().latitude(), + place.getCoordinate().longitude() + ); + double rounded = Math.round(distanceMeter * 100.0) / 100.0; + + return placeMapper.toPartnershipResponse(user, post, place, isLiked(place, user), getImgUrls(post), rounded); + } + + private boolean isLiked(Place place, User user) { + return likedPlacesRepository.existsByUserAndPlace(user, place); + } + + private List getImgUrls(StudentCouncilPost post) { + return postImageRepository.findImageUrlsByPost(post); + } + + private void validateAcademicInfo(User user) { + if (user.getSchool() == null || user.getCollege() == null || user.getMajor() == null) { + throw new AcademicInfoNotSetException(); + } + } + +} diff --git a/src/main/java/com/campus/campus/domain/partnership/presentation/PartnershipController.java b/src/main/java/com/campus/campus/domain/partnership/presentation/PartnershipController.java new file mode 100644 index 00000000..126f4f5d --- /dev/null +++ b/src/main/java/com/campus/campus/domain/partnership/presentation/PartnershipController.java @@ -0,0 +1,124 @@ +package com.campus.campus.domain.partnership.presentation; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.campus.campus.domain.partnership.application.dto.response.PartnershipPinResponse; +import com.campus.campus.domain.partnership.application.service.PartnershipService; +import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipResponse; +import com.campus.campus.global.annotation.CurrentUserId; +import com.campus.campus.global.common.response.CommonResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/partnership") +@RequiredArgsConstructor +public class PartnershipController { + + private final PartnershipService partnershipService; + + @GetMapping("/list") + @Operation(summary = "리스트로 제휴 전체 조회", description = "무한 스크롤 방식으로 제휴 장소 목록을 조회합니다.") + public CommonResponse> getPartnershipPlaces( + @CurrentUserId Long userId, + @Parameter( + description = "현재 위치의 위도", + example = "37.50415" + ) + @RequestParam double lat, + @Parameter( + description = "현재 위치의 경도", + example = "126.9570" + ) + @RequestParam double lng, + @Parameter( + description = """ + 무한 스크롤 커서 값. + - 첫 요청 시 null + - 다음 요청부터는 이전 응답의 nextCursor 값 + """, + examples = { + @ExampleObject( + name = "첫 요청", + value = "null" + ), + @ExampleObject( + name = "다음 요청", + value = "120" + ) + } + ) + @RequestParam(required = false) Long cursor, + @Parameter( + description = "한 번에 조회할 개수", + example = "5" + ) + @RequestParam(defaultValue = "5") int size) { + List response = partnershipService.getPartnershipPlaces(userId, cursor, size, lat, lng); + return CommonResponse.success(PartnershipResponseCode.CHECK_PARTNERSHIP_PLACES_SUCCESS, response); + } + + @GetMapping("/map") + @Operation( + summary = "지도에서 제휴 장소 조회", + description = """ + 현재 지도 화면(bounds) 안에 있는 제휴 장소들을 조회합니다. + - bounds는 지도 화면의 남서/북동 좌표입니다. + - 지도 이동 또는 확대/축소 시 재호출됩니다. + """) + public CommonResponse> getPartnershipMap( + @CurrentUserId Long userId, + @Parameter( + description = "지도 화면의 남쪽(최소) 위도", + example = "37.497" + ) + @RequestParam Double minLat, + @Parameter( + description = "지도 화면의 북쪽(최대) 위도", + example = "37.512" + ) + @RequestParam Double maxLat, + @Parameter( + description = "지도 화면의 서쪽(최소) 경도", + example = "126.953" + ) + @RequestParam Double minLng, + @Parameter( + description = "지도 화면의 동쪽(최대) 경도", + example = "126.982" + ) + @RequestParam Double maxLng + ) { + return CommonResponse.success( + PartnershipResponseCode.CHECK_PARTNERSHIP_PLACES_SUCCESS, + partnershipService.findPartnerInBounds(userId, minLat, maxLat, minLng, maxLng) + ); + } + + @GetMapping("/detail") + @Operation(summary = "제휴 장소 상세 조회(맵에서 핀 클릭 시)") + public CommonResponse getPartnershipDetail( + @Parameter(description = "현재 위치의 위도", example = "37.50415") + @RequestParam double lat, + + @Parameter(description = "현재 위치의 경도", example = "126.9570") + @RequestParam double lng, + + @Parameter(description = "제휴글 ID", example = "10") + @RequestParam Long postId, + + @CurrentUserId Long userId + ) { + return CommonResponse.success( + PartnershipResponseCode.CHECK_ONE_PARTNERSHIP_PLACE_SUCCESS, + partnershipService.getPartnershipDetail(postId, userId, lat, lng)); + } +} diff --git a/src/main/java/com/campus/campus/domain/partnership/presentation/PartnershipResponseCode.java b/src/main/java/com/campus/campus/domain/partnership/presentation/PartnershipResponseCode.java new file mode 100644 index 00000000..f8504161 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/partnership/presentation/PartnershipResponseCode.java @@ -0,0 +1,21 @@ +package com.campus.campus.domain.partnership.presentation; + +import org.springframework.http.HttpStatus; + +import com.campus.campus.global.common.response.ResponseCodeInterface; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PartnershipResponseCode implements ResponseCodeInterface { + + PLACE_SAVE_SUCCESS(200, HttpStatus.OK, "좋아요 처리가 완료되었습니다."), + CHECK_PARTNERSHIP_PLACES_SUCCESS(200, HttpStatus.OK, "제휴 장소 리스트 조회가 완료되었습니다."), + CHECK_ONE_PARTNERSHIP_PLACE_SUCCESS(200, HttpStatus.OK, "제휴 장소 단건 조회가 완료되었습니다."); + + private final int code; + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java index 6563eacd..ef47fd0c 100644 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java @@ -5,19 +5,24 @@ import com.campus.campus.domain.place.domain.entity.Coordinate; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; public record SavedPlaceInfo( @Schema(description = "해당 장소명", example = "숙명여자대학교") + @NotBlank String placeName, @Schema(description = "장소 식별 고유 ID") + @NotBlank String placeKey, @Schema(description = "장소 주소", example = "서울특별시 용산구 청파로47길 99") + @NotBlank String address, @Schema(description = "장소 카테고리", example = "교육,학문>대학교") + @NotBlank String category, @Schema(description = "장소 상세 정보 네이버 페이지 하이퍼링크", example = "https://map.naver.com/v5/search/%EC%88%99%EB%AA%85%EC%97%AC%EC%9E%90%EB%8C%80%ED%95%99%EA%B5%90+%EC%A0%9C1%EC%BA%A0%ED%8D%BC%EC%8A%A4?c=37.545947,126.964578,15,0,0,0,dh") @@ -27,6 +32,7 @@ public record SavedPlaceInfo( String telephone, @Schema(description = "위도/경도") + @NotBlank Coordinate coordinate, @Schema(description = "이미지 url") diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/geocoder/AddressResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/geocoder/AddressResponse.java new file mode 100644 index 00000000..6666e3e9 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/geocoder/AddressResponse.java @@ -0,0 +1,35 @@ +package com.campus.campus.domain.place.application.dto.response.geocoder; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class AddressResponse { + + private Response response; + + @Getter + public static class Response { + private String status; + private List result; + } + + @Getter + public static class Result { + private String type; + private String text; + private Structure structure; + } + + @Getter + public static class Structure { + private String level1; + private String level2; + private String level3; + private String level4L; + private String level4A; + } +} diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipResponse.java new file mode 100644 index 00000000..2353aad4 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipResponse.java @@ -0,0 +1,24 @@ +package com.campus.campus.domain.place.application.dto.response.partnership; + +import java.time.LocalDate; +import java.util.List; + +public record PartnershipResponse( + Long placeId, + String placeKey, + String name, + String category, + String address, + Double latitude, + Double longitude, + String tag, //(ex.) 총학생회, 사회과학대학, IT공학과 + boolean isLiked, + double star, //리뷰 평점 + String partnerTitle, //제휴 제목 + double distance, //거리(m) + LocalDate endDate, //제휴 끝나는 시점 + + //StudentCouncilPost 이미지 받아오기 + List imgUrls +) { +} diff --git a/src/main/java/com/campus/campus/domain/place/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/place/application/exception/ErrorCode.java index c50ca038..a55734ea 100644 --- a/src/main/java/com/campus/campus/domain/place/application/exception/ErrorCode.java +++ b/src/main/java/com/campus/campus/domain/place/application/exception/ErrorCode.java @@ -15,7 +15,8 @@ public enum ErrorCode implements ErrorCodeInterface { PLACE_NOT_FOUND(2602, HttpStatus.NOT_FOUND, "해당 장소를 찾을 수 없습니다."), SHA256_NOT_SUPPORTED(2603, HttpStatus.INTERNAL_SERVER_ERROR, "SHA-256이 지원되지 않습니다."), NAVER_API_ERROR(2604, HttpStatus.INTERNAL_SERVER_ERROR, "네이버 api 호출에 실패하였습니다."), - PLACE_CREATION_ERROR(2605, HttpStatus.INTERNAL_SERVER_ERROR, "Place 생성에 오류가 발생하였습니다."); + PLACE_CREATION_ERROR(2605, HttpStatus.INTERNAL_SERVER_ERROR, "Place 생성에 오류가 발생하였습니다."), + GEOCODER_ERROR(2606, HttpStatus.INTERNAL_SERVER_ERROR, "좌표 -> 주소 변환 과정에서 오류가 발생하였습니다."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/domain/place/application/exception/GeoCoderException.java b/src/main/java/com/campus/campus/domain/place/application/exception/GeoCoderException.java new file mode 100644 index 00000000..5f3fd85a --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/exception/GeoCoderException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.place.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class GeoCoderException extends ApplicationException { + public GeoCoderException() { + super(ErrorCode.GEOCODER_ERROR); + } +} diff --git a/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java b/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java index 2839e259..6ff69c74 100644 --- a/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java +++ b/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java @@ -4,9 +4,14 @@ import org.springframework.stereotype.Component; +import com.campus.campus.domain.council.domain.entity.CouncilType; +import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; +import com.campus.campus.domain.partnership.application.dto.response.PartnershipPinResponse; import com.campus.campus.domain.place.application.dto.response.LikeResponse; import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; +import com.campus.campus.domain.place.application.dto.response.geocoder.AddressResponse; import com.campus.campus.domain.place.application.dto.response.naver.NaverSearchResponse; +import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipResponse; import com.campus.campus.domain.place.domain.entity.Coordinate; import com.campus.campus.domain.place.domain.entity.LikedPlace; import com.campus.campus.domain.place.domain.entity.Place; @@ -35,10 +40,49 @@ public SavedPlaceInfo toSavedPlaceInfo(NaverSearchResponse.Item item, String pla ); } - public Place createPlace(SavedPlaceInfo savedPlaceInfo, String placeName) { + public PartnershipPinResponse toPartnershipPinResponse(StudentCouncilPost post, Place place) { + return new PartnershipPinResponse( + post.getId(), + place.getPlaceId(), + place.getPlaceName(), + place.getCoordinate().latitude(), + place.getCoordinate().longitude() + ); + } + + public PartnershipResponse toPartnershipResponse(User user, StudentCouncilPost post, Place place, boolean isLiked, + List imgUrls, double distance) { + return new PartnershipResponse( + place.getPlaceId(), + place.getPlaceKey(), + place.getPlaceName(), + place.getPlaceCategory(), + place.getAddress(), + place.getCoordinate().latitude(), + place.getCoordinate().longitude(), + resolveTag(post, user), + isLiked, + 5.0, //리뷰 구현 이후 수정 예정 + post.getTitle(), + distance, + post.getEndDateTime().toLocalDate(), + imgUrls + ); + } + + private String resolveTag(StudentCouncilPost post, User user) { + CouncilType councilType = post.getWriter().getCouncilType(); + return switch (councilType) { + case SCHOOL_COUNCIL -> user.getSchool().getSchoolName(); + case COLLEGE_COUNCIL -> user.getCollege().getCollegeName(); + case MAJOR_COUNCIL -> user.getMajor().getMajorName(); + }; + } + + public Place createPlace(SavedPlaceInfo savedPlaceInfo) { return Place.builder() .placeKey(savedPlaceInfo.placeKey()) - .placeName(placeName) + .placeName(savedPlaceInfo.placeName()) .placeCategory(savedPlaceInfo.category()) .phone(savedPlaceInfo.telephone()) .address(savedPlaceInfo.address()) 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 81a05fe1..ef421082 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 @@ -14,9 +14,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.campus.campus.domain.councilpost.application.dto.request.PostRequest; import com.campus.campus.domain.place.application.dto.response.LikeResponse; import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; import com.campus.campus.domain.place.application.dto.response.SearchCandidateResponse; +import com.campus.campus.domain.place.application.dto.response.geocoder.AddressResponse; import com.campus.campus.domain.place.application.dto.response.naver.NaverSearchResponse; import com.campus.campus.domain.place.application.exception.NaverMapAPIException; import com.campus.campus.domain.place.application.exception.PlaceCreationException; @@ -29,6 +31,7 @@ import com.campus.campus.domain.place.domain.repository.LikedPlacesRepository; import com.campus.campus.domain.place.domain.repository.PlaceImagesRepository; import com.campus.campus.domain.place.domain.repository.PlaceRepository; +import com.campus.campus.domain.place.infrastructure.geocoder.GeoCoderClient; import com.campus.campus.domain.place.infrastructure.google.GooglePlaceClient; import com.campus.campus.domain.place.infrastructure.naver.NaverMapClient; import com.campus.campus.domain.user.application.exception.UserNotFoundException; @@ -55,10 +58,27 @@ public class PlaceService { private final LikedPlacesRepository likedPlacesRepository; private final UserRepository userRepository; private final ExecutorService executorService; + private final GeoCoderClient geoCoderClient; + + public List search(double lat, double lng, String keyword) { + String searchWord = keyword; + + try { + AddressResponse addressResponse = geoCoderClient.getAddress(lat, lng); + String nowAddress = toStringAddress(addressResponse); + + if (nowAddress != null && !nowAddress.isBlank()) { + searchWord = nowAddress + " " + keyword; + log.info("nowAddress={}", nowAddress); + } + } catch (Exception e) { + log.warn("지오코딩 변환 실패 (좌표: {}, {}). 사유: {}", lat, lng, e.getMessage()); + } + + log.info("최종 검색어(searchWord)={}", searchWord); - public List search(String keyword) { //네이버에서 특정 장소 기본정보 받아오기 - NaverSearchResponse naverSearchResponse = naverMapClient.searchPlaces(keyword, 5); + NaverSearchResponse naverSearchResponse = naverMapClient.searchPlaces(searchWord, 5); List candidates = naverSearchResponse.items().stream() .map(item -> { @@ -92,6 +112,16 @@ public List search(String keyword) { return futures.stream().map(CompletableFuture::join).toList(); } + @Transactional + public Place findOrCreatePlace(PostRequest request) { + SavedPlaceInfo place = request.place(); + String placeKey = place.placeKey(); + + //이미 Place 존재하는지 확인 후 없으면 객체 생성 후 저장 + return placeRepository.findByPlaceKey(placeKey) + .orElseGet(() -> placeRepository.save(placeMapper.createPlace(place))); + } + //장소 저장 @Transactional public LikeResponse likePlace(SavedPlaceInfo placeInfo, Long userId) { @@ -117,7 +147,7 @@ public LikeResponse likePlace(SavedPlaceInfo placeInfo, Long userId) { .orElseGet(() -> { //없으면 생성 String placeName = stripHtml(placeInfo.placeName()); - Place newPlace = placeRepository.save(placeMapper.createPlace(placeInfo, placeName)); + Place newPlace = placeRepository.save(placeMapper.createPlace(placeInfo)); //신규 생성된 경우에만 이미지 저장 migrateImagesToOci(newPlace.getPlaceKey(), placeInfo.imgUrls()); @@ -150,7 +180,7 @@ private SavedPlaceInfo fallback(SearchCandidateResponse response) { response.naverPlaceUrl(), List.of()); } - /** + /* * 태그 제거용 */ private String stripHtml(String text) { @@ -212,4 +242,13 @@ private void migrateImagesToOci(String placeKey, List imageUrls) { } } + + private String toStringAddress(AddressResponse nowAddress) { + return nowAddress.getResponse().getResult().stream() + .filter(r -> "road".equalsIgnoreCase(r.getType()) || "parcel".equalsIgnoreCase(r.getType())) + .findFirst() + .map(AddressResponse.Result::getText) + .orElse(null); + } + } diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/LikedPlacesRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/LikedPlacesRepository.java index 298b61eb..3a644c3c 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/repository/LikedPlacesRepository.java +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/LikedPlacesRepository.java @@ -1,14 +1,31 @@ package com.campus.campus.domain.place.domain.repository; +import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.campus.campus.domain.place.domain.entity.LikedPlace; +import com.campus.campus.domain.place.domain.entity.Place; +import com.campus.campus.domain.user.domain.entity.User; public interface LikedPlacesRepository extends JpaRepository { - boolean existsByUserIdAndPlace_PlaceKey(Long userId, String placeKey); - Optional findByUserIdAndPlace_PlaceKey(Long userId, String placeKey); + + @Query(""" + SELECT lp.place.placeId + FROM LikedPlace lp + WHERE lp.user.id=:userId + and lp.place.placeId in :placeIds + """) + Set findLikedPlaceIds( + @Param("userId") Long userId, + @Param("placeIds") List placeIds + ); + + boolean existsByUserAndPlace(User user, Place place); } diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceRepository.java index 2efaf28a..fad50e43 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceRepository.java +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceRepository.java @@ -1,5 +1,6 @@ package com.campus.campus.domain.place.domain.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,4 +11,5 @@ public interface PlaceRepository extends JpaRepository { // placeKey 기준으로 Place 조회 Optional findByPlaceKey(String placeKey); + } diff --git a/src/main/java/com/campus/campus/domain/place/infrastructure/geocoder/GeoCoderClient.java b/src/main/java/com/campus/campus/domain/place/infrastructure/geocoder/GeoCoderClient.java new file mode 100644 index 00000000..67dd6619 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/infrastructure/geocoder/GeoCoderClient.java @@ -0,0 +1,50 @@ +package com.campus.campus.domain.place.infrastructure.geocoder; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import com.campus.campus.domain.place.application.dto.response.geocoder.AddressResponse; +import com.campus.campus.domain.place.application.exception.GeoCoderException; + +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +@Component +@Slf4j +public class GeoCoderClient { + private static final String BASE_URL = "https://api.vworld.kr"; + private final WebClient webClient; + private final String apiKey; + + public GeoCoderClient( + @Value("${map.geocoder.api-key}") String apiKey + ) { + this.apiKey = apiKey; + this.webClient = WebClient.builder().baseUrl(BASE_URL).build(); + } + + /* + * 현재 위치 위/경도 -> 주소 + */ + public AddressResponse getAddress(double lat, double lng) { + return webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/req/address") + .queryParam("service", "address") + .queryParam("request", "getAddress") + .queryParam("key", apiKey) + .queryParam("point", lng + "," + lat) + .queryParam("crs", "epsg:4326") + .queryParam("type", "both") + .queryParam("format", "json") + .build()) + .retrieve() + .onStatus( + HttpStatusCode::isError, + res -> Mono.error(new GeoCoderException()) + ) + .bodyToMono(AddressResponse.class).block(); + } +} diff --git a/src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java b/src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java index 6eaf07d5..150cdbd7 100644 --- a/src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java +++ b/src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java @@ -51,7 +51,7 @@ public List fetchImages(String name, String address, int limit) { return List.of(); } - //place details -> photo reference + //placeName details -> photo reference List photoRefs = getPhotoReferences(placeId); if (photoRefs.isEmpty()) { return List.of(); 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 1d54c726..48605f69 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 @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -11,11 +12,14 @@ import com.campus.campus.domain.place.application.dto.response.LikeResponse; import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; +import com.campus.campus.domain.place.application.dto.response.geocoder.AddressResponse; import com.campus.campus.domain.place.application.service.PlaceService; +import com.campus.campus.domain.place.infrastructure.geocoder.GeoCoderClient; import com.campus.campus.global.annotation.CurrentUserId; import com.campus.campus.global.common.response.CommonResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -25,14 +29,47 @@ public class PlaceController { private final PlaceService placeService; + private final GeoCoderClient geoCoderClient; @GetMapping("/search") - @Operation(summary = "장소 키워드로 검색", description = "검색 결과 5개 검색되도록 함") - public CommonResponse> getPlaceInfo(@RequestParam String keyword) { - List searchResponse = placeService.search(keyword); + @Operation(summary = "현위치 기반 가까운 순으로 장소 키워드 검색", description = "검색 결과 5개 검색되도록 함") + public CommonResponse> getPlaceInfo( + @Parameter( + description = "검색할 키워드", + example = "스타벅스" + ) + @RequestParam String keyword, + @Parameter( + description = "현재 위치의 위도", + example = "37.50415" + ) + @RequestParam double lat, + @Parameter( + description = "현재 위치의 경도", + example = "126.9570" + ) + @RequestParam double lng + ) { + List searchResponse = placeService.search(lat, lng, keyword); return CommonResponse.success(PlaceResponseCode.PLACE_SEARCH_SUCCESS, searchResponse); } + @GetMapping + public ResponseEntity getAddress( + @Parameter( + description = "현재 위치의 위도", + example = "37.50415" + ) + @RequestParam double lat, + @Parameter( + description = "현재 위치의 경도", + example = "126.9570" + ) + @RequestParam double lng + ) { + return ResponseEntity.ok(geoCoderClient.getAddress(lat, lng)); + } + @PostMapping("/like-place") @Operation(summary = "장소 좋아요 누르기") public CommonResponse likePlace(@Valid @RequestBody SavedPlaceInfo request, @@ -40,4 +77,7 @@ public CommonResponse likePlace(@Valid @RequestBody SavedPlaceInfo LikeResponse response = placeService.likePlace(request, userId); return CommonResponse.success(PlaceResponseCode.PLACE_SAVE_SUCCESS, response); } + + //가게 상세 조회 (리뷰 기능 구현 완료 후) + } diff --git a/src/main/java/com/campus/campus/domain/place/presentation/PlaceResponseCode.java b/src/main/java/com/campus/campus/domain/place/presentation/PlaceResponseCode.java index 15164b37..0b1a6920 100644 --- a/src/main/java/com/campus/campus/domain/place/presentation/PlaceResponseCode.java +++ b/src/main/java/com/campus/campus/domain/place/presentation/PlaceResponseCode.java @@ -11,7 +11,8 @@ @AllArgsConstructor public enum PlaceResponseCode implements ResponseCodeInterface { PLACE_SAVE_SUCCESS(200, HttpStatus.OK, "좋아요 처리가 완료되었습니다."), - PLACE_SEARCH_SUCCESS(200, HttpStatus.OK, "키워드 장소 검색이 성공적으로 완료되었습니다."); + PLACE_SEARCH_SUCCESS(200, HttpStatus.OK, "키워드 장소 검색이 성공적으로 완료되었습니다."), + CHECK_PARTNERSHIP_PLACE_SUCCESS(200, HttpStatus.OK, "제휴 장소 조회가 완료되었습니다."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java b/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java index 50243973..171fc2d6 100644 --- a/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java +++ b/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java @@ -30,7 +30,10 @@ public String[] getPublicUrl() { "/jwt/token/reissue", "/managers/login", "/places/search", - "/storage/presigned" + "/storage/presigned", + "/places", + "/api/partnership/list", + "/api/partnership/map" }; } } diff --git a/src/main/java/com/campus/campus/global/util/geocoder/GeoUtil.java b/src/main/java/com/campus/campus/global/util/geocoder/GeoUtil.java new file mode 100644 index 00000000..ead8a441 --- /dev/null +++ b/src/main/java/com/campus/campus/global/util/geocoder/GeoUtil.java @@ -0,0 +1,33 @@ +package com.campus.campus.global.util.geocoder; + +public class GeoUtil { + + private static final double EARTH_RADIUS_KM = 6371.0; + + private GeoUtil() { + } + + public static double distanceKm( + double lat1, double lon1, + double lat2, double lon2 + ) { + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) + * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLon / 2) * Math.sin(dLon / 2); + + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return EARTH_RADIUS_KM * c; + } + + public static double distanceMeter( + double lat1, double lon1, + double lat2, double lon2 + ) { + return distanceKm(lat1, lon1, lat2, lon2) * 1000; + } +} + diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 56f6885f..4ee5fa58 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -62,5 +62,7 @@ map: google: places: api-key: ENC(RbxPchNxZbeIglHQcB4o6vB6QtMv8QJrC+yNT80jr1vneoa3pSMB4Cw09dADjzxQ) + geocoder: + api-key: ENC(xgLHhhUSoE7yyS+SzF/KNmjs9swnSwQ2M1xKqWeJoXxrmhH/1rVOal2l5mZ9MP5E) server-uri: ENC(ZrgPccnQ3mEqVQTFEvGn6hzhP4xcNn6ISnp3TbBcd1J3jpZPb3hlzQ==) \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index e9971244..d41798f3 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -62,5 +62,7 @@ map: google: places: api-key: ${GOOGLE_PLACES_API_KEY} + geocoder: + api-key: ${GEOCODER_API_KEY} server-uri: http://localhost:8080 \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7cf0bb26..337d4215 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -62,5 +62,7 @@ map: google: places: api-key: ENC(RbxPchNxZbeIglHQcB4o6vB6QtMv8QJrC+yNT80jr1vneoa3pSMB4Cw09dADjzxQ) + geocoder: + api-key: ENC(xgLHhhUSoE7yyS+SzF/KNmjs9swnSwQ2M1xKqWeJoXxrmhH/1rVOal2l5mZ9MP5E) server-uri: ENC(pitqB0FTjbgREG33LepbDH3Pobg/8eTTzP882D0V14EEt9fTr1cv+w==) \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 5dedeeab..25689b8e 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -40,6 +40,8 @@ map: google: places: api-key: dummy + geocoder: + api-key: dummy jwt: access: