diff --git a/src/main/java/com/membershipflow/course/controller/CourseController.java b/src/main/java/com/membershipflow/course/controller/CourseController.java index d798deb..d4ec6ee 100644 --- a/src/main/java/com/membershipflow/course/controller/CourseController.java +++ b/src/main/java/com/membershipflow/course/controller/CourseController.java @@ -4,6 +4,7 @@ import com.membershipflow.course.dto.CourseListItemResponse; import com.membershipflow.course.dto.RankingItemResponse; import com.membershipflow.course.dto.RankingPageResponse; +import com.membershipflow.course.dto.SourceComparisonItem; import com.membershipflow.course.entity.CourseType; import com.membershipflow.course.entity.MembershipType; import com.membershipflow.course.service.CourseService; @@ -51,6 +52,14 @@ public ResponseEntity ranking( return ResponseEntity.ok(courseService.getRanking(period, sort, courseType, page, clampedSize)); } + // /courses/source-comparison 이 /courses/{courseId} 보다 먼저 위치해야 경로 충돌 없음 + @GetMapping("/source-comparison") + public ResponseEntity> sourceComparison( + @RequestParam(defaultValue = "10") int limit) { + int clampedLimit = Math.min(limit, 50); + return ResponseEntity.ok(courseService.getSourceComparison(clampedLimit)); + } + @GetMapping("/{courseId}") public ResponseEntity detail(@PathVariable Long courseId) { return ResponseEntity.ok(courseService.getDetail(courseId)); diff --git a/src/main/java/com/membershipflow/course/dto/SourceComparisonItem.java b/src/main/java/com/membershipflow/course/dto/SourceComparisonItem.java new file mode 100644 index 0000000..3421c97 --- /dev/null +++ b/src/main/java/com/membershipflow/course/dto/SourceComparisonItem.java @@ -0,0 +1,12 @@ +package com.membershipflow.course.dto; + +public record SourceComparisonItem( + Long courseId, + String name, + String region, + String courseType, + Long dongaPrice, + Long dongbuPrice, + Long diffAmount, // dongbu - donga (원 단위) + Double diffRate // 차이 비율 (%) +) {} diff --git a/src/main/java/com/membershipflow/course/service/CourseService.java b/src/main/java/com/membershipflow/course/service/CourseService.java index 0d73698..df7b272 100644 --- a/src/main/java/com/membershipflow/course/service/CourseService.java +++ b/src/main/java/com/membershipflow/course/service/CourseService.java @@ -6,6 +6,7 @@ import com.membershipflow.course.dto.CourseListItemResponse; import com.membershipflow.course.dto.RankingItemResponse; import com.membershipflow.course.dto.RankingPageResponse; +import com.membershipflow.course.dto.SourceComparisonItem; import com.membershipflow.course.entity.CourseType; import com.membershipflow.course.entity.MembershipCourse; import com.membershipflow.course.entity.MembershipType; @@ -166,6 +167,38 @@ public RankingPageResponse getRanking(String period, String sort, return new RankingPageResponse(content, page, size, totalElements, toIndex < totalElements); } + public List getSourceComparison(int limit) { + List rows = priceService.getLatestByTwoSources("동아골프", "동부회원권"); + + List courseIds = rows.stream() + .map(r -> ((Number) r[0]).longValue()) + .toList(); + + Map courseMap = courseRepository.findAllById(courseIds).stream() + .collect(Collectors.toMap(MembershipCourse::getId, c -> c)); + + return rows.stream() + .map(r -> { + long courseId = ((Number) r[0]).longValue(); + long dongaPrice = ((Number) r[1]).longValue(); + long dongbuPrice = ((Number) r[2]).longValue(); + long diffAmount = dongbuPrice - dongaPrice; + double diffRate = dongaPrice == 0 ? 0 + : Math.round((double) diffAmount / dongaPrice * 10000d) / 100d; + + MembershipCourse c = courseMap.get(courseId); + if (c == null) return null; + return new SourceComparisonItem( + courseId, c.getName(), c.getRegion(), + c.getCourseType() != null ? c.getCourseType().name() : null, + dongaPrice, dongbuPrice, diffAmount, diffRate); + }) + .filter(item -> item != null) + .sorted((a, b) -> Double.compare(Math.abs(b.diffRate()), Math.abs(a.diffRate()))) + .limit(limit) + .toList(); + } + private Double calcChangeRate(PriceHistory latest, PriceHistory base) { if (latest == null || base == null || base.getPrice() == 0) return null; double rate = (double) (latest.getPrice() - base.getPrice()) / base.getPrice() * 100; diff --git a/src/main/java/com/membershipflow/price/repository/PriceHistoryRepository.java b/src/main/java/com/membershipflow/price/repository/PriceHistoryRepository.java index fd599b6..bcc6f6d 100644 --- a/src/main/java/com/membershipflow/price/repository/PriceHistoryRepository.java +++ b/src/main/java/com/membershipflow/price/repository/PriceHistoryRepository.java @@ -125,6 +125,26 @@ WHERE course_id IN (:courseIds) """, nativeQuery = true) List findCurrentPriceForRanking(@Param("courseIds") List courseIds); + // 소스 비교용: 두 소스 모두 최신가가 있는 종목 조회 + @Query(value = """ + SELECT ph.course_id, + MAX(CASE WHEN cs.name = :sourceA THEN ph.price END) AS price_a, + MAX(CASE WHEN cs.name = :sourceB THEN ph.price END) AS price_b + FROM ( + SELECT id, course_id, source_id, price, + ROW_NUMBER() OVER (PARTITION BY course_id, source_id ORDER BY collected_at DESC, id DESC) AS rn + FROM price_history + ) ph + JOIN crawl_source cs ON cs.id = ph.source_id + WHERE ph.rn = 1 + AND cs.name IN (:sourceA, :sourceB) + GROUP BY ph.course_id + HAVING price_a IS NOT NULL AND price_b IS NOT NULL + """, nativeQuery = true) + List findLatestByTwoSources( + @Param("sourceA") String sourceA, + @Param("sourceB") String sourceB); + // 랭킹용 기준 시점 가격 (period 시작 시점 가장 근접 레코드) @Query(value = """ SELECT ph.id, ph.course_id, ph.source_id, ph.price, ph.collected_at, ph.collect_run_id diff --git a/src/main/java/com/membershipflow/price/service/PriceService.java b/src/main/java/com/membershipflow/price/service/PriceService.java index cda7866..ccb7f09 100644 --- a/src/main/java/com/membershipflow/price/service/PriceService.java +++ b/src/main/java/com/membershipflow/price/service/PriceService.java @@ -106,6 +106,10 @@ public Map getCurrentPriceBatch(List courseIds) { .collect(Collectors.toMap(ph -> ph.getCourse().getId(), ph -> ph)); } + public List getLatestByTwoSources(String sourceA, String sourceB) { + return priceHistoryRepository.findLatestByTwoSources(sourceA, sourceB); + } + public Map getBasePriceBatch(List courseIds, LocalDateTime baseTime) { long periodDays = java.time.temporal.ChronoUnit.DAYS.between(baseTime, LocalDateTime.now()); long windowDays = Math.max(7, periodDays);