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..c96c70c9 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,6 +5,7 @@ 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; @@ -22,7 +23,24 @@ 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에서 반환된 결과 중 하나를 선택") + 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..5e25a13a 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, + Long placeId, LocalDateTime endDateTime, String thumbnailImageUrl, ThumbnailIcon thumbnailIcon, 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..0ac41511 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().getPlaceId(), + 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()); @@ -96,6 +99,9 @@ public GetPostResponse toGetPostResponse(StudentCouncilPost post, List i return builder.build(); } + // + // public StudentCouncilPost createStudentCouncilPost(StudentCouncil writer, PostRequest dto, + // LocalDateTime startDateTime, LocalDateTime endDateTime, Place place) { public GetPostForUserResponse toGetPostForUserResponse(StudentCouncilPost post, List images, Long currentUserId, boolean isLiked) { var writer = post.getWriter(); @@ -106,7 +112,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 +140,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..9e717a82 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,12 +71,18 @@ 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); + //제휴 엔티티 생성 + partnershipService.create(post, place); + if (dto.imageUrls() != null) { for (String imageUrl : dto.imageUrls()) { postImageRepository.save(studentCouncilPostMapper.createPostImage(post, imageUrl)); @@ -203,10 +215,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().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..77f990da 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,9 +3,12 @@ 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; +import com.campus.campus.domain.place.application.dto.response.partnership.PostImageSummary; public interface PostImageRepository extends JpaRepository { @@ -14,4 +17,26 @@ public interface PostImageRepository extends JpaRepository { void deleteByPost(StudentCouncilPost post); List findAllByPostOrderByIdAsc(StudentCouncilPost post); + + @Query(""" + SELECT NEW com.campus.campus.domain.place.application.dto.response.partnership.PostImageSummary + (post.place.placeId, + img.imageUrl) + FROM PostImage img + JOIN img.post post + WHERE post.category='PARTNERSHIP' + AND post.place.placeId in :placeIds + ORDER BY img.id asc + """) + List findPartnershipImagesByPlaceIds( + @Param("placeIds") List placeIds + ); + + @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..6fa08fdd 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,87 @@ Page findUpcomingMajorEvents( @Param("limit") LocalDateTime limit, Pageable pageable ); + + @Query(""" + SELECT p + FROM StudentCouncilPost p + WHERE p.place.placeId=:placeId + and p.writer.id=:councilId + and p.category='PARTNERSHIP' + order by p.createdAt desc + """) + Optional findByCouncilIdAndPlaceId( + @Param("placeId") Long placeId, + @Param("councilId") Long councilId + ); + + @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..a2bf5db5 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/partnership/application/service/PartnershipService.java @@ -0,0 +1,193 @@ +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.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.partnership.domain.entity.Partnership; +import com.campus.campus.domain.partnership.domain.entity.PartnershipStatus; +import com.campus.campus.domain.partnership.domain.repository.PartnershipRepository; +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.place.domain.repository.PlaceRepository; +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.jwt.GeoUtil; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Service +public class PartnershipService { + + private final PartnershipRepository partnershipRepository; + private final UserRepository userRepository; + private final LikedPlacesRepository likedPlacesRepository; + private final PostImageRepository postImageRepository; + private final PlaceMapper placeMapper; + private final StudentCouncilPostRepository studentCouncilPostRepository; + private final PlaceRepository placeRepository; + + @Transactional + public List getPartnershipPlaces(Long userId, Long cursor, int size, double userLat, + double userLng) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + 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() + .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(); + } + + private boolean isLiked(Place place, User user) { + return likedPlacesRepository.existsByUserAndPlace(user, place); + } + + private List getImgUrls(StudentCouncilPost post) { + return postImageRepository.findImageUrlsByPost(post); + } + + // 제휴 엔티티 생성 + @Transactional + public Partnership create(StudentCouncilPost post, Place place) { + + // 1. 제휴 기간 결정 + LocalDateTime startDate = post.getStartDateTime(); + LocalDateTime endDate = post.getEndDateTime(); + + // 2. 초기 상태 결정 + PartnershipStatus status = + LocalDateTime.now().isAfter(endDate) + ? PartnershipStatus.EXPIRED + : PartnershipStatus.ACTIVE; + + // 3. Partnership 생성 + Partnership partnership = Partnership.builder() + .post(post) + .place(place) + .startDate(startDate) + .endDate(endDate) + .status(status) + .build(); + + // 4. 저장 + return partnershipRepository.save(partnership); + } + + @Transactional + public List findPartnerInBounds(Long userId, Double minLat, Double maxLat, Double minLng, + Double maxLng) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + 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 변환 + List responses = posts.stream() + .map(post -> placeMapper.toPartnershipPinResponse(post, post.getPlace())) + .toList(); + return responses; + } + + @Transactional + public PartnershipResponse getPartnershipDetail(Long postId, Long userId, double userLat, + double userLng) { + StudentCouncilPost post = studentCouncilPostRepository.findById(postId) + .orElseThrow(PostNotFoundException::new); + Place place = post.getPlace(); + + 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); + } + +} diff --git a/src/main/java/com/campus/campus/domain/partnership/domain/entity/Partnership.java b/src/main/java/com/campus/campus/domain/partnership/domain/entity/Partnership.java new file mode 100644 index 00000000..41fca1e7 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/partnership/domain/entity/Partnership.java @@ -0,0 +1,49 @@ +package com.campus.campus.domain.partnership.domain.entity; + +import java.time.LocalDateTime; + +import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; +import com.campus.campus.domain.place.domain.entity.Place; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public class Partnership { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "partnership_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "place_id", nullable = false) + private Place place; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private StudentCouncilPost post; + + @Enumerated(EnumType.STRING) + private PartnershipStatus status; //ACTIVE, EXPIRED, SUSPENDED + + private LocalDateTime startDate; + private LocalDateTime endDate; + +} diff --git a/src/main/java/com/campus/campus/domain/partnership/domain/entity/PartnershipStatus.java b/src/main/java/com/campus/campus/domain/partnership/domain/entity/PartnershipStatus.java new file mode 100644 index 00000000..7243f28f --- /dev/null +++ b/src/main/java/com/campus/campus/domain/partnership/domain/entity/PartnershipStatus.java @@ -0,0 +1,23 @@ +package com.campus.campus.domain.partnership.domain.entity; + +public enum PartnershipStatus { + + ACTIVE, // 현재 제휴 중 + EXPIRED, // 제휴 기간 만료 + SUSPENDED; // 일시 중단 (관리자/정책에 의함) + + /** + * 사용자에게 노출 가능한 상태인지 여부 + */ + public boolean isVisible() { + return this == ACTIVE; + } + + /** + * 혜택 제공이 가능한 상태인지 여부 + */ + public boolean isBenefitAvailable() { + return this == ACTIVE; + } +} + diff --git a/src/main/java/com/campus/campus/domain/partnership/domain/repository/PartnershipRepository.java b/src/main/java/com/campus/campus/domain/partnership/domain/repository/PartnershipRepository.java new file mode 100644 index 00000000..a44a9951 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/partnership/domain/repository/PartnershipRepository.java @@ -0,0 +1,61 @@ +package com.campus.campus.domain.partnership.domain.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Pageable; +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.council.domain.entity.CouncilType; +import com.campus.campus.domain.partnership.domain.entity.Partnership; +import com.campus.campus.domain.partnership.domain.entity.PartnershipStatus; +import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipPlaceSummary; + +public interface PartnershipRepository extends JpaRepository { + + @Query(""" + select new com.campus.campus.domain.place.application.dto.response.partnership.PartnershipPlaceSummary( + pt.id, + p.placeId, + p.placeKey, + p.placeName, + p.placeCategory, + p.address, + p.coordinate.latitude, + p.coordinate.longitude, + council.councilType + ) + from Partnership pt + join pt.place p + join pt.post post + join post.writer council + where pt.status = :activeStatus + and :today between pt.startDate and pt.endDate + and council.deletedAt is null + and ( + (council.councilType = :majorType + and council.major.majorId = :majorId) + or (council.councilType = :collegeType + and council.college.collegeId = :collegeId) + or (council.councilType = :schoolType + and council.school.schoolId = :schoolId) + ) + and (:cursor is null or pt.id < :cursor) + order by pt.id desc + """) + List findActivePartnershipPlaces( + @Param("today") LocalDateTime today, + @Param("majorId") Long majorId, + @Param("collegeId") Long collegeId, + @Param("schoolId") Long schoolId, + @Param("cursor") Long cursor, + @Param("activeStatus") PartnershipStatus activeStatus, + @Param("majorType") CouncilType majorType, + @Param("collegeType") CouncilType collegeType, + @Param("schoolType") CouncilType schoolType, + Pageable pageable + ); +} + 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..3cfefb6a 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 @@ -32,5 +32,10 @@ public record SavedPlaceInfo( @Schema(description = "이미지 url") List imgUrls + // @Schema(description = "좋아요 여부") + // boolean isLiked, + // + // @Schema(description = "제휴 있을 시, 태그도 함께 반환") + // List tags ) { } 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..ac29c679 --- /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/PartnershipMapResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipMapResponse.java new file mode 100644 index 00000000..e80186cc --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipMapResponse.java @@ -0,0 +1,12 @@ +package com.campus.campus.domain.place.application.dto.response.partnership; + +import java.util.List; + +public record PartnershipMapResponse( + Long placeId, + Double latitude, + Double longitude, + List tags +) { +} + diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipMapSummary.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipMapSummary.java new file mode 100644 index 00000000..73a34516 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipMapSummary.java @@ -0,0 +1,12 @@ +package com.campus.campus.domain.place.application.dto.response.partnership; + +import com.campus.campus.domain.council.domain.entity.CouncilType; + +public record PartnershipMapSummary( + Long placeId, + Double latitude, + Double longitude, + CouncilType councilType +) { +} + diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipPlaceSummary.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipPlaceSummary.java new file mode 100644 index 00000000..675e2a48 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipPlaceSummary.java @@ -0,0 +1,19 @@ +package com.campus.campus.domain.place.application.dto.response.partnership; + +import com.campus.campus.domain.council.domain.entity.CouncilType; + +public record PartnershipPlaceSummary( + Long partnershipId, //추가 + + Long placeId, + String placeKey, + String name, + String category, + String address, + Double latitude, + Double longitude, + + // Long councilId, + CouncilType councilType +) { +} 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..d5f1442a --- /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/dto/response/partnership/PartnershipScrollResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipScrollResponse.java new file mode 100644 index 00000000..8b0d3f6a --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipScrollResponse.java @@ -0,0 +1,10 @@ +package com.campus.campus.domain.place.application.dto.response.partnership; + +import java.util.List; + +public record PartnershipScrollResponse( + List items, + boolean hasNext, + Long nextCursor +) { +} diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PostImageSummary.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PostImageSummary.java new file mode 100644 index 00000000..dc7ca3a8 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PostImageSummary.java @@ -0,0 +1,7 @@ +package com.campus.campus.domain.place.application.dto.response.partnership; + +public record PostImageSummary( + Long placeId, + String imageUrl +) { +} 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..0b4a035c 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(2605, 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..74876c90 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 -> "총학생회"; + 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()) @@ -47,6 +91,14 @@ public Place createPlace(SavedPlaceInfo savedPlaceInfo, String placeName) { .build(); } + public 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); + } + public PlaceImages createPlaceImages(String placeKey, String googleImageUrl) { return PlaceImages.builder() .placeKey(placeKey) 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..6dce7ebd 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,20 @@ 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) { + //현위치 좌표 -> 주소로 변환 + AddressResponse geocoderRes = geoCoderClient.getAddress(lat, lng); + String nowAddress = placeMapper.toStringAddress(geocoderRes); + + //주소 + 키워드 합쳐서 검색하도록 함 + String searchWord = nowAddress + keyword; + log.info("nowAddress={}", nowAddress); + 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 +105,21 @@ 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 존재하는지 확인 + Optional existing = placeRepository.findByPlaceKey(placeKey); + if (existing.isPresent()) { + return existing.get(); + } + + //저장되어 있지 않는 Place의 경우, 객체 생성 후 저장 + return placeRepository.save(placeMapper.createPlace(place)); + } + //장소 저장 @Transactional public LikeResponse likePlace(SavedPlaceInfo placeInfo, Long userId) { @@ -117,7 +145,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()); @@ -140,6 +168,10 @@ private SavedPlaceInfo convertToSavedPlaceInfo(SearchCandidateResponse response, List placeImages = !cached.isEmpty() ? cached : googleClient.fetchImages(response.name(), response.address(), 3); + //좋아요 있는지 확인 + + //제휴 장소인지 확인 + return placeMapper.toSavedPlaceInfo(response.item(), response.name(), response.placeKey(), response.naverPlaceUrl(), placeImages == null ? List.of() : placeImages ); @@ -212,4 +244,5 @@ private void migrateImagesToOci(String placeKey, List imageUrls) { } } + } 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..29159521 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,13 +1,87 @@ package com.campus.campus.domain.place.domain.repository; +import java.util.List; import java.util.Optional; 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.application.dto.response.partnership.PartnershipMapSummary; import com.campus.campus.domain.place.domain.entity.Place; public interface PlaceRepository extends JpaRepository { // placeKey 기준으로 Place 조회 Optional findByPlaceKey(String placeKey); + + //제휴 가게 조회(학과) + // @Query(""" + // select new com.campus.campus.domain.place.application.dto.response.partnership.PartnershipPlaceSummary( + // p.placeId, + // p.placeKey, + // p.placeName, + // p.placeCategory, + // p.address, + // p.coordinate.latitude, + // p.coordinate.longitude, + // + // council.councilType + // ) + // from StudentCouncilPost post + // join post.place p + // join post.writer council + // where post.category = :category + // and ( + // (council.councilType = 'MAJOR' + // and council.major.majorId = :majorId) + // or (council.councilType = 'COLLEGE' + // and council.college.collegeId = :collegeId) + // or (council.councilType = 'SCHOOL' + // and council.school.schoolId = :schoolId) + // ) + // and (:cursor is null or p.placeId<:cursor) + // order by p.placeId desc + // """) + // List findPartnershipPlaces( + // @Param("category") PostCategory category, + // @Param("majorId") Long majorId, + // @Param("collegeId") Long collegeId, + // @Param("schoolId") Long schoolId, + // @Param("cursor") Long cursor, + // Pageable pageable + // ); + + @Query(""" + select distinct new com.campus.campus.domain.place.application.dto.response.partnership.PartnershipMapSummary( + p.placeId, + p.coordinate.latitude, + p.coordinate.longitude, + council.councilType + ) + from StudentCouncilPost post + join post.place p + join post.writer council + where post.category = 'PARTNERSHIP' + and p.coordinate.latitude between :minLat and :maxLat + and p.coordinate.longitude between :minLng and :maxLng + and ( + (council.councilType = 'MAJOR' + and council.major.majorId = :majorId) + or (council.councilType = 'COLLEGE' + and council.college.collegeId = :collegeId) + or (council.councilType = 'SCHOOL' + and council.school.schoolId = :schoolId) + ) + """) + List findPartnershipPlacesForMap( + @Param("minLat") double minLat, + @Param("maxLat") double maxLat, + @Param("minLng") double minLng, + @Param("maxLng") double maxLng, + @Param("majorId") Long majorId, + @Param("collegeId") Long collegeId, + @Param("schoolId") Long schoolId + ); + } 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..f7265123 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/infrastructure/geocoder/GeoCoderClient.java @@ -0,0 +1,51 @@ +package com.campus.campus.domain.place.infrastructure.geocoder; + +import org.springframework.beans.factory.annotation.Value; +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) { + AddressResponse response = 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( + status -> status.isError(), + res -> Mono.error(new GeoCoderException()) + ) + .bodyToMono(AddressResponse.class).block(); + + return response; + } +} diff --git a/src/main/java/com/campus/campus/domain/place/infrastructure/naver/NaverDirectionsClient.java b/src/main/java/com/campus/campus/domain/place/infrastructure/naver/NaverDirectionsClient.java new file mode 100644 index 00000000..723864b1 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/infrastructure/naver/NaverDirectionsClient.java @@ -0,0 +1,50 @@ +// package com.campus.campus.domain.place.infrastructure.naver; +// +// import org.springframework.beans.factory.annotation.Value; +// import org.springframework.stereotype.Component; +// import org.springframework.web.client.RestClient; +// import org.springframework.web.reactive.function.client.WebClient; +// +// import lombok.extern.slf4j.Slf4j; +// +// @Component +// @Slf4j +// public class NaverDirectionsClient { +// +// private final WebClient webClient; +// private final String clientId; +// private final String clientSecret; +// +// public NaverDirectionsClient( +// RestClient restClient, +// @Value("${map.naver.client-id}") String clientId, +// @Value("${map.naver.client-secret}") String clientSecret) { +// this.restClient = restClient; +// this.clientId = clientId; +// this.clientSecret = clientSecret; +// } +// +// public int getWalkingTimeSeconds( +// double startLat, double startLng, +// double goalLat, double goalLng +// ) { +// DirectionsResponse response = webClient.get() +// .uri(uriBuilder -> uriBuilder +// .path("/map-direction/v1/driving") +// .queryParam("start", startLng + "," + startLat) +// .queryParam("goal", goalLng + "," + goalLat) +// .queryParam("option", "pedestrian") +// .build() +// ) +// .retrieve() +// .bodyToMono(DirectionsResponse.class) +// .block(); +// +// return response.getRoute() +// .getTraoptimal() +// .get(0) +// .getSummary() +// .getDuration(); // 초 단위 +// } +// +// } 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/domain/user/application/service/KakaoOauthService.java b/src/main/java/com/campus/campus/domain/user/application/service/KakaoOauthService.java index 6a44d0b3..5d23b1ee 100644 --- a/src/main/java/com/campus/campus/domain/user/application/service/KakaoOauthService.java +++ b/src/main/java/com/campus/campus/domain/user/application/service/KakaoOauthService.java @@ -47,8 +47,9 @@ public class KakaoOauthService { private long refreshTokenExpirationSeconds; @Transactional - public OauthLoginResponse login(String kakaoAccessToken) { - KakaoUserResponse kakaoUser = getUserInfo(kakaoAccessToken); + public OauthLoginResponse login(String authorizationCode) { + KakaoTokenResponse kakaoToken = getToken(authorizationCode); + KakaoUserResponse kakaoUser = getUserInfo(kakaoToken.accessToken()); User user = findOrCreateUser(kakaoUser); @@ -96,6 +97,28 @@ public boolean unlink(Long kakaoId) { } } + private KakaoTokenResponse getToken(String authorizationCode) { + RestClient client = RestClient.create(); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("client_id", kakaoOauthProperty.getClientId()); + body.add("redirect_uri", kakaoOauthProperty.getRedirectUri()); + body.add("code", authorizationCode); + + if (kakaoOauthProperty.getClientSecret() != null && + !kakaoOauthProperty.getClientSecret().isBlank()) { + body.add("client_secret", kakaoOauthProperty.getClientSecret()); + } + + return client.post() + .uri(KAUTH_BASE_URL + "/oauth/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(KakaoTokenResponse.class); + } + private KakaoUserResponse getUserInfo(String kakaoAccessToken) { RestClient client = RestClient.create(); diff --git a/src/main/java/com/campus/campus/domain/user/presentation/AuthController.java b/src/main/java/com/campus/campus/domain/user/presentation/AuthController.java index a6012080..9c36270c 100644 --- a/src/main/java/com/campus/campus/domain/user/presentation/AuthController.java +++ b/src/main/java/com/campus/campus/domain/user/presentation/AuthController.java @@ -24,9 +24,9 @@ public class AuthController { private final KakaoOauthService kakaoOauthService; @PostMapping("/login/kakao") - @Operation(summary = "카카오 로그인 (Native App 방식)") - public CommonResponse kakaoLogin(@RequestParam("token") String kakaoAccessToken) { - OauthLoginResponse response = kakaoOauthService.login(kakaoAccessToken); + @Operation(summary = "카카오 로그인") + public CommonResponse kakaoLogin(@RequestParam("code") String code) { + OauthLoginResponse response = kakaoOauthService.login(code); return CommonResponse.success(UserResponseCode.LOGIN_SUCCESS, response); } 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/jwt/GeoUtil.java b/src/main/java/com/campus/campus/global/util/jwt/GeoUtil.java new file mode 100644 index 00000000..e6dcb9b5 --- /dev/null +++ b/src/main/java/com/campus/campus/global/util/jwt/GeoUtil.java @@ -0,0 +1,33 @@ +package com.campus.campus.global.util.jwt; + +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-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