Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -51,6 +52,14 @@ public ResponseEntity<RankingPageResponse> ranking(
return ResponseEntity.ok(courseService.getRanking(period, sort, courseType, page, clampedSize));
}

// /courses/source-comparison 이 /courses/{courseId} 보다 먼저 위치해야 경로 충돌 없음
@GetMapping("/source-comparison")
public ResponseEntity<List<SourceComparisonItem>> sourceComparison(
@RequestParam(defaultValue = "10") int limit) {
int clampedLimit = Math.min(limit, 50);
return ResponseEntity.ok(courseService.getSourceComparison(clampedLimit));
}

@GetMapping("/{courseId}")
public ResponseEntity<CourseDetailResponse> detail(@PathVariable Long courseId) {
return ResponseEntity.ok(courseService.getDetail(courseId));
Expand Down
Original file line number Diff line number Diff line change
@@ -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 // 차이 비율 (%)
) {}
33 changes: 33 additions & 0 deletions src/main/java/com/membershipflow/course/service/CourseService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -166,6 +167,38 @@ public RankingPageResponse getRanking(String period, String sort,
return new RankingPageResponse(content, page, size, totalElements, toIndex < totalElements);
}

public List<SourceComparisonItem> getSourceComparison(int limit) {
List<Object[]> rows = priceService.getLatestByTwoSources("동아골프", "동부회원권");

List<Long> courseIds = rows.stream()
.map(r -> ((Number) r[0]).longValue())
.toList();

Map<Long, MembershipCourse> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,26 @@ WHERE course_id IN (:courseIds)
""", nativeQuery = true)
List<PriceHistory> findCurrentPriceForRanking(@Param("courseIds") List<Long> 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<Object[]> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ public Map<Long, PriceHistory> getCurrentPriceBatch(List<Long> courseIds) {
.collect(Collectors.toMap(ph -> ph.getCourse().getId(), ph -> ph));
}

public List<Object[]> getLatestByTwoSources(String sourceA, String sourceB) {
return priceHistoryRepository.findLatestByTwoSources(sourceA, sourceB);
}

public Map<Long, PriceHistory> getBasePriceBatch(List<Long> courseIds, LocalDateTime baseTime) {
long periodDays = java.time.temporal.ChronoUnit.DAYS.between(baseTime, LocalDateTime.now());
long windowDays = Math.max(7, periodDays);
Expand Down
Loading