diff --git a/src/main/java/com/shotmap/post/repository/PostRepository.java b/src/main/java/com/shotmap/post/repository/PostRepository.java index d3ab5c7..699bb17 100644 --- a/src/main/java/com/shotmap/post/repository/PostRepository.java +++ b/src/main/java/com/shotmap/post/repository/PostRepository.java @@ -54,7 +54,7 @@ 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); @@ -62,4 +62,5 @@ public interface PostRepository { void incrementHeartCount(@Param("postId") Long postId); void decrementHeartCount(@Param("postId") Long postId); + } diff --git a/src/main/java/com/shotmap/spot/application/SpotClusteringService.java b/src/main/java/com/shotmap/spot/application/SpotClusteringService.java index e95fac4..a39b668 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,7 @@ public class SpotClusteringService { private static final double EPSILON = 0.1; private static final int MIN_POINT = 3; + @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..46b5947 --- /dev/null +++ b/src/main/java/com/shotmap/spot/application/SpotClusteringV2Service.java @@ -0,0 +1,107 @@ +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 lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import smile.clustering.DBSCAN; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@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 void generateSpots() { + + List posts = getAllPost(); + Map> clusters = performClustering(posts); + Set validSpotIds = new HashSet<>(); + + for (List cluster : clusters.values()) { + Map countMap = new HashMap<>(); + for (Post post : cluster) { + if (post.getSpotId() != null) { + countMap.merge(post.getSpotId(), 1, Integer::sum); + } + } + + List> sorted = countMap.entrySet().stream() + .sorted((a, b) -> b.getValue() - a.getValue()).toList(); + + Long SpotIdToUse = sorted.isEmpty() ? null : sorted.get(0).getKey(); + + + 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); + } + } + + 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) { + 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..fc34c20 --- /dev/null +++ b/src/main/java/com/shotmap/spot/application/SpotClusteringV3Service.java @@ -0,0 +1,94 @@ +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 void generateSpots() { + spotRepository.createTempPostSpotTable(); + + deleteAllSpots(); + List posts = getAllPost(); + 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(); + + } + + 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/SpotController.java b/src/main/java/com/shotmap/spot/presentation/SpotController.java index 7bbfdc4..50f28d7 100644 --- a/src/main/java/com/shotmap/spot/presentation/SpotController.java +++ b/src/main/java/com/shotmap/spot/presentation/SpotController.java @@ -4,22 +4,22 @@ import com.shotmap.global.response.PagedResponse; import com.shotmap.post.response.PostListResponse; +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; 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,8 +27,10 @@ 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); return new ApiResponse<>(response); @@ -48,8 +50,14 @@ 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() { + 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 1cacc8b..1fd8119 100644 --- a/src/main/java/com/shotmap/spot/repository/SpotRepository.java +++ b/src/main/java/com/shotmap/spot/repository/SpotRepository.java @@ -7,11 +7,13 @@ import com.shotmap.spot.request.SpotSearchNearbyRequest; import com.shotmap.spot.response.*; +import com.shotmap.spot.vo.PostToUpdate; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.Collection; import java.util.List; +import java.util.Set; @Mapper public interface SpotRepository { @@ -31,4 +33,22 @@ public interface SpotRepository { List selectSpotImageUrlBySpotIds(@Param("list") List spotIds); List selectSpotTagBySpotIds(@Param("list") List spotIds); + + //클러스터링 v2용 + + void deleteByIds(@Param("ids") List ids); + + List findAllSpotIds(); + + //클러스터링 v3용 + + 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 cc88b31..ad4f98c 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,7 +270,7 @@ UPDATE heart SET is_deleted = TRUE WHERE post_no = #{postId} - AND user_no = #{userId} + AND user_no = #{userId} @@ -286,4 +286,5 @@ SET heart_count = heart_count - 1 WHERE no = #{postId} + \ No newline at end of file diff --git a/src/main/resources/mappers/SpotMapper.xml b/src/main/resources/mappers/SpotMapper.xml index 0e0ccd3..b991596 100644 --- a/src/main/resources/mappers/SpotMapper.xml +++ b/src/main/resources/mappers/SpotMapper.xml @@ -162,4 +162,58 @@ ) sub WHERE rn <= 3 + + + + + DELETE FROM spot + WHERE no IN + + #{id} + + + + + + + + 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 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() { - } - -}