From d9426d052c87e94b05f16f26d64c167bf82b8583 Mon Sep 17 00:00:00 2001 From: TToiTToy Date: Thu, 26 Jun 2025 20:18:52 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat=20:=20=EC=84=B1=EB=8A=A5=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes: #이슈번호 fixes: #이슈번호 --- 127.0.0.1 | 0 .../post/repository/PostRepository.java | 4 +- .../application/SpotClusteringService.java | 18 +++ .../application/SpotClusteringV2Service.java | 117 ++++++++++++++++ .../spot/presentation/SpotController.java | 30 +++- .../spot/presentation/SpotV2Controller.java | 39 ++++++ src/main/resources/mappers/PostMapper.xml | 83 ++++++----- src/main/resources/mappers/SpotMapper.xml | 6 + .../com/shotmap/AttractionDetailTest.java | 28 ++++ .../java/com/shotmap/SpotClusteringTest.java | 131 ++++++++++++++++++ src/test/java/com/shotmap/SpotDetailTest.java | 31 +++++ src/test/java/com/shotmap/SpotPromptTest.java | 91 ++++++++++++ src/test/resources/application-test.yml | 24 ++++ 13 files changed, 559 insertions(+), 43 deletions(-) create mode 100644 127.0.0.1 create mode 100644 src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java create mode 100644 src/main/java/com/shotmap/spot/presentation/SpotV2Controller.java create mode 100644 src/test/java/com/shotmap/AttractionDetailTest.java create mode 100644 src/test/java/com/shotmap/SpotClusteringTest.java create mode 100644 src/test/java/com/shotmap/SpotDetailTest.java create mode 100644 src/test/java/com/shotmap/SpotPromptTest.java create mode 100644 src/test/resources/application-test.yml diff --git a/127.0.0.1 b/127.0.0.1 new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/shotmap/post/repository/PostRepository.java b/src/main/java/com/shotmap/post/repository/PostRepository.java index 6a6daa9..34e05c8 100644 --- a/src/main/java/com/shotmap/post/repository/PostRepository.java +++ b/src/main/java/com/shotmap/post/repository/PostRepository.java @@ -54,8 +54,10 @@ public interface PostRepository { void insertHeart(@Param("postId") Long postId, @Param("userId") Long userId); void deleteHeart(@Param("postId") Long postId, @Param("userId") Long userId); - + List findPostsBySpotId(@Param("spotId") Long spotId, @Param("pageable") Pageable pageable); long countPostsBySpotId(@Param("spotId") Long spotId); + + List findPostWithLimit(int limit); } diff --git a/src/main/java/com/shotmap/spot/application/SpotClusteringService.java b/src/main/java/com/shotmap/spot/application/SpotClusteringService.java index e95fac4..dacd136 100644 --- a/src/main/java/com/shotmap/spot/application/SpotClusteringService.java +++ b/src/main/java/com/shotmap/spot/application/SpotClusteringService.java @@ -6,6 +6,7 @@ import com.shotmap.spot.repository.SpotRepository; import com.shotmap.spot.util.HaversineDistance; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import smile.clustering.DBSCAN; @@ -22,6 +23,23 @@ public class SpotClusteringService { private static final double EPSILON = 0.1; private static final int MIN_POINT = 3; + @Transactional + public long generateSpotsWithLimit(int limit) { + long start = System.currentTimeMillis(); + + deleteAllSpots(); + List posts = postRepository.findPostWithLimit(limit); + Map> clusters = performClustering(posts); + for (List cluster : clusters.values()) { + Spot spot = createSpot(cluster); + assignSpotToPost(cluster, spot.getId()); + } + + long end = System.currentTimeMillis(); + return end - start; + + } + @Transactional public void generateSpots() { deleteAllSpots(); diff --git a/src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java b/src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java new file mode 100644 index 0000000..27f027a --- /dev/null +++ b/src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java @@ -0,0 +1,117 @@ +package com.shotmap.spot.application; + +import com.shotmap.post.domain.Post; +import com.shotmap.post.repository.PostRepository; +import com.shotmap.spot.domain.Spot; +import com.shotmap.spot.repository.SpotRepository; +import com.shotmap.spot.util.HaversineDistance; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import smile.clustering.DBSCAN; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class SpotClusteringV2Service { + private final SpotRepository spotRepository; + private final PostRepository postRepository; + + private static final double EPSILON = 0.1; + private static final int MIN_POINT = 3; + + @Transactional + public long generateSpotsWithLimit(int limit) { + long start = System.currentTimeMillis(); + + List posts = postRepository.findPostWithLimit(limit); + Map> clusters = performClustering(posts); + for (List cluster : clusters.values()) { + Long spotIdToUse = determineSpotIdToUse(cluster); // 👈 과반수 spotId 또는 null + + Spot spot; + if (spotIdToUse != null) { + spot = spotRepository.findById(spotIdToUse); // 유지 + } else { + spot = createSpot(cluster); // 새로 생성 + } + + assignSpotToPost(cluster, spot.getId()); + } + long end = System.currentTimeMillis(); + return end - start; + + } + + private Long determineSpotIdToUse(List cluster) { + Map countMap = new HashMap<>(); + for (Post post : cluster) { + if (post.getSpotId() != null) { + countMap.merge(post.getSpotId(), 1, Integer::sum); + } + } + + // 과반수 spotId가 있다면 반환 + return countMap.entrySet().stream() + .max(Map.Entry.comparingByValue()) + .filter(e -> e.getValue() > cluster.size() / 2) + .map(Map.Entry::getKey) + .orElse(null); + } + + + @Transactional + public void generateSpots() { + deleteAllSpots(); + List posts = getAllPost(); + Map> clusters = performClustering(posts); + for (List cluster : clusters.values()) { + Spot spot = createSpot(cluster); + assignSpotToPost(cluster, spot.getId()); + } + } + + public void deleteAllSpots() { + spotRepository.deleteAll(); + } + + public List getAllPost() { + return postRepository.findAllPost(); + } + + public Map> performClustering(List posts) { + + double[][] coords = posts.stream().map(p -> new double[]{p.getLatitude(), p.getLongitude()}) + .toArray((double[][]::new)); + + DBSCAN dbscan = DBSCAN.fit(coords, new HaversineDistance(), MIN_POINT, EPSILON); + int[] labels = dbscan.y; + + Map> clusterMap = new HashMap<>(); + for (int i = 0; i < labels.length; i++) { + if (labels[i] == Integer.MAX_VALUE) { + continue; + } + clusterMap.computeIfAbsent(labels[i], k -> new ArrayList<>()).add(posts.get(i)); + } + + return clusterMap; + } + + public Spot createSpot(List cluster) { + double avgLatitude = cluster.stream().mapToDouble(Post::getLatitude).average().orElse(0); + double avgLongitude = cluster.stream().mapToDouble(Post::getLongitude).average().orElse(0); + Spot spot = Spot.builder().latitude(avgLatitude).longitude(avgLongitude).build(); + spotRepository.insert(spot); + return spot; + } + + public void assignSpotToPost(List cluster, Long spotId) { + for (Post post : cluster) { + post.setSpotId(spotId); + } + postRepository.batchUpdateSpotId(cluster); + } +} diff --git a/src/main/java/com/shotmap/spot/presentation/SpotController.java b/src/main/java/com/shotmap/spot/presentation/SpotController.java index 4615006..ebfb4f6 100644 --- a/src/main/java/com/shotmap/spot/presentation/SpotController.java +++ b/src/main/java/com/shotmap/spot/presentation/SpotController.java @@ -4,6 +4,7 @@ import com.shotmap.global.response.PagedResponse; import com.shotmap.post.response.PostListResponse; +import com.shotmap.spot.application.SpotClusteringService; import com.shotmap.spot.request.SpotSearchNearbyRequest; import com.shotmap.spot.request.SpotRecommendationRequest; import com.shotmap.spot.response.SpotDetailResponse; @@ -15,11 +16,12 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.*; +import lombok.extern.slf4j.Slf4j; +import java.util.ArrayList; import java.util.List; - - +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/spots") @@ -27,10 +29,11 @@ public class SpotController { private final SpotService spotService; private final SpotRecommendationService spotRecommendationService; + private final SpotClusteringService clusteringService; @GetMapping("/nearby") - public ApiResponse getNearbySpots(SpotSearchNearbyRequest request,@PageableDefault(size = 10, sort="heart_count") Pageable pageable){ - PagedResponse response =spotService.findNearbySpots(request, pageable); + public ApiResponse getNearbySpots(SpotSearchNearbyRequest request, @PageableDefault(size = 10, sort = "heart_count") Pageable pageable) { + PagedResponse response = spotService.findNearbySpots(request, pageable); return new ApiResponse<>(response); } @@ -49,8 +52,25 @@ public ApiResponse> spotRecommendation(@Request } @GetMapping("/{spotId}/posts") - public ApiResponse> getPostsBySpotId(@PathVariable Long spotId, @PageableDefault(size=4, sort="post_create_at") Pageable pageable){ + public ApiResponse> getPostsBySpotId(@PathVariable Long spotId, @PageableDefault(size = 4, sort = "post_create_at") Pageable pageable) { PagedResponse response = spotService.findPostsBySpotId(spotId, pageable); return new ApiResponse<>(response); } + + @GetMapping("/clustering") + public ApiResponse spotClustering() { + List limits = List.of(30000, 33000, 36000, 39000, 42000, 45000, 48000, 50000); + List times = new ArrayList<>(); + for (int i = 0; i < limits.size(); i++) { + int limit = limits.get(i); + log.info("[Clustering Test] Round {} | limit = {}", (i + 1), limit); + times.add(clusteringService.generateSpotsWithLimit(limit)); + } + log.info("======== Clustering Summary ========"); + for (int i = 0; i < times.size(); i++) { + log.info("Limit: {} | Time: {}ms", limits.get(i), times.get(i)); + } + log.info("===================================="); + return new ApiResponse<>(null); + } } diff --git a/src/main/java/com/shotmap/spot/presentation/SpotV2Controller.java b/src/main/java/com/shotmap/spot/presentation/SpotV2Controller.java new file mode 100644 index 0000000..92242cf --- /dev/null +++ b/src/main/java/com/shotmap/spot/presentation/SpotV2Controller.java @@ -0,0 +1,39 @@ +package com.shotmap.spot.presentation; + +import com.shotmap.global.response.ApiResponse; +import com.shotmap.spot.application.SpotClusteringService; +import com.shotmap.spot.application.SpotClusteringV2Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v2/spots") +public class SpotV2Controller { + + private final SpotClusteringV2Service clusteringService; + + @GetMapping("/clustering") + public ApiResponse spotClustering() { + List limits = List.of(30000, 33000, 36000, 39000, 42000, 45000, 48000, 50000); + List times = new ArrayList<>(); + for (int i = 0; i < limits.size(); i++) { + int limit = limits.get(i); + log.info("[Clustering Test] Round {} | limit = {}", (i + 1), limit); + times.add(clusteringService.generateSpotsWithLimit(limit)); + } + log.info("======== Clustering Summary ========"); + for (int i = 0; i < times.size(); i++) { + log.info("Limit: {} | Time: {}ms", limits.get(i), times.get(i)); + } + log.info("===================================="); + return new ApiResponse<>(null); + } +} diff --git a/src/main/resources/mappers/PostMapper.xml b/src/main/resources/mappers/PostMapper.xml index e6540e4..6eb5adb 100644 --- a/src/main/resources/mappers/PostMapper.xml +++ b/src/main/resources/mappers/PostMapper.xml @@ -18,16 +18,16 @@ u.no AS user_no, u.nickname AS user_nickname, u.profile_image_url AS user_profile FROM post p LEFT JOIN ( - SELECT post_no, COUNT(*) AS heart_count - FROM heart - WHERE is_deleted = FALSE - GROUP BY post_no + SELECT post_no, COUNT(*) AS heart_count + FROM heart + WHERE is_deleted = FALSE + GROUP BY post_no )hc ON p.no = hc.post_no LEFT JOIN users u ON p.user_no = u.no WHERE p.latitude BETWEEN #{data.swLocation.latitude} AND #{data.neLocation.latitude} - AND p.longitude BETWEEN #{data.swLocation.longitude} AND #{data.neLocation.longitude} - AND p.is_deleted = false - AND u.is_deleted = false + AND p.longitude BETWEEN #{data.swLocation.longitude} AND #{data.neLocation.longitude} + AND p.is_deleted = false + AND u.is_deleted = false ORDER BY @@ -53,20 +53,20 @@ u.no AS user_no, u.nickname AS user_nickname, u.profile_image_url AS user_profile FROM post p LEFT JOIN ( - SELECT post_no, COUNT(*) AS heart_count - FROM heart - WHERE is_deleted = FALSE - GROUP BY post_no - )hc ON p.no = hc.post_no + SELECT post_no, COUNT(*) AS heart_count + FROM heart + WHERE is_deleted = FALSE + GROUP BY post_no + )hc ON p.no = hc.post_no JOIN users u ON p.user_no = u.no WHERE p.user_no = #{userId} - AND p.is_deleted = false - AND u.is_deleted = false + AND p.is_deleted = false + AND u.is_deleted = false ORDER BY p.created_at DESC LIMIT #{pageable.pageSize} OFFSET #{pageable.offset} - SELECT COUNT(*) FROM post WHERE user_no = #{userId} @@ -74,18 +74,18 @@ @@ -187,7 +187,7 @@ AND pi.is_deleted = FALSE - SELECT t.no AS id, t.name AS name FROM post_tag pt JOIN tag t @@ -251,11 +251,11 @@ @@ -270,6 +270,15 @@ UPDATE heart SET is_deleted = TRUE WHERE post_no = #{postId} - AND user_no = #{userId} + AND user_no = #{userId} + + \ No newline at end of file diff --git a/src/main/resources/mappers/SpotMapper.xml b/src/main/resources/mappers/SpotMapper.xml index a980086..ef10b20 100644 --- a/src/main/resources/mappers/SpotMapper.xml +++ b/src/main/resources/mappers/SpotMapper.xml @@ -125,4 +125,10 @@ ORDER BY COUNT(t.no) DESC LIMIT 3 + + \ No newline at end of file diff --git a/src/test/java/com/shotmap/AttractionDetailTest.java b/src/test/java/com/shotmap/AttractionDetailTest.java new file mode 100644 index 0000000..98c9315 --- /dev/null +++ b/src/test/java/com/shotmap/AttractionDetailTest.java @@ -0,0 +1,28 @@ +package com.shotmap; + +import com.shotmap.attraction.response.AttractionDetailDto; +import com.shotmap.attraction.service.AttractionService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +public class AttractionDetailTest { + @Autowired + private AttractionService attractionService; + + @Test + void test_AttractionDetail() { + AttractionDetailDto attraction = attractionService.findAttractionById(56653L); + assertThat(attraction).isNotNull(); + assertThat(attraction.getAttraction()).isNotNull(); + assertThat(attraction.getAttraction().getTitle()).isEqualTo("강서역사문화거리"); + } +} diff --git a/src/test/java/com/shotmap/SpotClusteringTest.java b/src/test/java/com/shotmap/SpotClusteringTest.java new file mode 100644 index 0000000..a3b2933 --- /dev/null +++ b/src/test/java/com/shotmap/SpotClusteringTest.java @@ -0,0 +1,131 @@ +package com.shotmap; + +import com.shotmap.post.domain.Post; +import com.shotmap.post.domain.TimeBlock; +import com.shotmap.post.repository.PostRepository; +import com.shotmap.spot.domain.Spot; +import com.shotmap.spot.repository.SpotRepository; +import com.shotmap.spot.service.SpotClusteringService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Commit; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +public class SpotClusteringTest { + + + @Autowired + private SpotRepository spotRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private SpotClusteringService spotClusteringService; + + @Test + @Rollback(false) + void testClusterUpdate() { + spotClusteringService.generateSpots(); + } + +// // 중간 위치에 새 게시물 추가 (1번과 2번을 잇는 중간 지점) +// postRepository.insert(createMockPost("C", 37.15, 127.15)); +// +// // 2차 클러스터링 → Spot 1개로 합쳐지며 총 1 + 외톨이 2 → 3개 예상 +// spotClusteringService.generateSpots(); +// +// +// // 외딴 게시물 추가 (새로운 Spot 유도) +// postRepository.insert(createMockPost("D", 38.0, 128.0)); +// +// // 3차 클러스터링 → Spot 총 4개 +// spotClusteringService.generateSpots(); + + +} + +// @Test +// void test_1_insertSpotAndDeleteAll() { +// // given +// Spot spot = Spot.builder() +// .latitude(37.1234) +// .longitude(127.4567) +// .build(); +// +// // when +// spotRepository.insert(spot); +// assertThat(spot.getId()).isNotNull(); +// +// spotRepository.deleteAll(); +// +// // then: 이후 select 시 비어 있어야 +// // (추가로 findAll을 Spot에도 구현했다면 여기서 확인 가능) +// } +// +// @Test +// void test_2_insertPostAndFindAll() { +// // given +// Post post1 = createMockPost("A", 37.11, 127.11); +// Post post2 = createMockPost("B", 37.12, 127.12); +// +// postRepository.insert(post1); +// postRepository.insert(post2); +// +// // when +// List posts = postRepository.findAllPost(); +// +// // then +// assertThat(posts.size()).isGreaterThanOrEqualTo(2); +// assertThat(posts).extracting(Post::getTitle).contains("A", "B"); +// } +// +// @Test +// void test_3_batchUpdateSpotId() { +// // given +// Post post1 = createMockPost("A", 37.11, 127.11); +// Post post2 = createMockPost("B", 37.12, 127.12); +// postRepository.insert(post1); +// postRepository.insert(post2); +// +// Spot spot = Spot.builder().latitude(37.1).longitude(127.1).build(); +// spotRepository.insert(spot); +// +// Long spotId = spot.getId(); +// post1.setSpotId(spotId); +// post2.setSpotId(spotId); +// +// // when +// postRepository.batchUpdateSpotId(List.of(post1, post2)); +// +// // then +// List posts = postRepository.findAllPost(); +// assertThat(posts).allMatch(p -> spotId.equals(p.getSpotId())); +// } +// +// private Post createMockPost(String title, double lat, double lon) { +// return Post.builder() +// .title(title) +// .latitude(lat) +// .longitude(lon) +// .tip("테스트 팁") +// .recommendTime(TimeBlock.MORNING) +// .viewCount(0L) +// .userId(1L) +// .spotId(null) +// .build(); +// } + diff --git a/src/test/java/com/shotmap/SpotDetailTest.java b/src/test/java/com/shotmap/SpotDetailTest.java new file mode 100644 index 0000000..da21497 --- /dev/null +++ b/src/test/java/com/shotmap/SpotDetailTest.java @@ -0,0 +1,31 @@ +package com.shotmap; + +import com.shotmap.attraction.response.AttractionDetailDto; +import com.shotmap.spot.response.SpotDetailDto; +import com.shotmap.spot.service.SpotService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +public class SpotDetailTest { + + @Autowired + private SpotService spotService; + + @Test + void test_spotDetail() { + SpotDetailDto spot = spotService.findSpotById(40L); + assertThat(spot).isNotNull(); + assertThat(spot.getSpotNo()).isEqualTo(40); + assertThat(spot.getPostImageUrls()).contains("aaaaaa.ssafy.com"); + + } + +} diff --git a/src/test/java/com/shotmap/SpotPromptTest.java b/src/test/java/com/shotmap/SpotPromptTest.java new file mode 100644 index 0000000..1a2a534 --- /dev/null +++ b/src/test/java/com/shotmap/SpotPromptTest.java @@ -0,0 +1,91 @@ +package com.shotmap; + +import com.shotmap.attraction.vo.Location; +import com.shotmap.spot.repository.SpotRepository; +import com.shotmap.spot.request.SpotRecommendationRequest; +import com.shotmap.spot.response.SpotPostPromptDto; +import com.shotmap.spot.response.SpotPromptDto; +import com.shotmap.spot.response.SpotRecommendationDto; +import com.shotmap.spot.service.SpotRecommendationService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +public class SpotPromptTest { + + @Autowired + private SpotRepository spotRepository; + + @Autowired + private SpotRecommendationService spotRecommendationService; + + @Test + void Test_SpotPrompt() { + SpotRecommendationRequest request = SpotRecommendationRequest.builder() + .mood("감성적인") + .swLocation(Location.builder() + .latitude(new BigDecimal("37.558")) + .longitude(new BigDecimal("126.925")) + .build()) + .neLocation(Location.builder() + .latitude(new BigDecimal("37.568")) + .longitude(new BigDecimal("126.935")) + .build()) + .centerLocation(Location.builder() + .latitude(new BigDecimal("37.558")) + .longitude(new BigDecimal("126.925")) + .build()) + .build(); + // when + List result = spotRepository.findPromptDataByBoundsAndMood(request); + + // then + assertThat(result).isNotEmpty(); // ✅ 스팟이 하나 이상이어야 함 + + SpotPromptDto firstSpot = result.get(0); + assertThat(firstSpot.getSpotId()).isNotNull(); // ✅ spotId 존재 + assertThat(firstSpot.getPosts()).isNotEmpty(); // ✅ 최소 1개의 게시글 포함 + + SpotPostPromptDto firstPost = firstSpot.getPosts().get(0); + assertThat(firstPost.getTitle()).isNotBlank(); // ✅ 게시글 제목 존재 + assertThat(firstPost.getTip()).isNotBlank(); // ✅ 팁 존재 + } + + @Test + void Test_SpotRecommendationService() { + SpotRecommendationRequest request = SpotRecommendationRequest.builder() + .mood("감성적인") + .swLocation(Location.builder() + .latitude(new BigDecimal("37.558")) + .longitude(new BigDecimal("126.925")) + .build()) + .neLocation(Location.builder() + .latitude(new BigDecimal("37.568")) + .longitude(new BigDecimal("126.935")) + .build()) + .centerLocation(Location.builder() + .latitude(new BigDecimal("37.558")) + .longitude(new BigDecimal("126.925")) + .build()) + .build(); + List result = spotRecommendationService.recommend(request); + + assertThat(result).isNotEmpty(); // ✅ 추천 결과 있어야 함 + assertThat(result.get(0).getSpotId()).isNotNull(); // ✅ spotId 포함 + assertThat(result.get(0).getReason()).isNotBlank(); // ✅ 추천 이유 포함 + + System.out.println("추천 결과:"); + result.forEach(r -> System.out.println("spotId = " + r.getSpotId() + ", reason = " + r.getReason())); + + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..4e52107 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,24 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3308/ssafytrip?useSSL=false&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true + username: testuser + password: test1234 + driver-class-name: com.mysql.cj.jdbc.Driver + + ai: + openai: + api-key: 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 + chat: + options: + model: gpt-4o + temperature: 0.7 + max-tokens: 10000 + +mybatis: + mapper-locations: classpath:/mappers/**/*.xml + type-aliases-package: com.shotmap + configuration: + map-underscore-to-camel-case: true + +server: + port: 0 From 04ee9ad55ffea2d3f1cf5d34a667da5cce53ca7b Mon Sep 17 00:00:00 2001 From: TToiTToy Date: Fri, 4 Jul 2025 20:40:39 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=EA=B0=9C=EC=84=A0=204=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shotmap/global/config/SecurityConfig.java | 2 + .../application/SpotClusteringV2Service.java | 64 +++++----- .../application/SpotClusteringV3Service.java | 110 ++++++++++++++++++ .../spot/presentation/SpotV3Controller.java | 39 +++++++ .../spot/repository/SpotRepository.java | 16 +++ .../com/shotmap/spot/vo/PostToUpdate.java | 13 +++ src/main/resources/mappers/PostMapper.xml | 2 +- src/main/resources/mappers/SpotMapper.xml | 51 ++++++++ 8 files changed, 267 insertions(+), 30 deletions(-) create mode 100644 src/main/java/com/shotmap/spot/application/SpotClusteringV3Service.java create mode 100644 src/main/java/com/shotmap/spot/presentation/SpotV3Controller.java create mode 100644 src/main/java/com/shotmap/spot/vo/PostToUpdate.java diff --git a/src/main/java/com/shotmap/global/config/SecurityConfig.java b/src/main/java/com/shotmap/global/config/SecurityConfig.java index 4d0d92d..5e38876 100644 --- a/src/main/java/com/shotmap/global/config/SecurityConfig.java +++ b/src/main/java/com/shotmap/global/config/SecurityConfig.java @@ -41,6 +41,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/v1/users/signup").permitAll() .requestMatchers("/api/v1/attractions/**").permitAll() .requestMatchers("/api/v1/spots/**").permitAll() + .requestMatchers("/api/v2/spots/**").permitAll() + .requestMatchers("/api/v3/spots/**").permitAll() .requestMatchers("/api/v1/posts/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-resources/**", "/webjars/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // CORS Preflight 허용 diff --git a/src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java b/src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java index 27f027a..c45412e 100644 --- a/src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java +++ b/src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java @@ -6,6 +6,7 @@ import com.shotmap.spot.repository.SpotRepository; import com.shotmap.spot.util.HaversineDistance; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import smile.clustering.DBSCAN; @@ -13,6 +14,7 @@ import java.util.*; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class SpotClusteringV2Service { @@ -28,43 +30,48 @@ public long generateSpotsWithLimit(int limit) { List posts = postRepository.findPostWithLimit(limit); Map> clusters = performClustering(posts); - for (List cluster : clusters.values()) { - Long spotIdToUse = determineSpotIdToUse(cluster); // 👈 과반수 spotId 또는 null + Set validSpotIds = new HashSet<>(); - Spot spot; - if (spotIdToUse != null) { - spot = spotRepository.findById(spotIdToUse); // 유지 - } else { - spot = createSpot(cluster); // 새로 생성 + for (List cluster : clusters.values()) { + Map countMap = new HashMap<>(); + for (Post post : cluster) { + if (post.getSpotId() != null) { + countMap.merge(post.getSpotId(), 1, Integer::sum); + } } - assignSpotToPost(cluster, spot.getId()); - } - long end = System.currentTimeMillis(); - return end - start; + List> sorted = countMap.entrySet().stream() + .sorted((a, b) -> b.getValue() - a.getValue()).toList(); - } + Long SpotIdToUse = sorted.isEmpty() ? null : sorted.get(0).getKey(); - private Long determineSpotIdToUse(List cluster) { - Map countMap = new HashMap<>(); - for (Post post : cluster) { - if (post.getSpotId() != null) { - countMap.merge(post.getSpotId(), 1, Integer::sum); + + if (SpotIdToUse == null) { + Spot newSpot = createSpot(cluster); + SpotIdToUse = newSpot.getId(); } + validSpotIds.add(SpotIdToUse); + assignSpotToPost(cluster, SpotIdToUse); + } + + List allSpotIdsInDB = spotRepository.findAllSpotIds(); + List orphanSpotIds = allSpotIdsInDB.stream() + .filter(id -> !validSpotIds.contains(id)) + .toList(); + + if (!orphanSpotIds.isEmpty()) { + spotRepository.deleteByIds(orphanSpotIds); } - // 과반수 spotId가 있다면 반환 - return countMap.entrySet().stream() - .max(Map.Entry.comparingByValue()) - .filter(e -> e.getValue() > cluster.size() / 2) - .map(Map.Entry::getKey) - .orElse(null); + long end = System.currentTimeMillis(); + return end - start; + } @Transactional public void generateSpots() { - deleteAllSpots(); + List posts = getAllPost(); Map> clusters = performClustering(posts); for (List cluster : clusters.values()) { @@ -73,10 +80,6 @@ public void generateSpots() { } } - public void deleteAllSpots() { - spotRepository.deleteAll(); - } - public List getAllPost() { return postRepository.findAllPost(); } @@ -110,7 +113,10 @@ public Spot createSpot(List cluster) { public void assignSpotToPost(List cluster, Long spotId) { for (Post post : cluster) { - post.setSpotId(spotId); + if (post.getSpotId() == null || !post.getSpotId().equals(spotId)) { + post.setSpotId(spotId); + } + } postRepository.batchUpdateSpotId(cluster); } diff --git a/src/main/java/com/shotmap/spot/application/SpotClusteringV3Service.java b/src/main/java/com/shotmap/spot/application/SpotClusteringV3Service.java new file mode 100644 index 0000000..e46da4e --- /dev/null +++ b/src/main/java/com/shotmap/spot/application/SpotClusteringV3Service.java @@ -0,0 +1,110 @@ +package com.shotmap.spot.application; + +import com.shotmap.post.domain.Post; +import com.shotmap.post.repository.PostRepository; +import com.shotmap.spot.domain.Spot; +import com.shotmap.spot.repository.SpotRepository; +import com.shotmap.spot.util.HaversineDistance; +import com.shotmap.spot.vo.PostToUpdate; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import smile.clustering.DBSCAN; + +import java.util.*; + +@Service +@RequiredArgsConstructor +public class SpotClusteringV3Service { + private final SpotRepository spotRepository; + private final PostRepository postRepository; + + private static final double EPSILON = 0.1; + private static final int MIN_POINT = 3; + + @Transactional + public long generateSpotsWithLimit(int limit) { + long start = System.currentTimeMillis(); + + spotRepository.dropTempPostSpotTable(); + spotRepository.createTempPostSpotTable(); + + deleteAllSpots(); + List posts = postRepository.findPostWithLimit(limit); + Map> clusters = new LinkedHashMap<>(performClustering(posts)); + List updates = new ArrayList<>(); + List spotList = new ArrayList<>(); + + for (List cluster : clusters.values()) { + spotList.add(createSpot(cluster)); + } + spotRepository.insertSpotsBulk(spotList); + + Iterator> cIt = clusters.values().iterator(); + Iterator sIt = spotList.iterator(); + while (cIt.hasNext() && sIt.hasNext()) { + long spotId = sIt.next().getId(); + for (Post p : cIt.next()) { + updates.add(new PostToUpdate(p.getId(), spotId)); + } + } + spotRepository.insertTempPostSpot(updates); + spotRepository.updatePostSpotIdFromTemp(); + + long end = System.currentTimeMillis(); + return end - start; + + } + + @Transactional + public void generateSpots() { + deleteAllSpots(); + List posts = getAllPost(); + Map> clusters = performClustering(posts); + for (List cluster : clusters.values()) { + Spot spot = createSpot(cluster); + assignSpotToPost(cluster, spot.getId()); + } + } + + public void deleteAllSpots() { + spotRepository.deleteAll(); + } + + public List getAllPost() { + return postRepository.findAllPost(); + } + + public Map> performClustering(List posts) { + + double[][] coords = posts.stream().map(p -> new double[]{p.getLatitude(), p.getLongitude()}) + .toArray((double[][]::new)); + + DBSCAN dbscan = DBSCAN.fit(coords, new HaversineDistance(), MIN_POINT, EPSILON); + int[] labels = dbscan.y; + + Map> clusterMap = new HashMap<>(); + for (int i = 0; i < labels.length; i++) { + if (labels[i] == Integer.MAX_VALUE) { + continue; + } + clusterMap.computeIfAbsent(labels[i], k -> new ArrayList<>()).add(posts.get(i)); + } + + return clusterMap; + } + + public Spot createSpot(List cluster) { + double avgLatitude = cluster.stream().mapToDouble(Post::getLatitude).average().orElse(0); + double avgLongitude = cluster.stream().mapToDouble(Post::getLongitude).average().orElse(0); + Spot spot = Spot.builder().latitude(avgLatitude).longitude(avgLongitude).build(); + return spot; + } + + public void assignSpotToPost(List cluster, Long spotId) { + for (Post post : cluster) { + post.setSpotId(spotId); + } + postRepository.batchUpdateSpotId(cluster); + } +} diff --git a/src/main/java/com/shotmap/spot/presentation/SpotV3Controller.java b/src/main/java/com/shotmap/spot/presentation/SpotV3Controller.java new file mode 100644 index 0000000..78c4266 --- /dev/null +++ b/src/main/java/com/shotmap/spot/presentation/SpotV3Controller.java @@ -0,0 +1,39 @@ +package com.shotmap.spot.presentation; + +import com.shotmap.global.response.ApiResponse; +import com.shotmap.spot.application.SpotClusteringV2Service; +import com.shotmap.spot.application.SpotClusteringV3Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v3/spots") +public class SpotV3Controller { + + private final SpotClusteringV3Service clusteringService; + + @GetMapping("/clustering") + public ApiResponse spotClustering() { + List limits = List.of(30000, 33000, 36000, 39000, 42000, 45000, 48000, 50000); + List times = new ArrayList<>(); + for (int i = 0; i < limits.size(); i++) { + int limit = limits.get(i); + log.info("[Clustering Test] Round {} | limit = {}", (i + 1), limit); + times.add(clusteringService.generateSpotsWithLimit(limit)); + } + log.info("======== Clustering Summary ========"); + for (int i = 0; i < times.size(); i++) { + log.info("Limit: {} | Time: {}ms", limits.get(i), times.get(i)); + } + log.info("===================================="); + return new ApiResponse<>(null); + } +} diff --git a/src/main/java/com/shotmap/spot/repository/SpotRepository.java b/src/main/java/com/shotmap/spot/repository/SpotRepository.java index f827b45..f9a86bb 100644 --- a/src/main/java/com/shotmap/spot/repository/SpotRepository.java +++ b/src/main/java/com/shotmap/spot/repository/SpotRepository.java @@ -9,10 +9,12 @@ import com.shotmap.spot.response.SpotPromptResponse; import com.shotmap.spot.response.SpotListResponse; +import com.shotmap.spot.vo.PostToUpdate; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; +import java.util.Set; @Mapper public interface SpotRepository { @@ -29,4 +31,18 @@ public interface SpotRepository { long countNearbySpots(SpotSearchNearbyRequest request); + void deleteByIds(@Param("ids") List ids); + + List findAllSpotIds(); + + //클러스터링 v3용 추가 코드 + void dropTempPostSpotTable(); + + void createTempPostSpotTable(); + + void insertTempPostSpot(@Param("posts") List posts); + + void updatePostSpotIdFromTemp(); + + void insertSpotsBulk(List spots); } diff --git a/src/main/java/com/shotmap/spot/vo/PostToUpdate.java b/src/main/java/com/shotmap/spot/vo/PostToUpdate.java new file mode 100644 index 0000000..764318d --- /dev/null +++ b/src/main/java/com/shotmap/spot/vo/PostToUpdate.java @@ -0,0 +1,13 @@ +package com.shotmap.spot.vo; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class PostToUpdate { + private Long postId; + private Long spotId; +} diff --git a/src/main/resources/mappers/PostMapper.xml b/src/main/resources/mappers/PostMapper.xml index 6eb5adb..167fc56 100644 --- a/src/main/resources/mappers/PostMapper.xml +++ b/src/main/resources/mappers/PostMapper.xml @@ -273,7 +273,7 @@ AND user_no = #{userId} - SELECT * FROM post WHERE latitude IS NOT NULL diff --git a/src/main/resources/mappers/SpotMapper.xml b/src/main/resources/mappers/SpotMapper.xml index ef10b20..6094d29 100644 --- a/src/main/resources/mappers/SpotMapper.xml +++ b/src/main/resources/mappers/SpotMapper.xml @@ -131,4 +131,55 @@ FROM spot WHERE no = #{id} + + + DELETE FROM spot + WHERE no IN + + #{id} + + + + + + + DROP TEMPORARY TABLE IF EXISTS temp_post_spot + + + + CREATE TEMPORARY TABLE IF NOT EXISTS temp_post_spot ( + no BIGINT NOT NULL PRIMARY KEY, + spot_no BIGINT NOT NULL + ) + + + + INSERT INTO temp_post_spot (no, spot_no) + VALUES + + (#{p.postId}, #{p.spotId}) + + + + + UPDATE post p + JOIN temp_post_spot t ON p.no = t.no + SET p.spot_no = t.spot_no + + + + INSERT INTO spot (latitude, longitude) + VALUES + + (#{spot.latitude}, #{spot.longitude}) + + + + \ No newline at end of file From e70d894578be8fe0047870a31da24f003f90b80c Mon Sep 17 00:00:00 2001 From: TToiTToy Date: Sun, 20 Jul 2025 19:54:38 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=ED=8F=AC=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8C=9F=20id=20=EC=9C=A0=EC=A7=80=20=ED=81=B4=EB=9F=AC?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spot/presentation/SpotV2Controller.java | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/shotmap/spot/presentation/SpotV2Controller.java b/src/main/java/com/shotmap/spot/presentation/SpotV2Controller.java index 92242cf..4dba38e 100644 --- a/src/main/java/com/shotmap/spot/presentation/SpotV2Controller.java +++ b/src/main/java/com/shotmap/spot/presentation/SpotV2Controller.java @@ -20,20 +20,20 @@ public class SpotV2Controller { private final SpotClusteringV2Service clusteringService; - @GetMapping("/clustering") - public ApiResponse spotClustering() { - List limits = List.of(30000, 33000, 36000, 39000, 42000, 45000, 48000, 50000); - List times = new ArrayList<>(); - for (int i = 0; i < limits.size(); i++) { - int limit = limits.get(i); - log.info("[Clustering Test] Round {} | limit = {}", (i + 1), limit); - times.add(clusteringService.generateSpotsWithLimit(limit)); - } - log.info("======== Clustering Summary ========"); - for (int i = 0; i < times.size(); i++) { - log.info("Limit: {} | Time: {}ms", limits.get(i), times.get(i)); - } - log.info("===================================="); - return new ApiResponse<>(null); - } +// @GetMapping("/clustering") +// public ApiResponse spotClustering() { +// List limits = List.of(30000, 33000, 36000, 39000, 42000, 45000, 48000, 50000); +// List times = new ArrayList<>(); +// for (int i = 0; i < limits.size(); i++) { +// int limit = limits.get(i); +// log.info("[Clustering Test] Round {} | limit = {}", (i + 1), limit); +// times.add(clusteringService.generateSpotsWithLimit(limit)); +// } +// log.info("======== Clustering Summary ========"); +// for (int i = 0; i < times.size(); i++) { +// log.info("Limit: {} | Time: {}ms", limits.get(i), times.get(i)); +// } +// log.info("===================================="); +// return new ApiResponse<>(null); +// } } From 6256ed1a6afb1ea854b86bd5433eb4356ff0eea1 Mon Sep 17 00:00:00 2001 From: TToiTToy Date: Sun, 20 Jul 2025 20:34:12 +0900 Subject: [PATCH 4/7] =?UTF-8?q?remove:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=AA=BD=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/shotmap/AttractionDetailTest.java | 28 ---- .../com/shotmap/ShotmapApplicationTests.java | 15 -- .../java/com/shotmap/SpotClusteringTest.java | 131 ------------------ src/test/java/com/shotmap/SpotDetailTest.java | 31 ----- src/test/java/com/shotmap/SpotPromptTest.java | 91 ------------ src/test/resources/application-test.yml | 24 ---- 6 files changed, 320 deletions(-) delete mode 100644 src/test/java/com/shotmap/AttractionDetailTest.java delete mode 100644 src/test/java/com/shotmap/ShotmapApplicationTests.java delete mode 100644 src/test/java/com/shotmap/SpotClusteringTest.java delete mode 100644 src/test/java/com/shotmap/SpotDetailTest.java delete mode 100644 src/test/java/com/shotmap/SpotPromptTest.java delete mode 100644 src/test/resources/application-test.yml diff --git a/src/test/java/com/shotmap/AttractionDetailTest.java b/src/test/java/com/shotmap/AttractionDetailTest.java deleted file mode 100644 index 98c9315..0000000 --- a/src/test/java/com/shotmap/AttractionDetailTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.shotmap; - -import com.shotmap.attraction.response.AttractionDetailDto; -import com.shotmap.attraction.service.AttractionService; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.Assert; - -import static org.assertj.core.api.Assertions.assertThat; - -@SpringBootTest -@Transactional -@ActiveProfiles("test") -public class AttractionDetailTest { - @Autowired - private AttractionService attractionService; - - @Test - void test_AttractionDetail() { - AttractionDetailDto attraction = attractionService.findAttractionById(56653L); - assertThat(attraction).isNotNull(); - assertThat(attraction.getAttraction()).isNotNull(); - assertThat(attraction.getAttraction().getTitle()).isEqualTo("강서역사문화거리"); - } -} diff --git a/src/test/java/com/shotmap/ShotmapApplicationTests.java b/src/test/java/com/shotmap/ShotmapApplicationTests.java deleted file mode 100644 index 6f96ac0..0000000 --- a/src/test/java/com/shotmap/ShotmapApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.shotmap; - -import org.junit.jupiter.api.Test; -import org.mybatis.spring.annotation.MapperScan; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -@MapperScan("com.shotmap") -class ShotmapApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/com/shotmap/SpotClusteringTest.java b/src/test/java/com/shotmap/SpotClusteringTest.java deleted file mode 100644 index a3b2933..0000000 --- a/src/test/java/com/shotmap/SpotClusteringTest.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.shotmap; - -import com.shotmap.post.domain.Post; -import com.shotmap.post.domain.TimeBlock; -import com.shotmap.post.repository.PostRepository; -import com.shotmap.spot.domain.Spot; -import com.shotmap.spot.repository.SpotRepository; -import com.shotmap.spot.service.SpotClusteringService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.annotation.Commit; -import org.springframework.test.annotation.Rollback; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - - -@SpringBootTest -@Transactional -@ActiveProfiles("test") -public class SpotClusteringTest { - - - @Autowired - private SpotRepository spotRepository; - - @Autowired - private PostRepository postRepository; - - @Autowired - private SpotClusteringService spotClusteringService; - - @Test - @Rollback(false) - void testClusterUpdate() { - spotClusteringService.generateSpots(); - } - -// // 중간 위치에 새 게시물 추가 (1번과 2번을 잇는 중간 지점) -// postRepository.insert(createMockPost("C", 37.15, 127.15)); -// -// // 2차 클러스터링 → Spot 1개로 합쳐지며 총 1 + 외톨이 2 → 3개 예상 -// spotClusteringService.generateSpots(); -// -// -// // 외딴 게시물 추가 (새로운 Spot 유도) -// postRepository.insert(createMockPost("D", 38.0, 128.0)); -// -// // 3차 클러스터링 → Spot 총 4개 -// spotClusteringService.generateSpots(); - - -} - -// @Test -// void test_1_insertSpotAndDeleteAll() { -// // given -// Spot spot = Spot.builder() -// .latitude(37.1234) -// .longitude(127.4567) -// .build(); -// -// // when -// spotRepository.insert(spot); -// assertThat(spot.getId()).isNotNull(); -// -// spotRepository.deleteAll(); -// -// // then: 이후 select 시 비어 있어야 -// // (추가로 findAll을 Spot에도 구현했다면 여기서 확인 가능) -// } -// -// @Test -// void test_2_insertPostAndFindAll() { -// // given -// Post post1 = createMockPost("A", 37.11, 127.11); -// Post post2 = createMockPost("B", 37.12, 127.12); -// -// postRepository.insert(post1); -// postRepository.insert(post2); -// -// // when -// List posts = postRepository.findAllPost(); -// -// // then -// assertThat(posts.size()).isGreaterThanOrEqualTo(2); -// assertThat(posts).extracting(Post::getTitle).contains("A", "B"); -// } -// -// @Test -// void test_3_batchUpdateSpotId() { -// // given -// Post post1 = createMockPost("A", 37.11, 127.11); -// Post post2 = createMockPost("B", 37.12, 127.12); -// postRepository.insert(post1); -// postRepository.insert(post2); -// -// Spot spot = Spot.builder().latitude(37.1).longitude(127.1).build(); -// spotRepository.insert(spot); -// -// Long spotId = spot.getId(); -// post1.setSpotId(spotId); -// post2.setSpotId(spotId); -// -// // when -// postRepository.batchUpdateSpotId(List.of(post1, post2)); -// -// // then -// List posts = postRepository.findAllPost(); -// assertThat(posts).allMatch(p -> spotId.equals(p.getSpotId())); -// } -// -// private Post createMockPost(String title, double lat, double lon) { -// return Post.builder() -// .title(title) -// .latitude(lat) -// .longitude(lon) -// .tip("테스트 팁") -// .recommendTime(TimeBlock.MORNING) -// .viewCount(0L) -// .userId(1L) -// .spotId(null) -// .build(); -// } - diff --git a/src/test/java/com/shotmap/SpotDetailTest.java b/src/test/java/com/shotmap/SpotDetailTest.java deleted file mode 100644 index da21497..0000000 --- a/src/test/java/com/shotmap/SpotDetailTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.shotmap; - -import com.shotmap.attraction.response.AttractionDetailDto; -import com.shotmap.spot.response.SpotDetailDto; -import com.shotmap.spot.service.SpotService; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -import static org.assertj.core.api.Assertions.assertThat; - -@SpringBootTest -@Transactional -@ActiveProfiles("test") -public class SpotDetailTest { - - @Autowired - private SpotService spotService; - - @Test - void test_spotDetail() { - SpotDetailDto spot = spotService.findSpotById(40L); - assertThat(spot).isNotNull(); - assertThat(spot.getSpotNo()).isEqualTo(40); - assertThat(spot.getPostImageUrls()).contains("aaaaaa.ssafy.com"); - - } - -} diff --git a/src/test/java/com/shotmap/SpotPromptTest.java b/src/test/java/com/shotmap/SpotPromptTest.java deleted file mode 100644 index 1a2a534..0000000 --- a/src/test/java/com/shotmap/SpotPromptTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.shotmap; - -import com.shotmap.attraction.vo.Location; -import com.shotmap.spot.repository.SpotRepository; -import com.shotmap.spot.request.SpotRecommendationRequest; -import com.shotmap.spot.response.SpotPostPromptDto; -import com.shotmap.spot.response.SpotPromptDto; -import com.shotmap.spot.response.SpotRecommendationDto; -import com.shotmap.spot.service.SpotRecommendationService; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -@SpringBootTest -@Transactional -@ActiveProfiles("test") -public class SpotPromptTest { - - @Autowired - private SpotRepository spotRepository; - - @Autowired - private SpotRecommendationService spotRecommendationService; - - @Test - void Test_SpotPrompt() { - SpotRecommendationRequest request = SpotRecommendationRequest.builder() - .mood("감성적인") - .swLocation(Location.builder() - .latitude(new BigDecimal("37.558")) - .longitude(new BigDecimal("126.925")) - .build()) - .neLocation(Location.builder() - .latitude(new BigDecimal("37.568")) - .longitude(new BigDecimal("126.935")) - .build()) - .centerLocation(Location.builder() - .latitude(new BigDecimal("37.558")) - .longitude(new BigDecimal("126.925")) - .build()) - .build(); - // when - List result = spotRepository.findPromptDataByBoundsAndMood(request); - - // then - assertThat(result).isNotEmpty(); // ✅ 스팟이 하나 이상이어야 함 - - SpotPromptDto firstSpot = result.get(0); - assertThat(firstSpot.getSpotId()).isNotNull(); // ✅ spotId 존재 - assertThat(firstSpot.getPosts()).isNotEmpty(); // ✅ 최소 1개의 게시글 포함 - - SpotPostPromptDto firstPost = firstSpot.getPosts().get(0); - assertThat(firstPost.getTitle()).isNotBlank(); // ✅ 게시글 제목 존재 - assertThat(firstPost.getTip()).isNotBlank(); // ✅ 팁 존재 - } - - @Test - void Test_SpotRecommendationService() { - SpotRecommendationRequest request = SpotRecommendationRequest.builder() - .mood("감성적인") - .swLocation(Location.builder() - .latitude(new BigDecimal("37.558")) - .longitude(new BigDecimal("126.925")) - .build()) - .neLocation(Location.builder() - .latitude(new BigDecimal("37.568")) - .longitude(new BigDecimal("126.935")) - .build()) - .centerLocation(Location.builder() - .latitude(new BigDecimal("37.558")) - .longitude(new BigDecimal("126.925")) - .build()) - .build(); - List result = spotRecommendationService.recommend(request); - - assertThat(result).isNotEmpty(); // ✅ 추천 결과 있어야 함 - assertThat(result.get(0).getSpotId()).isNotNull(); // ✅ spotId 포함 - assertThat(result.get(0).getReason()).isNotBlank(); // ✅ 추천 이유 포함 - - System.out.println("추천 결과:"); - result.forEach(r -> System.out.println("spotId = " + r.getSpotId() + ", reason = " + r.getReason())); - - } -} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml deleted file mode 100644 index 4e52107..0000000 --- a/src/test/resources/application-test.yml +++ /dev/null @@ -1,24 +0,0 @@ -spring: - datasource: - url: jdbc:mysql://localhost:3308/ssafytrip?useSSL=false&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true - username: testuser - password: test1234 - driver-class-name: com.mysql.cj.jdbc.Driver - - ai: - openai: - api-key: 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 - chat: - options: - model: gpt-4o - temperature: 0.7 - max-tokens: 10000 - -mybatis: - mapper-locations: classpath:/mappers/**/*.xml - type-aliases-package: com.shotmap - configuration: - map-underscore-to-camel-case: true - -server: - port: 0 From 7eab2d59c92d7675fa724e4d639245509ecffa8f Mon Sep 17 00:00:00 2001 From: TToiTToy Date: Sun, 20 Jul 2025 20:37:57 +0900 Subject: [PATCH 5/7] =?UTF-8?q?remove:=20=ED=81=B4=EB=9F=AC=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20con?= =?UTF-8?q?troller=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 127.0.0.1 | 0 .../spot/presentation/SpotV2Controller.java | 39 ------------------- .../spot/presentation/SpotV3Controller.java | 39 ------------------- 3 files changed, 78 deletions(-) delete mode 100644 127.0.0.1 delete mode 100644 src/main/java/com/shotmap/spot/presentation/SpotV2Controller.java delete mode 100644 src/main/java/com/shotmap/spot/presentation/SpotV3Controller.java diff --git a/127.0.0.1 b/127.0.0.1 deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/shotmap/spot/presentation/SpotV2Controller.java b/src/main/java/com/shotmap/spot/presentation/SpotV2Controller.java deleted file mode 100644 index 4dba38e..0000000 --- a/src/main/java/com/shotmap/spot/presentation/SpotV2Controller.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.shotmap.spot.presentation; - -import com.shotmap.global.response.ApiResponse; -import com.shotmap.spot.application.SpotClusteringService; -import com.shotmap.spot.application.SpotClusteringV2Service; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.ArrayList; -import java.util.List; - -@Slf4j -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v2/spots") -public class SpotV2Controller { - - private final SpotClusteringV2Service clusteringService; - -// @GetMapping("/clustering") -// public ApiResponse spotClustering() { -// List limits = List.of(30000, 33000, 36000, 39000, 42000, 45000, 48000, 50000); -// List times = new ArrayList<>(); -// for (int i = 0; i < limits.size(); i++) { -// int limit = limits.get(i); -// log.info("[Clustering Test] Round {} | limit = {}", (i + 1), limit); -// times.add(clusteringService.generateSpotsWithLimit(limit)); -// } -// log.info("======== Clustering Summary ========"); -// for (int i = 0; i < times.size(); i++) { -// log.info("Limit: {} | Time: {}ms", limits.get(i), times.get(i)); -// } -// log.info("===================================="); -// return new ApiResponse<>(null); -// } -} diff --git a/src/main/java/com/shotmap/spot/presentation/SpotV3Controller.java b/src/main/java/com/shotmap/spot/presentation/SpotV3Controller.java deleted file mode 100644 index 78c4266..0000000 --- a/src/main/java/com/shotmap/spot/presentation/SpotV3Controller.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.shotmap.spot.presentation; - -import com.shotmap.global.response.ApiResponse; -import com.shotmap.spot.application.SpotClusteringV2Service; -import com.shotmap.spot.application.SpotClusteringV3Service; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.ArrayList; -import java.util.List; - -@Slf4j -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v3/spots") -public class SpotV3Controller { - - private final SpotClusteringV3Service clusteringService; - - @GetMapping("/clustering") - public ApiResponse spotClustering() { - List limits = List.of(30000, 33000, 36000, 39000, 42000, 45000, 48000, 50000); - List times = new ArrayList<>(); - for (int i = 0; i < limits.size(); i++) { - int limit = limits.get(i); - log.info("[Clustering Test] Round {} | limit = {}", (i + 1), limit); - times.add(clusteringService.generateSpotsWithLimit(limit)); - } - log.info("======== Clustering Summary ========"); - for (int i = 0; i < times.size(); i++) { - log.info("Limit: {} | Time: {}ms", limits.get(i), times.get(i)); - } - log.info("===================================="); - return new ApiResponse<>(null); - } -} From 64fc3df07cf9c7752a4aeb75042d4e60a9f22c3a Mon Sep 17 00:00:00 2001 From: TToiTToy Date: Sun, 20 Jul 2025 20:40:48 +0900 Subject: [PATCH 6/7] =?UTF-8?q?store:=20=ED=81=B4=EB=9F=AC=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20v2,v3=20=EC=84=B8=ED=81=90=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20=EC=BD=98=ED=94=BC=EA=B7=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/shotmap/global/config/SecurityConfig.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/shotmap/global/config/SecurityConfig.java b/src/main/java/com/shotmap/global/config/SecurityConfig.java index 5e38876..4d0d92d 100644 --- a/src/main/java/com/shotmap/global/config/SecurityConfig.java +++ b/src/main/java/com/shotmap/global/config/SecurityConfig.java @@ -41,8 +41,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/v1/users/signup").permitAll() .requestMatchers("/api/v1/attractions/**").permitAll() .requestMatchers("/api/v1/spots/**").permitAll() - .requestMatchers("/api/v2/spots/**").permitAll() - .requestMatchers("/api/v3/spots/**").permitAll() .requestMatchers("/api/v1/posts/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-resources/**", "/webjars/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // CORS Preflight 허용 From 4a7e3e7ddd7c5497ad377117815fae624ac986a8 Mon Sep 17 00:00:00 2001 From: TToiTToy Date: Sun, 20 Jul 2025 21:05:04 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=ED=81=B4=EB=9F=AC=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/repository/PostRepository.java | 3 +-- .../application/SpotClusteringService.java | 16 -------------- .../application/SpotClusteringV2Service.java | 22 +++---------------- .../application/SpotClusteringV3Service.java | 22 +++---------------- .../spot/presentation/SpotController.java | 17 ++------------ .../spot/repository/SpotRepository.java | 6 +++-- src/main/resources/mappers/PostMapper.xml | 10 +-------- src/main/resources/mappers/SpotMapper.xml | 3 --- 8 files changed, 14 insertions(+), 85 deletions(-) diff --git a/src/main/java/com/shotmap/post/repository/PostRepository.java b/src/main/java/com/shotmap/post/repository/PostRepository.java index 34e05c8..d232da0 100644 --- a/src/main/java/com/shotmap/post/repository/PostRepository.java +++ b/src/main/java/com/shotmap/post/repository/PostRepository.java @@ -58,6 +58,5 @@ public interface PostRepository { List findPostsBySpotId(@Param("spotId") Long spotId, @Param("pageable") Pageable pageable); long countPostsBySpotId(@Param("spotId") Long spotId); - - List findPostWithLimit(int limit); + } diff --git a/src/main/java/com/shotmap/spot/application/SpotClusteringService.java b/src/main/java/com/shotmap/spot/application/SpotClusteringService.java index dacd136..a39b668 100644 --- a/src/main/java/com/shotmap/spot/application/SpotClusteringService.java +++ b/src/main/java/com/shotmap/spot/application/SpotClusteringService.java @@ -23,22 +23,6 @@ public class SpotClusteringService { private static final double EPSILON = 0.1; private static final int MIN_POINT = 3; - @Transactional - public long generateSpotsWithLimit(int limit) { - long start = System.currentTimeMillis(); - - deleteAllSpots(); - List posts = postRepository.findPostWithLimit(limit); - Map> clusters = performClustering(posts); - for (List cluster : clusters.values()) { - Spot spot = createSpot(cluster); - assignSpotToPost(cluster, spot.getId()); - } - - long end = System.currentTimeMillis(); - return end - start; - - } @Transactional public void generateSpots() { diff --git a/src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java b/src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java index c45412e..46b5947 100644 --- a/src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java +++ b/src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java @@ -24,11 +24,11 @@ public class SpotClusteringV2Service { private static final double EPSILON = 0.1; private static final int MIN_POINT = 3; + @Transactional - public long generateSpotsWithLimit(int limit) { - long start = System.currentTimeMillis(); + public void generateSpots() { - List posts = postRepository.findPostWithLimit(limit); + List posts = getAllPost(); Map> clusters = performClustering(posts); Set validSpotIds = new HashSet<>(); @@ -62,22 +62,6 @@ public long generateSpotsWithLimit(int limit) { if (!orphanSpotIds.isEmpty()) { spotRepository.deleteByIds(orphanSpotIds); } - - long end = System.currentTimeMillis(); - return end - start; - - } - - - @Transactional - public void generateSpots() { - - List posts = getAllPost(); - Map> clusters = performClustering(posts); - for (List cluster : clusters.values()) { - Spot spot = createSpot(cluster); - assignSpotToPost(cluster, spot.getId()); - } } public List getAllPost() { diff --git a/src/main/java/com/shotmap/spot/application/SpotClusteringV3Service.java b/src/main/java/com/shotmap/spot/application/SpotClusteringV3Service.java index e46da4e..fc34c20 100644 --- a/src/main/java/com/shotmap/spot/application/SpotClusteringV3Service.java +++ b/src/main/java/com/shotmap/spot/application/SpotClusteringV3Service.java @@ -22,15 +22,13 @@ public class SpotClusteringV3Service { private static final double EPSILON = 0.1; private static final int MIN_POINT = 3; - @Transactional - public long generateSpotsWithLimit(int limit) { - long start = System.currentTimeMillis(); - spotRepository.dropTempPostSpotTable(); + @Transactional + public void generateSpots() { spotRepository.createTempPostSpotTable(); deleteAllSpots(); - List posts = postRepository.findPostWithLimit(limit); + List posts = getAllPost(); Map> clusters = new LinkedHashMap<>(performClustering(posts)); List updates = new ArrayList<>(); List spotList = new ArrayList<>(); @@ -51,20 +49,6 @@ public long generateSpotsWithLimit(int limit) { spotRepository.insertTempPostSpot(updates); spotRepository.updatePostSpotIdFromTemp(); - long end = System.currentTimeMillis(); - return end - start; - - } - - @Transactional - public void generateSpots() { - deleteAllSpots(); - List posts = getAllPost(); - Map> clusters = performClustering(posts); - for (List cluster : clusters.values()) { - Spot spot = createSpot(cluster); - assignSpotToPost(cluster, spot.getId()); - } } public void deleteAllSpots() { diff --git a/src/main/java/com/shotmap/spot/presentation/SpotController.java b/src/main/java/com/shotmap/spot/presentation/SpotController.java index ebfb4f6..22f0066 100644 --- a/src/main/java/com/shotmap/spot/presentation/SpotController.java +++ b/src/main/java/com/shotmap/spot/presentation/SpotController.java @@ -4,14 +4,12 @@ import com.shotmap.global.response.PagedResponse; import com.shotmap.post.response.PostListResponse; -import com.shotmap.spot.application.SpotClusteringService; +import com.shotmap.spot.application.*; import com.shotmap.spot.request.SpotSearchNearbyRequest; import com.shotmap.spot.request.SpotRecommendationRequest; import com.shotmap.spot.response.SpotDetailResponse; import com.shotmap.spot.response.SpotListResponse; import com.shotmap.spot.response.SpotRecommendationResponse; -import com.shotmap.spot.application.SpotRecommendationService; -import com.shotmap.spot.application.SpotService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; @@ -59,18 +57,7 @@ public ApiResponse> getPostsBySpotId(@PathVariab @GetMapping("/clustering") public ApiResponse spotClustering() { - List limits = List.of(30000, 33000, 36000, 39000, 42000, 45000, 48000, 50000); - List times = new ArrayList<>(); - for (int i = 0; i < limits.size(); i++) { - int limit = limits.get(i); - log.info("[Clustering Test] Round {} | limit = {}", (i + 1), limit); - times.add(clusteringService.generateSpotsWithLimit(limit)); - } - log.info("======== Clustering Summary ========"); - for (int i = 0; i < times.size(); i++) { - log.info("Limit: {} | Time: {}ms", limits.get(i), times.get(i)); - } - log.info("===================================="); + clusteringService.generateSpots(); return new ApiResponse<>(null); } } diff --git a/src/main/java/com/shotmap/spot/repository/SpotRepository.java b/src/main/java/com/shotmap/spot/repository/SpotRepository.java index f9a86bb..1810739 100644 --- a/src/main/java/com/shotmap/spot/repository/SpotRepository.java +++ b/src/main/java/com/shotmap/spot/repository/SpotRepository.java @@ -31,12 +31,14 @@ public interface SpotRepository { long countNearbySpots(SpotSearchNearbyRequest request); + + //클러스터링 v2용 + void deleteByIds(@Param("ids") List ids); List findAllSpotIds(); - //클러스터링 v3용 추가 코드 - void dropTempPostSpotTable(); + //클러스터링 v3용 void createTempPostSpotTable(); diff --git a/src/main/resources/mappers/PostMapper.xml b/src/main/resources/mappers/PostMapper.xml index 167fc56..e4efc1d 100644 --- a/src/main/resources/mappers/PostMapper.xml +++ b/src/main/resources/mappers/PostMapper.xml @@ -272,13 +272,5 @@ WHERE post_no = #{postId} AND user_no = #{userId} - - + \ No newline at end of file diff --git a/src/main/resources/mappers/SpotMapper.xml b/src/main/resources/mappers/SpotMapper.xml index 6094d29..9a33bed 100644 --- a/src/main/resources/mappers/SpotMapper.xml +++ b/src/main/resources/mappers/SpotMapper.xml @@ -144,9 +144,6 @@ SELECT no FROM spot - - DROP TEMPORARY TABLE IF EXISTS temp_post_spot - CREATE TEMPORARY TABLE IF NOT EXISTS temp_post_spot (