diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml index 01410f4..b6e7199 100644 --- a/docker-compose-dev.yaml +++ b/docker-compose-dev.yaml @@ -10,7 +10,8 @@ services: ports: - "${SERVER_PORT}:${SERVER_PORT}" env_file: - - .env + - .env: + labels: # Autoheal: unhealthy 상태 시 자동 재시작 활성화 autoheal: "true" @@ -38,7 +39,6 @@ services: NEXON_OPEN_API_KEY: ${NEXON_OPEN_API_KEY} AUCTION_HISTORY_DELAY_MS: ${AUCTION_HISTORY_DELAY_MS} AUCTION_HISTORY_CRON: "${AUCTION_HISTORY_CRON}" - AUCTION_HISTORY_MIN_PRICE_CRON: "${AUCTION_HISTORY_MIN_PRICE_CRON}" # === Docker Configuration === DOCKER_USERNAME: ${DOCKER_USERNAME} diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 6244012..e4b2c6b 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -13,7 +13,7 @@ services: image: open-api-batch-server:local container_name: spring-app-local ports: - - "${SERVER_PORT:-8080}:${SERVER_PORT:-8080}" + - "${SERVER_PORT:-8093}:${SERVER_PORT:-8093}" env_file: - .env.local # 로컬 환경 변수 파일 labels: @@ -23,7 +23,7 @@ services: SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-local} LANG: C.UTF-8 LC_ALL: C.UTF-8 - SERVER_PORT: ${SERVER_PORT:-8080} + SERVER_PORT: ${SERVER_PORT:-8093} # === Database Configuration === # Docker 네트워크 내부 연결 설정 (고정값) @@ -42,7 +42,6 @@ services: NEXON_OPEN_API_KEY: ${NEXON_OPEN_API_KEY} AUCTION_HISTORY_DELAY_MS: ${AUCTION_HISTORY_DELAY_MS:-1000} AUCTION_HISTORY_CRON: "${AUCTION_HISTORY_CRON:-0 0 * * * *}" - AUCTION_HISTORY_MIN_PRICE_CRON: "${AUCTION_HISTORY_MIN_PRICE_CRON:-0 30 * * * *}" # === JVM Configuration (로컬 개발용 - 메모리 사용량 감소) === JAVA_OPTS: >- @@ -88,7 +87,7 @@ services: # Health Check healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${SERVER_PORT:-8080}/actuator/health"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${SERVER_PORT:-8093}/actuator/health"] interval: ${HEALTHCHECK_INTERVAL:-30s} timeout: ${HEALTHCHECK_TIMEOUT:-10s} retries: ${HEALTHCHECK_RETRIES:-3} @@ -108,7 +107,7 @@ services: env_file: - .env.local # 환경 변수 파일 로드 ports: - - "${MYSQL_EXTERNAL_PORT:-3306}:3306" # 외부 접속용 포트 (호스트에서 접근 시) + - "${MYSQL_EXTERNAL_PORT:-3319}:3306" # 외부 접속용 포트 (호스트에서 접근 시) environment: MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-password} MYSQL_DATABASE: ${DB_SCHEMA:-devnogi} diff --git a/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java b/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java index 9de88c8..b94f88a 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java @@ -26,7 +26,7 @@ public class AuctionHistoryScheduler { @Value("${openapi.auction-history.delay-ms}") private long delayMs; - @Scheduled(cron = "${openapi.auction-history.cron}", zone = "Asia/Seoul") + @Scheduled(cron = "${openapi.auction-history.cron:0 0 * * * *}", zone = "Asia/Seoul") public void fetchAndSaveAuctionHistoryAll() { // ItemCategory를 topCategory별로 그룹화 Map> categoriesByTopCategory = diff --git a/src/main/java/until/the/eternity/common/request/PageRequestDto.java b/src/main/java/until/the/eternity/common/request/PageRequestDto.java index 06384ad..793c15f 100644 --- a/src/main/java/until/the/eternity/common/request/PageRequestDto.java +++ b/src/main/java/until/the/eternity/common/request/PageRequestDto.java @@ -24,7 +24,7 @@ public record PageRequestDto( private static final SortDirection DEFAULT_DIRECTION = SortDirection.DESC; public Pageable toPageable() { - int resolvedPage = this.page != null ? this.page - 1 : DEFAULT_PAGE; + int resolvedPage = this.page != null ? this.page - 1 : DEFAULT_PAGE - 1; int resolvedSize = this.size != null ? this.size : DEFAULT_SIZE; SortField resolvedSortBy = this.sortBy != null ? this.sortBy : DEFAULT_SORT_BY; SortDirection resolvedDirection = diff --git a/src/main/java/until/the/eternity/config/SecurityConfig.java b/src/main/java/until/the/eternity/config/SecurityConfig.java index db930c4..62c0af1 100644 --- a/src/main/java/until/the/eternity/config/SecurityConfig.java +++ b/src/main/java/until/the/eternity/config/SecurityConfig.java @@ -41,7 +41,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // API 엔드포인트는 공개 // TODO: API endpoint 정리 후 matcher 수정 // TODO: 권한 관련 기능 개발 완료 후 hasRole 추가 - .requestMatchers("/api/**", "/auction-history/**") + .requestMatchers( + "/api/**", "/auction-history/**", "/statistics/**") .permitAll() // 나머지 요청은 인증 필요 .anyRequest() diff --git a/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java index 2465ae1..755b5a2 100644 --- a/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java +++ b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java @@ -8,12 +8,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import until.the.eternity.common.enums.SortDirection; import until.the.eternity.common.response.ApiResponse; import until.the.eternity.iteminfo.application.service.ItemInfoService; diff --git a/src/main/java/until/the/eternity/itemminprice/controller/ItemDailyMinPriceController.java b/src/main/java/until/the/eternity/itemminprice/controller/ItemDailyMinPriceController.java deleted file mode 100644 index 6c03fb7..0000000 --- a/src/main/java/until/the/eternity/itemminprice/controller/ItemDailyMinPriceController.java +++ /dev/null @@ -1,41 +0,0 @@ -package until.the.eternity.itemminprice.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import until.the.eternity.itemminprice.domain.dto.response.ItemDailyMinPriceResponseDto; -import until.the.eternity.itemminprice.service.ItemDailyMinPriceService; - -@Slf4j -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/item-min-prices") -@Tag(name = "아이템 일간 최저가 API", description = "아이템 일간 최저가 API") -public class ItemDailyMinPriceController { - - private final ItemDailyMinPriceService itemDailyMinPriceService; - - @PostMapping("/batch") - @Operation( - summary = "최저가 배치 실행", - description = "auction_history로부터 오늘의 최저가 데이터를 계산해 item_daily_min_price 테이블에 upsert") - public ResponseEntity triggerMinPriceBatch() { - itemDailyMinPriceService.upsertTodayMinPrices(); - return ResponseEntity.ok().build(); - } - - // TODO: 페이지네이션을 해야되나 고민 중 의상 구매를 할 때는 최저가로 볼 거 같기도 한데 그런 경우에는 특수한 경우라서) - @GetMapping - @Operation(summary = "최저가 전체 조회", description = "item_daily_min_price 테이블의 모든 데이터를 반환합니다.") - public ResponseEntity> getAll() { - List dtos = itemDailyMinPriceService.findAll(); - return ResponseEntity.ok(dtos); - } -} diff --git a/src/main/java/until/the/eternity/itemminprice/domain/dto/response/ItemDailyMinPriceResponseDto.java b/src/main/java/until/the/eternity/itemminprice/domain/dto/response/ItemDailyMinPriceResponseDto.java deleted file mode 100644 index e5fcf6f..0000000 --- a/src/main/java/until/the/eternity/itemminprice/domain/dto/response/ItemDailyMinPriceResponseDto.java +++ /dev/null @@ -1,14 +0,0 @@ -package until.the.eternity.itemminprice.domain.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDate; -import java.time.LocalDateTime; - -@Schema(name = "ItemDailyMinPriceDto", description = "아이템 일간 최저가 DTO") -public record ItemDailyMinPriceResponseDto( - @Schema(description = "고유 식별자", example = "1") Long id, - @Schema(description = "아이템 이름", example = "켈틱 로열 나이트 소드") String itemName, - @Schema(description = "기록된 최저 단가", example = "120000") Long minPrice, - @Schema(description = "해당 가격이 발견된 시각 (거래 발생 시각)", example = "2025-07-01T14:35:00") - LocalDateTime dateAuctionBuy, - @Schema(description = "데이터가 저장된 일자", example = "2025-07-01") LocalDate createdAt) {} diff --git a/src/main/java/until/the/eternity/itemminprice/domain/entity/ItemDailyMinPrice.java b/src/main/java/until/the/eternity/itemminprice/domain/entity/ItemDailyMinPrice.java deleted file mode 100644 index a3dec6f..0000000 --- a/src/main/java/until/the/eternity/itemminprice/domain/entity/ItemDailyMinPrice.java +++ /dev/null @@ -1,48 +0,0 @@ -package until.the.eternity.itemminprice.domain.entity; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.persistence.*; -import java.time.LocalDate; -import java.time.LocalDateTime; -import lombok.*; - -@Entity -@Table( - name = "item_daily_min_price", - indexes = { - @Index( - name = "idx_item_daily_min_price_item_name_date_auction_buy", - columnList = "item_name, date_auction_buy") - }) -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -@Schema(description = "아이템별 일간 최저가 이력") -public class ItemDailyMinPrice { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Schema(description = "고유 식별자", example = "1") - private Long id; - - @Column(name = "item_name", nullable = false, length = 255) - @Schema(description = "아이템 이름", example = "켈틱 로열 나이트 소드") - private String itemName; - - @Column(name = "min_price", nullable = false) - @Schema(description = "기록된 최저 단가 (거래내역이 없으면 레코드 자체를 생성하지 않음)", example = "120000") - private Long minPrice; - - @Column(name = "date_auction_buy", nullable = false) - @Schema(description = "거래 일자 (해당 데이터가 저장된 일시보다 9시간 전 일자)", example = "2025-07-01") - private LocalDate dateAuctionBuy; - - @Column(name = "created_at", nullable = false) - @Schema(description = "해당 데이터가 저장된 일시", example = "2025-07-01T14:35:00") - private LocalDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - @Schema(description = "해당 데이터가 수정된 일시", example = "2025-07-01T15:35:00") - private LocalDateTime updatedAt; -} diff --git a/src/main/java/until/the/eternity/itemminprice/domain/mapper/ItemDailyMinPriceMapper.java b/src/main/java/until/the/eternity/itemminprice/domain/mapper/ItemDailyMinPriceMapper.java deleted file mode 100644 index 2f54a40..0000000 --- a/src/main/java/until/the/eternity/itemminprice/domain/mapper/ItemDailyMinPriceMapper.java +++ /dev/null @@ -1,23 +0,0 @@ -package until.the.eternity.itemminprice.domain.mapper; - -import java.util.List; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.mapstruct.MappingTarget; -import org.mapstruct.ReportingPolicy; -import until.the.eternity.itemminprice.domain.dto.response.ItemDailyMinPriceResponseDto; -import until.the.eternity.itemminprice.domain.entity.ItemDailyMinPrice; - -@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface ItemDailyMinPriceMapper { - - ItemDailyMinPriceResponseDto toDto(ItemDailyMinPrice entity); - - List toDtoList(List entities); - - @Mapping(target = "id", ignore = true) - ItemDailyMinPrice toEntity(ItemDailyMinPriceResponseDto dto); - - @Mapping(target = "id", ignore = true) - void updateEntity(ItemDailyMinPriceResponseDto dto, @MappingTarget ItemDailyMinPrice entity); -} diff --git a/src/main/java/until/the/eternity/itemminprice/repository/ItemDailyMinPriceRepository.java b/src/main/java/until/the/eternity/itemminprice/repository/ItemDailyMinPriceRepository.java deleted file mode 100644 index f82c2f5..0000000 --- a/src/main/java/until/the/eternity/itemminprice/repository/ItemDailyMinPriceRepository.java +++ /dev/null @@ -1,40 +0,0 @@ -package until.the.eternity.itemminprice.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.transaction.annotation.Transactional; -import until.the.eternity.itemminprice.domain.entity.ItemDailyMinPrice; - -public interface ItemDailyMinPriceRepository extends JpaRepository { - - /** - * 오늘(서버 타임존 기준) 거래된 각 아이템의 최저가를 item_daily_min_price 테이블에 upsert. 실시간 API가 아니라 9시 전 정보가 들어온다... - * 최저가 갱신도 9시간 전을 기준으로 한다. - */ - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Transactional - @Query( - value = - """ - INSERT INTO item_daily_min_price ( - item_name, - min_price, - date_auction_buy, - updated_at - ) - SELECT - ah.item_name, - MIN(ah.auction_price_per_unit) AS current_min_price, - DATE(DATE_SUB(NOW(), INTERVAL 9 HOUR)), - CURRENT_TIMESTAMP - FROM auction_history ah - WHERE DATE(ah.date_auction_buy) = DATE(DATE_SUB(NOW(), INTERVAL 9 HOUR)) - GROUP BY ah.item_name - ON DUPLICATE KEY UPDATE - min_price = VALUES(min_price), - updated_at = CURRENT_TIMESTAMP; - """, - nativeQuery = true) - void upsertTodayMinPrices(); -} diff --git a/src/main/java/until/the/eternity/itemminprice/service/ItemDailyMinPriceScheduler.java b/src/main/java/until/the/eternity/itemminprice/service/ItemDailyMinPriceScheduler.java deleted file mode 100644 index 2aa2879..0000000 --- a/src/main/java/until/the/eternity/itemminprice/service/ItemDailyMinPriceScheduler.java +++ /dev/null @@ -1,23 +0,0 @@ -package until.the.eternity.itemminprice.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ItemDailyMinPriceScheduler { - - private final ItemDailyMinPriceService itemDailyMinPriceService; - - @Scheduled(cron = "${openapi.min-price.cron}", zone = "Asia/Seoul") - public void scheduleMinPriceUpsert() { - long start = System.currentTimeMillis(); - itemDailyMinPriceService.upsertTodayMinPrices(); - log.info( - "[Min Price Scheduler] Upsert completed in {} ms", - System.currentTimeMillis() - start); - } -} diff --git a/src/main/java/until/the/eternity/itemminprice/service/ItemDailyMinPriceService.java b/src/main/java/until/the/eternity/itemminprice/service/ItemDailyMinPriceService.java deleted file mode 100644 index 6c5c6aa..0000000 --- a/src/main/java/until/the/eternity/itemminprice/service/ItemDailyMinPriceService.java +++ /dev/null @@ -1,32 +0,0 @@ -package until.the.eternity.itemminprice.service; - -import java.util.List; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import until.the.eternity.itemminprice.domain.dto.response.ItemDailyMinPriceResponseDto; -import until.the.eternity.itemminprice.domain.entity.ItemDailyMinPrice; -import until.the.eternity.itemminprice.domain.mapper.ItemDailyMinPriceMapper; -import until.the.eternity.itemminprice.repository.ItemDailyMinPriceRepository; - -@Slf4j -@Service -@RequiredArgsConstructor -public class ItemDailyMinPriceService { - - private final ItemDailyMinPriceRepository itemDailyMinPriceRepository; - private final ItemDailyMinPriceMapper itemDailyMinPriceMapper; - - @Transactional - public void upsertTodayMinPrices() { - itemDailyMinPriceRepository.upsertTodayMinPrices(); - } - - @Transactional(readOnly = true) - public List findAll() { - List entities = itemDailyMinPriceRepository.findAll(); - return entities.stream().map(itemDailyMinPriceMapper::toDto).collect(Collectors.toList()); - } -} diff --git a/src/main/java/until/the/eternity/statistics/application/service/ItemDailyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/ItemDailyStatisticsService.java new file mode 100644 index 0000000..923325b --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/application/service/ItemDailyStatisticsService.java @@ -0,0 +1,43 @@ +package until.the.eternity.statistics.application.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.domain.entity.daily.ItemDailyStatistics; +import until.the.eternity.statistics.domain.mapper.ItemDailyStatisticsMapper; +import until.the.eternity.statistics.interfaces.rest.dto.response.ItemDailyStatisticsResponse; +import until.the.eternity.statistics.repository.daily.ItemDailyStatisticsRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ItemDailyStatisticsService { + + private final ItemDailyStatisticsRepository repository; + private final ItemDailyStatisticsMapper mapper; + + /** 아이템별 일간 통계 전체 조회 (페이징) */ + @Transactional(readOnly = true) + public PageResponseDto findAll(Pageable pageable) { + Page page = repository.findAll(pageable); + Page dtoPage = page.map(mapper::toDto); + return PageResponseDto.of(dtoPage); + } + + /** 아이템별 일간 통계 ID로 단건 조회 */ + @Transactional(readOnly = true) + public ItemDailyStatisticsResponse findById(Long id) { + ItemDailyStatistics entity = + repository + .findById(id) + .orElseThrow( + () -> + new IllegalArgumentException( + "ItemDailyStatistics not found: " + id)); + return mapper.toDto(entity); + } +} diff --git a/src/main/java/until/the/eternity/statistics/application/service/ItemWeeklyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/ItemWeeklyStatisticsService.java new file mode 100644 index 0000000..9d3099f --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/application/service/ItemWeeklyStatisticsService.java @@ -0,0 +1,43 @@ +package until.the.eternity.statistics.application.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.domain.entity.weekly.ItemWeeklyStatistics; +import until.the.eternity.statistics.domain.mapper.ItemWeeklyStatisticsMapper; +import until.the.eternity.statistics.interfaces.rest.dto.response.ItemWeeklyStatisticsResponse; +import until.the.eternity.statistics.repository.weekly.ItemWeeklyStatisticsRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ItemWeeklyStatisticsService { + + private final ItemWeeklyStatisticsRepository repository; + private final ItemWeeklyStatisticsMapper mapper; + + /** 아이템별 주간 통계 전체 조회 (페이징) */ + @Transactional(readOnly = true) + public PageResponseDto findAll(Pageable pageable) { + Page page = repository.findAll(pageable); + Page dtoPage = page.map(mapper::toDto); + return PageResponseDto.of(dtoPage); + } + + /** 아이템별 주간 통계 ID로 단건 조회 */ + @Transactional(readOnly = true) + public ItemWeeklyStatisticsResponse findById(Long id) { + ItemWeeklyStatistics entity = + repository + .findById(id) + .orElseThrow( + () -> + new IllegalArgumentException( + "ItemWeeklyStatistics not found: " + id)); + return mapper.toDto(entity); + } +} diff --git a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java new file mode 100644 index 0000000..9d8638d --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java @@ -0,0 +1,43 @@ +package until.the.eternity.statistics.application.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.domain.entity.daily.SubcategoryDailyStatistics; +import until.the.eternity.statistics.domain.mapper.SubcategoryDailyStatisticsMapper; +import until.the.eternity.statistics.interfaces.rest.dto.response.SubcategoryDailyStatisticsResponse; +import until.the.eternity.statistics.repository.daily.SubcategoryDailyStatisticsRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SubcategoryDailyStatisticsService { + + private final SubcategoryDailyStatisticsRepository repository; + private final SubcategoryDailyStatisticsMapper mapper; + + /** 서브카테고리별 일간 통계 전체 조회 (페이징) */ + @Transactional(readOnly = true) + public PageResponseDto findAll(Pageable pageable) { + Page page = repository.findAll(pageable); + Page dtoPage = page.map(mapper::toDto); + return PageResponseDto.of(dtoPage); + } + + /** 서브카테고리별 일간 통계 ID로 단건 조회 */ + @Transactional(readOnly = true) + public SubcategoryDailyStatisticsResponse findById(Long id) { + SubcategoryDailyStatistics entity = + repository + .findById(id) + .orElseThrow( + () -> + new IllegalArgumentException( + "SubcategoryDailyStatistics not found: " + id)); + return mapper.toDto(entity); + } +} diff --git a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java new file mode 100644 index 0000000..676c1c9 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java @@ -0,0 +1,43 @@ +package until.the.eternity.statistics.application.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.domain.entity.weekly.SubcategoryWeeklyStatistics; +import until.the.eternity.statistics.domain.mapper.SubcategoryWeeklyStatisticsMapper; +import until.the.eternity.statistics.interfaces.rest.dto.response.SubcategoryWeeklyStatisticsResponse; +import until.the.eternity.statistics.repository.weekly.SubcategoryWeeklyStatisticsRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SubcategoryWeeklyStatisticsService { + + private final SubcategoryWeeklyStatisticsRepository repository; + private final SubcategoryWeeklyStatisticsMapper mapper; + + /** 서브카테고리별 주간 통계 전체 조회 (페이징) */ + @Transactional(readOnly = true) + public PageResponseDto findAll(Pageable pageable) { + Page page = repository.findAll(pageable); + Page dtoPage = page.map(mapper::toDto); + return PageResponseDto.of(dtoPage); + } + + /** 서브카테고리별 주간 통계 ID로 단건 조회 */ + @Transactional(readOnly = true) + public SubcategoryWeeklyStatisticsResponse findById(Long id) { + SubcategoryWeeklyStatistics entity = + repository + .findById(id) + .orElseThrow( + () -> + new IllegalArgumentException( + "SubcategoryWeeklyStatistics not found: " + id)); + return mapper.toDto(entity); + } +} diff --git a/src/main/java/until/the/eternity/statistics/application/service/TopCategoryDailyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/TopCategoryDailyStatisticsService.java new file mode 100644 index 0000000..f51801f --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/application/service/TopCategoryDailyStatisticsService.java @@ -0,0 +1,43 @@ +package until.the.eternity.statistics.application.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.domain.entity.daily.TopCategoryDailyStatistics; +import until.the.eternity.statistics.domain.mapper.TopCategoryDailyStatisticsMapper; +import until.the.eternity.statistics.interfaces.rest.dto.response.TopCategoryDailyStatisticsResponse; +import until.the.eternity.statistics.repository.daily.TopCategoryDailyStatisticsRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TopCategoryDailyStatisticsService { + + private final TopCategoryDailyStatisticsRepository repository; + private final TopCategoryDailyStatisticsMapper mapper; + + /** 탑카테고리별 일간 통계 전체 조회 (페이징) */ + @Transactional(readOnly = true) + public PageResponseDto findAll(Pageable pageable) { + Page page = repository.findAll(pageable); + Page dtoPage = page.map(mapper::toDto); + return PageResponseDto.of(dtoPage); + } + + /** 탑카테고리별 일간 통계 ID로 단건 조회 */ + @Transactional(readOnly = true) + public TopCategoryDailyStatisticsResponse findById(Long id) { + TopCategoryDailyStatistics entity = + repository + .findById(id) + .orElseThrow( + () -> + new IllegalArgumentException( + "TopCategoryDailyStatistics not found: " + id)); + return mapper.toDto(entity); + } +} diff --git a/src/main/java/until/the/eternity/statistics/application/service/TopCategoryWeeklyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/TopCategoryWeeklyStatisticsService.java new file mode 100644 index 0000000..a586ea5 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/application/service/TopCategoryWeeklyStatisticsService.java @@ -0,0 +1,43 @@ +package until.the.eternity.statistics.application.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.domain.entity.weekly.TopCategoryWeeklyStatistics; +import until.the.eternity.statistics.domain.mapper.TopCategoryWeeklyStatisticsMapper; +import until.the.eternity.statistics.interfaces.rest.dto.response.TopCategoryWeeklyStatisticsResponse; +import until.the.eternity.statistics.repository.weekly.TopCategoryWeeklyStatisticsRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TopCategoryWeeklyStatisticsService { + + private final TopCategoryWeeklyStatisticsRepository repository; + private final TopCategoryWeeklyStatisticsMapper mapper; + + /** 탑카테고리별 주간 통계 전체 조회 (페이징) */ + @Transactional(readOnly = true) + public PageResponseDto findAll(Pageable pageable) { + Page page = repository.findAll(pageable); + Page dtoPage = page.map(mapper::toDto); + return PageResponseDto.of(dtoPage); + } + + /** 탑카테고리별 주간 통계 ID로 단건 조회 */ + @Transactional(readOnly = true) + public TopCategoryWeeklyStatisticsResponse findById(Long id) { + TopCategoryWeeklyStatistics entity = + repository + .findById(id) + .orElseThrow( + () -> + new IllegalArgumentException( + "TopCategoryWeeklyStatistics not found: " + id)); + return mapper.toDto(entity); + } +} diff --git a/src/main/java/until/the/eternity/statistics/domain/entity/daily/ItemDailyStatistics.java b/src/main/java/until/the/eternity/statistics/domain/entity/daily/ItemDailyStatistics.java new file mode 100644 index 0000000..49f5b17 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/domain/entity/daily/ItemDailyStatistics.java @@ -0,0 +1,81 @@ +package until.the.eternity.statistics.domain.entity.daily; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.*; + +@Entity +@Table( + name = "item_daily_statistics", + indexes = { + @Index( + name = "idx_item_daily_statistics_item_name_date", + columnList = "item_name, date_auction_buy") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uk_item_daily_statistics_item_name_date", + columnNames = {"item_name", "date_auction_buy"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Schema(description = "아이템별 일간 통계") +public class ItemDailyStatistics { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "고유 식별자", example = "1") + private Long id; + + @Column(name = "item_name", nullable = false, length = 255) + @Schema(description = "아이템 이름", example = "켈틱 로열 나이트 소드") + private String itemName; + + @Column(name = "date_auction_buy", nullable = false) + @Schema(description = "거래 일자", example = "2025-07-01") + private LocalDate dateAuctionBuy; + + @Column(name = "min_price", nullable = false) + @Schema(description = "최저 단가", example = "120000") + private Long minPrice; + + @Column(name = "max_price", nullable = false) + @Schema(description = "최고 단가", example = "150000") + private Long maxPrice; + + @Column(name = "avg_price", nullable = false, precision = 15, scale = 2) + @Schema(description = "평균 단가", example = "135000.50") + private BigDecimal avgPrice; + + @Column(name = "total_volume", nullable = false) + @Schema(description = "거래 총량 (총 거래 금액)", example = "5000000") + private Long totalVolume; + + @Column(name = "total_quantity", nullable = false) + @Schema(description = "거래 수량 (itemCount 합계)", example = "150") + private Long totalQuantity; + + @Column(name = "created_at", nullable = false) + @Schema(description = "생성 일시", example = "2025-07-01T14:35:00") + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + @Schema(description = "수정 일시", example = "2025-07-01T15:35:00") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/until/the/eternity/statistics/domain/entity/daily/SubcategoryDailyStatistics.java b/src/main/java/until/the/eternity/statistics/domain/entity/daily/SubcategoryDailyStatistics.java new file mode 100644 index 0000000..66e5382 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/domain/entity/daily/SubcategoryDailyStatistics.java @@ -0,0 +1,81 @@ +package until.the.eternity.statistics.domain.entity.daily; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.*; + +@Entity +@Table( + name = "subcategory_daily_statistics", + indexes = { + @Index( + name = "idx_subcategory_daily_statistics_category_date", + columnList = "item_sub_category, date_auction_buy") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uk_subcategory_daily_statistics_category_date", + columnNames = {"item_sub_category", "date_auction_buy"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Schema(description = "서브카테고리별 일간 통계") +public class SubcategoryDailyStatistics { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "고유 식별자", example = "1") + private Long id; + + @Column(name = "item_sub_category", nullable = false, length = 255) + @Schema(description = "아이템 서브 카테고리", example = "한손검") + private String itemSubCategory; + + @Column(name = "date_auction_buy", nullable = false) + @Schema(description = "거래 일자", example = "2025-07-01") + private LocalDate dateAuctionBuy; + + @Column(name = "min_price", nullable = false) + @Schema(description = "최저 단가", example = "120000") + private Long minPrice; + + @Column(name = "max_price", nullable = false) + @Schema(description = "최고 단가", example = "150000") + private Long maxPrice; + + @Column(name = "avg_price", nullable = false, precision = 15, scale = 2) + @Schema(description = "평균 단가", example = "135000.50") + private BigDecimal avgPrice; + + @Column(name = "total_volume", nullable = false) + @Schema(description = "거래 총량 (총 거래 금액)", example = "50000000") + private Long totalVolume; + + @Column(name = "total_quantity", nullable = false) + @Schema(description = "거래 수량 (itemCount 합계)", example = "1500") + private Long totalQuantity; + + @Column(name = "created_at", nullable = false) + @Schema(description = "생성 일시", example = "2025-07-01T14:35:00") + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + @Schema(description = "수정 일시", example = "2025-07-01T15:35:00") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/until/the/eternity/statistics/domain/entity/daily/TopCategoryDailyStatistics.java b/src/main/java/until/the/eternity/statistics/domain/entity/daily/TopCategoryDailyStatistics.java new file mode 100644 index 0000000..fb3ab7a --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/domain/entity/daily/TopCategoryDailyStatistics.java @@ -0,0 +1,81 @@ +package until.the.eternity.statistics.domain.entity.daily; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.*; + +@Entity +@Table( + name = "top_category_daily_statistics", + indexes = { + @Index( + name = "idx_top_category_daily_statistics_category_date", + columnList = "item_top_category, date_auction_buy") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uk_top_category_daily_statistics_category_date", + columnNames = {"item_top_category", "date_auction_buy"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Schema(description = "탑카테고리별 일간 통계") +public class TopCategoryDailyStatistics { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "고유 식별자", example = "1") + private Long id; + + @Column(name = "item_top_category", nullable = false, length = 255) + @Schema(description = "아이템 탑 카테고리", example = "무기") + private String itemTopCategory; + + @Column(name = "date_auction_buy", nullable = false) + @Schema(description = "거래 일자", example = "2025-07-01") + private LocalDate dateAuctionBuy; + + @Column(name = "min_price", nullable = false) + @Schema(description = "최저 단가", example = "120000") + private Long minPrice; + + @Column(name = "max_price", nullable = false) + @Schema(description = "최고 단가", example = "150000") + private Long maxPrice; + + @Column(name = "avg_price", nullable = false, precision = 15, scale = 2) + @Schema(description = "평균 단가", example = "135000.50") + private BigDecimal avgPrice; + + @Column(name = "total_volume", nullable = false) + @Schema(description = "거래 총량 (총 거래 금액)", example = "500000000") + private Long totalVolume; + + @Column(name = "total_quantity", nullable = false) + @Schema(description = "거래 수량 (itemCount 합계)", example = "15000") + private Long totalQuantity; + + @Column(name = "created_at", nullable = false) + @Schema(description = "생성 일시", example = "2025-07-01T14:35:00") + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + @Schema(description = "수정 일시", example = "2025-07-01T15:35:00") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/until/the/eternity/statistics/domain/entity/weekly/ItemWeeklyStatistics.java b/src/main/java/until/the/eternity/statistics/domain/entity/weekly/ItemWeeklyStatistics.java new file mode 100644 index 0000000..b1ae76b --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/domain/entity/weekly/ItemWeeklyStatistics.java @@ -0,0 +1,89 @@ +package until.the.eternity.statistics.domain.entity.weekly; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.*; + +@Entity +@Table( + name = "item_weekly_statistics", + indexes = { + @Index( + name = "idx_item_weekly_statistics_item_name_year_week", + columnList = "item_name, year, week_number") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uk_item_weekly_statistics_item_name_year_week", + columnNames = {"item_name", "year", "week_number"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Schema(description = "아이템별 주간 통계") +public class ItemWeeklyStatistics { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "고유 식별자", example = "1") + private Long id; + + @Column(name = "item_name", nullable = false, length = 255) + @Schema(description = "아이템 이름", example = "켈틱 로열 나이트 소드") + private String itemName; + + @Column(name = "year", nullable = false) + @Schema(description = "연도", example = "2025") + private Integer year; + + @Column(name = "week_number", nullable = false) + @Schema(description = "주차 번호", example = "27") + private Integer weekNumber; + + @Column(name = "week_start_date", nullable = false) + @Schema(description = "주 시작일 (월요일)", example = "2025-07-01") + private LocalDate weekStartDate; + + @Column(name = "min_price", nullable = false) + @Schema(description = "최저 단가 (해당 주의 모든 거래 중 최저)", example = "120000") + private Long minPrice; + + @Column(name = "max_price", nullable = false) + @Schema(description = "최고 단가 (해당 주의 모든 거래 중 최고)", example = "150000") + private Long maxPrice; + + @Column(name = "avg_price", nullable = false, precision = 15, scale = 2) + @Schema(description = "평균 단가 (Daily 평균가의 평균)", example = "135000.50") + private BigDecimal avgPrice; + + @Column(name = "total_volume", nullable = false) + @Schema(description = "거래 총량 (총 거래 금액)", example = "35000000") + private Long totalVolume; + + @Column(name = "total_quantity", nullable = false) + @Schema(description = "거래 수량 (itemCount 합계)", example = "1050") + private Long totalQuantity; + + @Column(name = "created_at", nullable = false) + @Schema(description = "생성 일시", example = "2025-07-08T14:35:00") + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + @Schema(description = "수정 일시", example = "2025-07-08T15:35:00") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/until/the/eternity/statistics/domain/entity/weekly/SubcategoryWeeklyStatistics.java b/src/main/java/until/the/eternity/statistics/domain/entity/weekly/SubcategoryWeeklyStatistics.java new file mode 100644 index 0000000..5756b09 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/domain/entity/weekly/SubcategoryWeeklyStatistics.java @@ -0,0 +1,89 @@ +package until.the.eternity.statistics.domain.entity.weekly; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.*; + +@Entity +@Table( + name = "subcategory_weekly_statistics", + indexes = { + @Index( + name = "idx_subcategory_weekly_statistics_category_year_week", + columnList = "item_sub_category, year, week_number") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uk_subcategory_weekly_statistics_category_year_week", + columnNames = {"item_sub_category", "year", "week_number"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Schema(description = "서브카테고리별 주간 통계") +public class SubcategoryWeeklyStatistics { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "고유 식별자", example = "1") + private Long id; + + @Column(name = "item_sub_category", nullable = false, length = 255) + @Schema(description = "아이템 서브 카테고리", example = "한손검") + private String itemSubCategory; + + @Column(name = "year", nullable = false) + @Schema(description = "연도", example = "2025") + private Integer year; + + @Column(name = "week_number", nullable = false) + @Schema(description = "주차 번호", example = "27") + private Integer weekNumber; + + @Column(name = "week_start_date", nullable = false) + @Schema(description = "주 시작일 (월요일)", example = "2025-07-01") + private LocalDate weekStartDate; + + @Column(name = "min_price", nullable = false) + @Schema(description = "최저 단가 (해당 주의 모든 거래 중 최저)", example = "120000") + private Long minPrice; + + @Column(name = "max_price", nullable = false) + @Schema(description = "최고 단가 (해당 주의 모든 거래 중 최고)", example = "150000") + private Long maxPrice; + + @Column(name = "avg_price", nullable = false, precision = 15, scale = 2) + @Schema(description = "평균 단가 (Daily 평균가의 평균)", example = "135000.50") + private BigDecimal avgPrice; + + @Column(name = "total_volume", nullable = false) + @Schema(description = "거래 총량 (총 거래 금액)", example = "350000000") + private Long totalVolume; + + @Column(name = "total_quantity", nullable = false) + @Schema(description = "거래 수량 (itemCount 합계)", example = "10500") + private Long totalQuantity; + + @Column(name = "created_at", nullable = false) + @Schema(description = "생성 일시", example = "2025-07-08T14:35:00") + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + @Schema(description = "수정 일시", example = "2025-07-08T15:35:00") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/until/the/eternity/statistics/domain/entity/weekly/TopCategoryWeeklyStatistics.java b/src/main/java/until/the/eternity/statistics/domain/entity/weekly/TopCategoryWeeklyStatistics.java new file mode 100644 index 0000000..9201faa --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/domain/entity/weekly/TopCategoryWeeklyStatistics.java @@ -0,0 +1,89 @@ +package until.the.eternity.statistics.domain.entity.weekly; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.*; + +@Entity +@Table( + name = "top_category_weekly_statistics", + indexes = { + @Index( + name = "idx_top_category_weekly_statistics_category_year_week", + columnList = "item_top_category, year, week_number") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uk_top_category_weekly_statistics_category_year_week", + columnNames = {"item_top_category", "year", "week_number"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Schema(description = "탑카테고리별 주간 통계") +public class TopCategoryWeeklyStatistics { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "고유 식별자", example = "1") + private Long id; + + @Column(name = "item_top_category", nullable = false, length = 255) + @Schema(description = "아이템 탑 카테고리", example = "무기") + private String itemTopCategory; + + @Column(name = "year", nullable = false) + @Schema(description = "연도", example = "2025") + private Integer year; + + @Column(name = "week_number", nullable = false) + @Schema(description = "주차 번호", example = "27") + private Integer weekNumber; + + @Column(name = "week_start_date", nullable = false) + @Schema(description = "주 시작일 (월요일)", example = "2025-07-01") + private LocalDate weekStartDate; + + @Column(name = "min_price", nullable = false) + @Schema(description = "최저 단가 (해당 주의 모든 거래 중 최저)", example = "120000") + private Long minPrice; + + @Column(name = "max_price", nullable = false) + @Schema(description = "최고 단가 (해당 주의 모든 거래 중 최고)", example = "150000") + private Long maxPrice; + + @Column(name = "avg_price", nullable = false, precision = 15, scale = 2) + @Schema(description = "평균 단가 (Daily 평균가의 평균)", example = "135000.50") + private BigDecimal avgPrice; + + @Column(name = "total_volume", nullable = false) + @Schema(description = "거래 총량 (총 거래 금액)", example = "3500000000") + private Long totalVolume; + + @Column(name = "total_quantity", nullable = false) + @Schema(description = "거래 수량 (itemCount 합계)", example = "105000") + private Long totalQuantity; + + @Column(name = "created_at", nullable = false) + @Schema(description = "생성 일시", example = "2025-07-08T14:35:00") + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + @Schema(description = "수정 일시", example = "2025-07-08T15:35:00") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/until/the/eternity/statistics/domain/mapper/ItemDailyStatisticsMapper.java b/src/main/java/until/the/eternity/statistics/domain/mapper/ItemDailyStatisticsMapper.java new file mode 100644 index 0000000..3ffd598 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/domain/mapper/ItemDailyStatisticsMapper.java @@ -0,0 +1,10 @@ +package until.the.eternity.statistics.domain.mapper; + +import org.mapstruct.Mapper; +import until.the.eternity.statistics.domain.entity.daily.ItemDailyStatistics; +import until.the.eternity.statistics.interfaces.rest.dto.response.ItemDailyStatisticsResponse; + +@Mapper(componentModel = "spring") +public interface ItemDailyStatisticsMapper { + ItemDailyStatisticsResponse toDto(ItemDailyStatistics entity); +} diff --git a/src/main/java/until/the/eternity/statistics/domain/mapper/ItemWeeklyStatisticsMapper.java b/src/main/java/until/the/eternity/statistics/domain/mapper/ItemWeeklyStatisticsMapper.java new file mode 100644 index 0000000..fc114ff --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/domain/mapper/ItemWeeklyStatisticsMapper.java @@ -0,0 +1,10 @@ +package until.the.eternity.statistics.domain.mapper; + +import org.mapstruct.Mapper; +import until.the.eternity.statistics.domain.entity.weekly.ItemWeeklyStatistics; +import until.the.eternity.statistics.interfaces.rest.dto.response.ItemWeeklyStatisticsResponse; + +@Mapper(componentModel = "spring") +public interface ItemWeeklyStatisticsMapper { + ItemWeeklyStatisticsResponse toDto(ItemWeeklyStatistics entity); +} diff --git a/src/main/java/until/the/eternity/statistics/domain/mapper/SubcategoryDailyStatisticsMapper.java b/src/main/java/until/the/eternity/statistics/domain/mapper/SubcategoryDailyStatisticsMapper.java new file mode 100644 index 0000000..642940b --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/domain/mapper/SubcategoryDailyStatisticsMapper.java @@ -0,0 +1,10 @@ +package until.the.eternity.statistics.domain.mapper; + +import org.mapstruct.Mapper; +import until.the.eternity.statistics.domain.entity.daily.SubcategoryDailyStatistics; +import until.the.eternity.statistics.interfaces.rest.dto.response.SubcategoryDailyStatisticsResponse; + +@Mapper(componentModel = "spring") +public interface SubcategoryDailyStatisticsMapper { + SubcategoryDailyStatisticsResponse toDto(SubcategoryDailyStatistics entity); +} diff --git a/src/main/java/until/the/eternity/statistics/domain/mapper/SubcategoryWeeklyStatisticsMapper.java b/src/main/java/until/the/eternity/statistics/domain/mapper/SubcategoryWeeklyStatisticsMapper.java new file mode 100644 index 0000000..ab47841 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/domain/mapper/SubcategoryWeeklyStatisticsMapper.java @@ -0,0 +1,10 @@ +package until.the.eternity.statistics.domain.mapper; + +import org.mapstruct.Mapper; +import until.the.eternity.statistics.domain.entity.weekly.SubcategoryWeeklyStatistics; +import until.the.eternity.statistics.interfaces.rest.dto.response.SubcategoryWeeklyStatisticsResponse; + +@Mapper(componentModel = "spring") +public interface SubcategoryWeeklyStatisticsMapper { + SubcategoryWeeklyStatisticsResponse toDto(SubcategoryWeeklyStatistics entity); +} diff --git a/src/main/java/until/the/eternity/statistics/domain/mapper/TopCategoryDailyStatisticsMapper.java b/src/main/java/until/the/eternity/statistics/domain/mapper/TopCategoryDailyStatisticsMapper.java new file mode 100644 index 0000000..d9ed6ae --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/domain/mapper/TopCategoryDailyStatisticsMapper.java @@ -0,0 +1,10 @@ +package until.the.eternity.statistics.domain.mapper; + +import org.mapstruct.Mapper; +import until.the.eternity.statistics.domain.entity.daily.TopCategoryDailyStatistics; +import until.the.eternity.statistics.interfaces.rest.dto.response.TopCategoryDailyStatisticsResponse; + +@Mapper(componentModel = "spring") +public interface TopCategoryDailyStatisticsMapper { + TopCategoryDailyStatisticsResponse toDto(TopCategoryDailyStatistics entity); +} diff --git a/src/main/java/until/the/eternity/statistics/domain/mapper/TopCategoryWeeklyStatisticsMapper.java b/src/main/java/until/the/eternity/statistics/domain/mapper/TopCategoryWeeklyStatisticsMapper.java new file mode 100644 index 0000000..04622f4 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/domain/mapper/TopCategoryWeeklyStatisticsMapper.java @@ -0,0 +1,10 @@ +package until.the.eternity.statistics.domain.mapper; + +import org.mapstruct.Mapper; +import until.the.eternity.statistics.domain.entity.weekly.TopCategoryWeeklyStatistics; +import until.the.eternity.statistics.interfaces.rest.dto.response.TopCategoryWeeklyStatisticsResponse; + +@Mapper(componentModel = "spring") +public interface TopCategoryWeeklyStatisticsMapper { + TopCategoryWeeklyStatisticsResponse toDto(TopCategoryWeeklyStatistics entity); +} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemDailyStatisticsController.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemDailyStatisticsController.java new file mode 100644 index 0000000..892ad3b --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemDailyStatisticsController.java @@ -0,0 +1,40 @@ +package until.the.eternity.statistics.interfaces.rest.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import until.the.eternity.common.request.PageRequestDto; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.application.service.ItemDailyStatisticsService; +import until.the.eternity.statistics.interfaces.rest.dto.response.ItemDailyStatisticsResponse; + +@RestController +@RequestMapping("/statistics/daily/items") +@RequiredArgsConstructor +@Tag(name = "아이템별 일간 통계 API", description = "아이템별 일간 거래 통계 조회 API") +public class ItemDailyStatisticsController { + + private final ItemDailyStatisticsService service; + + @GetMapping + @Operation( + summary = "아이템별 일간 통계 목록 조회", + description = "아이템별 일간 거래 통계 목록을 페이징하여 조회합니다. 최저가, 최고가, 평균가, 거래 총량, 거래 수량 정보를 포함합니다.") + public ResponseEntity> getItemDailyStatistics( + @ParameterObject @ModelAttribute PageRequestDto pageDto) { + PageResponseDto result = service.findAll(pageDto.toPageable()); + return ResponseEntity.ok(result); + } + + @GetMapping("/{id}") + @Operation(summary = "아이템별 일간 통계 단건 조회", description = "ID를 통해 특정 아이템의 일간 거래 통계를 조회합니다.") + public ResponseEntity getItemDailyStatisticsById( + @Parameter(description = "통계 ID", example = "1") @PathVariable Long id) { + ItemDailyStatisticsResponse result = service.findById(id); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemWeeklyStatisticsController.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemWeeklyStatisticsController.java new file mode 100644 index 0000000..af8cad2 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemWeeklyStatisticsController.java @@ -0,0 +1,42 @@ +package until.the.eternity.statistics.interfaces.rest.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import until.the.eternity.common.request.PageRequestDto; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.application.service.ItemWeeklyStatisticsService; +import until.the.eternity.statistics.interfaces.rest.dto.response.ItemWeeklyStatisticsResponse; + +@RestController +@RequestMapping("/statistics/weekly/items") +@RequiredArgsConstructor +@Tag(name = "아이템별 주간 통계 API", description = "아이템별 주간 거래 통계 조회 API") +public class ItemWeeklyStatisticsController { + + private final ItemWeeklyStatisticsService service; + + @GetMapping + @Operation( + summary = "아이템별 주간 통계 목록 조회", + description = + "아이템별 주간 거래 통계 목록을 페이징하여 조회합니다. 최저가, 최고가, 평균가, 거래 총량, 거래 수량, 연도, 주차 정보를 포함합니다.") + public ResponseEntity> getItemWeeklyStatistics( + @ParameterObject @ModelAttribute PageRequestDto pageDto) { + PageResponseDto result = + service.findAll(pageDto.toPageable()); + return ResponseEntity.ok(result); + } + + @GetMapping("/{id}") + @Operation(summary = "아이템별 주간 통계 단건 조회", description = "ID를 통해 특정 아이템의 주간 거래 통계를 조회합니다.") + public ResponseEntity getItemWeeklyStatisticsById( + @Parameter(description = "통계 ID", example = "1") @PathVariable Long id) { + ItemWeeklyStatisticsResponse result = service.findById(id); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryDailyStatisticsController.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryDailyStatisticsController.java new file mode 100644 index 0000000..2f8df45 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryDailyStatisticsController.java @@ -0,0 +1,42 @@ +package until.the.eternity.statistics.interfaces.rest.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import until.the.eternity.common.request.PageRequestDto; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.application.service.SubcategoryDailyStatisticsService; +import until.the.eternity.statistics.interfaces.rest.dto.response.SubcategoryDailyStatisticsResponse; + +@RestController +@RequestMapping("/statistics/daily/subcategories") +@RequiredArgsConstructor +@Tag(name = "서브카테고리별 일간 통계 API", description = "서브카테고리별 일간 거래 통계 조회 API") +public class SubcategoryDailyStatisticsController { + + private final SubcategoryDailyStatisticsService service; + + @GetMapping + @Operation( + summary = "서브카테고리별 일간 통계 목록 조회", + description = + "서브카테고리별 일간 거래 통계 목록을 페이징하여 조회합니다. 최저가, 최고가, 평균가, 거래 총량, 거래 수량 정보를 포함합니다.") + public ResponseEntity> + getSubcategoryDailyStatistics(@ParameterObject @ModelAttribute PageRequestDto pageDto) { + PageResponseDto result = + service.findAll(pageDto.toPageable()); + return ResponseEntity.ok(result); + } + + @GetMapping("/{id}") + @Operation(summary = "서브카테고리별 일간 통계 단건 조회", description = "ID를 통해 특정 서브카테고리의 일간 거래 통계를 조회합니다.") + public ResponseEntity getSubcategoryDailyStatisticsById( + @Parameter(description = "통계 ID", example = "1") @PathVariable Long id) { + SubcategoryDailyStatisticsResponse result = service.findById(id); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryWeeklyStatisticsController.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryWeeklyStatisticsController.java new file mode 100644 index 0000000..43c7039 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryWeeklyStatisticsController.java @@ -0,0 +1,43 @@ +package until.the.eternity.statistics.interfaces.rest.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import until.the.eternity.common.request.PageRequestDto; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.application.service.SubcategoryWeeklyStatisticsService; +import until.the.eternity.statistics.interfaces.rest.dto.response.SubcategoryWeeklyStatisticsResponse; + +@RestController +@RequestMapping("/statistics/weekly/subcategories") +@RequiredArgsConstructor +@Tag(name = "서브카테고리별 주간 통계 API", description = "서브카테고리별 주간 거래 통계 조회 API") +public class SubcategoryWeeklyStatisticsController { + + private final SubcategoryWeeklyStatisticsService service; + + @GetMapping + @Operation( + summary = "서브카테고리별 주간 통계 목록 조회", + description = + "서브카테고리별 주간 거래 통계 목록을 페이징하여 조회합니다. 최저가, 최고가, 평균가, 거래 총량, 거래 수량, 연도, 주차 정보를 포함합니다.") + public ResponseEntity> + getSubcategoryWeeklyStatistics( + @ParameterObject @ModelAttribute PageRequestDto pageDto) { + PageResponseDto result = + service.findAll(pageDto.toPageable()); + return ResponseEntity.ok(result); + } + + @GetMapping("/{id}") + @Operation(summary = "서브카테고리별 주간 통계 단건 조회", description = "ID를 통해 특정 서브카테고리의 주간 거래 통계를 조회합니다.") + public ResponseEntity getSubcategoryWeeklyStatisticsById( + @Parameter(description = "통계 ID", example = "1") @PathVariable Long id) { + SubcategoryWeeklyStatisticsResponse result = service.findById(id); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryDailyStatisticsController.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryDailyStatisticsController.java new file mode 100644 index 0000000..f0c9c59 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryDailyStatisticsController.java @@ -0,0 +1,41 @@ +package until.the.eternity.statistics.interfaces.rest.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import until.the.eternity.common.request.PageRequestDto; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.application.service.TopCategoryDailyStatisticsService; +import until.the.eternity.statistics.interfaces.rest.dto.response.TopCategoryDailyStatisticsResponse; + +@RestController +@RequestMapping("/statistics/daily/top-categories") +@RequiredArgsConstructor +@Tag(name = "탑카테고리별 일간 통계 API", description = "탑카테고리별 일간 거래 통계 조회 API") +public class TopCategoryDailyStatisticsController { + + private final TopCategoryDailyStatisticsService service; + + @GetMapping + @Operation( + summary = "탑카테고리별 일간 통계 목록 조회", + description = "탑카테고리별 일간 거래 통계 목록을 페이징하여 조회합니다. 최저가, 최고가, 평균가, 거래 총량, 거래 수량 정보를 포함합니다.") + public ResponseEntity> + getTopCategoryDailyStatistics(@ParameterObject @ModelAttribute PageRequestDto pageDto) { + PageResponseDto result = + service.findAll(pageDto.toPageable()); + return ResponseEntity.ok(result); + } + + @GetMapping("/{id}") + @Operation(summary = "탑카테고리별 일간 통계 단건 조회", description = "ID를 통해 특정 탑카테고리의 일간 거래 통계를 조회합니다.") + public ResponseEntity getTopCategoryDailyStatisticsById( + @Parameter(description = "통계 ID", example = "1") @PathVariable Long id) { + TopCategoryDailyStatisticsResponse result = service.findById(id); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryWeeklyStatisticsController.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryWeeklyStatisticsController.java new file mode 100644 index 0000000..e63033e --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryWeeklyStatisticsController.java @@ -0,0 +1,43 @@ +package until.the.eternity.statistics.interfaces.rest.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import until.the.eternity.common.request.PageRequestDto; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.application.service.TopCategoryWeeklyStatisticsService; +import until.the.eternity.statistics.interfaces.rest.dto.response.TopCategoryWeeklyStatisticsResponse; + +@RestController +@RequestMapping("/statistics/weekly/top-categories") +@RequiredArgsConstructor +@Tag(name = "탑카테고리별 주간 통계 API", description = "탑카테고리별 주간 거래 통계 조회 API") +public class TopCategoryWeeklyStatisticsController { + + private final TopCategoryWeeklyStatisticsService service; + + @GetMapping + @Operation( + summary = "탑카테고리별 주간 통계 목록 조회", + description = + "탑카테고리별 주간 거래 통계 목록을 페이징하여 조회합니다. 최저가, 최고가, 평균가, 거래 총량, 거래 수량, 연도, 주차 정보를 포함합니다.") + public ResponseEntity> + getTopCategoryWeeklyStatistics( + @ParameterObject @ModelAttribute PageRequestDto pageDto) { + PageResponseDto result = + service.findAll(pageDto.toPageable()); + return ResponseEntity.ok(result); + } + + @GetMapping("/{id}") + @Operation(summary = "탑카테고리별 주간 통계 단건 조회", description = "ID를 통해 특정 탑카테고리의 주간 거래 통계를 조회합니다.") + public ResponseEntity getTopCategoryWeeklyStatisticsById( + @Parameter(description = "통계 ID", example = "1") @PathVariable Long id) { + TopCategoryWeeklyStatisticsResponse result = service.findById(id); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/request/DailyStatisticsSearchRequest.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/request/DailyStatisticsSearchRequest.java new file mode 100644 index 0000000..7908da4 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/request/DailyStatisticsSearchRequest.java @@ -0,0 +1,17 @@ +package until.the.eternity.statistics.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +@Schema(description = "일간 통계 검색 요청") +public record DailyStatisticsSearchRequest( + @Schema(description = "아이템 이름 (부분 일치)", example = "켈틱") String itemName, + @Schema(description = "아이템 서브 카테고리", example = "한손검") String itemSubCategory, + @Schema(description = "아이템 탑 카테고리", example = "무기") String itemTopCategory, + @Schema(description = "거래 시작 일자", example = "2025-07-01") + @DateTimeFormat(pattern = "yyyy-MM-dd") + LocalDate dateFrom, + @Schema(description = "거래 종료 일자", example = "2025-07-31") + @DateTimeFormat(pattern = "yyyy-MM-dd") + LocalDate dateTo) {} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/request/WeeklyStatisticsSearchRequest.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/request/WeeklyStatisticsSearchRequest.java new file mode 100644 index 0000000..c4c4fd4 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/request/WeeklyStatisticsSearchRequest.java @@ -0,0 +1,12 @@ +package until.the.eternity.statistics.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "주간 통계 검색 요청") +public record WeeklyStatisticsSearchRequest( + @Schema(description = "아이템 이름 (부분 일치)", example = "켈틱") String itemName, + @Schema(description = "아이템 서브 카테고리", example = "한손검") String itemSubCategory, + @Schema(description = "아이템 탑 카테고리", example = "무기") String itemTopCategory, + @Schema(description = "연도", example = "2025") Integer year, + @Schema(description = "주차 번호 (시작)", example = "1") Integer weekNumberFrom, + @Schema(description = "주차 번호 (종료)", example = "52") Integer weekNumberTo) {} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/ItemDailyStatisticsResponse.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/ItemDailyStatisticsResponse.java new file mode 100644 index 0000000..a961101 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/ItemDailyStatisticsResponse.java @@ -0,0 +1,25 @@ +package until.the.eternity.statistics.interfaces.rest.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Schema(description = "아이템별 일간 통계 응답") +public record ItemDailyStatisticsResponse( + @Schema(description = "고유 식별자", example = "1") Long id, + @Schema(description = "아이템 이름", example = "켈틱 로열 나이트 소드") String itemName, + @Schema(description = "거래 일자", example = "2025-07-01") @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate dateAuctionBuy, + @Schema(description = "최저 단가", example = "120000") Long minPrice, + @Schema(description = "최고 단가", example = "150000") Long maxPrice, + @Schema(description = "평균 단가", example = "135000.50") BigDecimal avgPrice, + @Schema(description = "거래 총량 (총 거래 금액)", example = "5000000") Long totalVolume, + @Schema(description = "거래 수량 (itemCount 합계)", example = "150") Long totalQuantity, + @Schema(description = "생성 일시", example = "2025-07-01T14:35:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime createdAt, + @Schema(description = "수정 일시", example = "2025-07-01T15:35:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime updatedAt) {} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/ItemWeeklyStatisticsResponse.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/ItemWeeklyStatisticsResponse.java new file mode 100644 index 0000000..05e86f7 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/ItemWeeklyStatisticsResponse.java @@ -0,0 +1,28 @@ +package until.the.eternity.statistics.interfaces.rest.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Schema(description = "아이템별 주간 통계 응답") +public record ItemWeeklyStatisticsResponse( + @Schema(description = "고유 식별자", example = "1") Long id, + @Schema(description = "아이템 이름", example = "켈틱 로열 나이트 소드") String itemName, + @Schema(description = "연도", example = "2025") Integer year, + @Schema(description = "주차 번호", example = "27") Integer weekNumber, + @Schema(description = "주 시작일 (월요일)", example = "2025-07-01") + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate weekStartDate, + @Schema(description = "최저 단가 (해당 주의 모든 거래 중 최저)", example = "120000") Long minPrice, + @Schema(description = "최고 단가 (해당 주의 모든 거래 중 최고)", example = "150000") Long maxPrice, + @Schema(description = "평균 단가 (Daily 평균가의 평균)", example = "135000.50") BigDecimal avgPrice, + @Schema(description = "거래 총량 (총 거래 금액)", example = "35000000") Long totalVolume, + @Schema(description = "거래 수량 (itemCount 합계)", example = "1050") Long totalQuantity, + @Schema(description = "생성 일시", example = "2025-07-08T14:35:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime createdAt, + @Schema(description = "수정 일시", example = "2025-07-08T15:35:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime updatedAt) {} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/SubcategoryDailyStatisticsResponse.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/SubcategoryDailyStatisticsResponse.java new file mode 100644 index 0000000..e27f7f3 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/SubcategoryDailyStatisticsResponse.java @@ -0,0 +1,25 @@ +package until.the.eternity.statistics.interfaces.rest.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Schema(description = "서브카테고리별 일간 통계 응답") +public record SubcategoryDailyStatisticsResponse( + @Schema(description = "고유 식별자", example = "1") Long id, + @Schema(description = "아이템 서브 카테고리", example = "한손검") String itemSubCategory, + @Schema(description = "거래 일자", example = "2025-07-01") @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate dateAuctionBuy, + @Schema(description = "최저 단가", example = "120000") Long minPrice, + @Schema(description = "최고 단가", example = "150000") Long maxPrice, + @Schema(description = "평균 단가", example = "135000.50") BigDecimal avgPrice, + @Schema(description = "거래 총량 (총 거래 금액)", example = "50000000") Long totalVolume, + @Schema(description = "거래 수량 (itemCount 합계)", example = "1500") Long totalQuantity, + @Schema(description = "생성 일시", example = "2025-07-01T14:35:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime createdAt, + @Schema(description = "수정 일시", example = "2025-07-01T15:35:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime updatedAt) {} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/SubcategoryWeeklyStatisticsResponse.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/SubcategoryWeeklyStatisticsResponse.java new file mode 100644 index 0000000..bb82686 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/SubcategoryWeeklyStatisticsResponse.java @@ -0,0 +1,28 @@ +package until.the.eternity.statistics.interfaces.rest.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Schema(description = "서브카테고리별 주간 통계 응답") +public record SubcategoryWeeklyStatisticsResponse( + @Schema(description = "고유 식별자", example = "1") Long id, + @Schema(description = "아이템 서브 카테고리", example = "한손검") String itemSubCategory, + @Schema(description = "연도", example = "2025") Integer year, + @Schema(description = "주차 번호", example = "27") Integer weekNumber, + @Schema(description = "주 시작일 (월요일)", example = "2025-07-01") + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate weekStartDate, + @Schema(description = "최저 단가 (해당 주의 모든 거래 중 최저)", example = "120000") Long minPrice, + @Schema(description = "최고 단가 (해당 주의 모든 거래 중 최고)", example = "150000") Long maxPrice, + @Schema(description = "평균 단가 (Daily 평균가의 평균)", example = "135000.50") BigDecimal avgPrice, + @Schema(description = "거래 총량 (총 거래 금액)", example = "350000000") Long totalVolume, + @Schema(description = "거래 수량 (itemCount 합계)", example = "10500") Long totalQuantity, + @Schema(description = "생성 일시", example = "2025-07-08T14:35:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime createdAt, + @Schema(description = "수정 일시", example = "2025-07-08T15:35:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime updatedAt) {} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/TopCategoryDailyStatisticsResponse.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/TopCategoryDailyStatisticsResponse.java new file mode 100644 index 0000000..fd49c2f --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/TopCategoryDailyStatisticsResponse.java @@ -0,0 +1,25 @@ +package until.the.eternity.statistics.interfaces.rest.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Schema(description = "탑카테고리별 일간 통계 응답") +public record TopCategoryDailyStatisticsResponse( + @Schema(description = "고유 식별자", example = "1") Long id, + @Schema(description = "아이템 탑 카테고리", example = "무기") String itemTopCategory, + @Schema(description = "거래 일자", example = "2025-07-01") @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate dateAuctionBuy, + @Schema(description = "최저 단가", example = "120000") Long minPrice, + @Schema(description = "최고 단가", example = "150000") Long maxPrice, + @Schema(description = "평균 단가", example = "135000.50") BigDecimal avgPrice, + @Schema(description = "거래 총량 (총 거래 금액)", example = "500000000") Long totalVolume, + @Schema(description = "거래 수량 (itemCount 합계)", example = "15000") Long totalQuantity, + @Schema(description = "생성 일시", example = "2025-07-01T14:35:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime createdAt, + @Schema(description = "수정 일시", example = "2025-07-01T15:35:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime updatedAt) {} diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/TopCategoryWeeklyStatisticsResponse.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/TopCategoryWeeklyStatisticsResponse.java new file mode 100644 index 0000000..ad81f76 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/dto/response/TopCategoryWeeklyStatisticsResponse.java @@ -0,0 +1,28 @@ +package until.the.eternity.statistics.interfaces.rest.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Schema(description = "탑카테고리별 주간 통계 응답") +public record TopCategoryWeeklyStatisticsResponse( + @Schema(description = "고유 식별자", example = "1") Long id, + @Schema(description = "아이템 탑 카테고리", example = "무기") String itemTopCategory, + @Schema(description = "연도", example = "2025") Integer year, + @Schema(description = "주차 번호", example = "27") Integer weekNumber, + @Schema(description = "주 시작일 (월요일)", example = "2025-07-01") + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate weekStartDate, + @Schema(description = "최저 단가 (해당 주의 모든 거래 중 최저)", example = "120000") Long minPrice, + @Schema(description = "최고 단가 (해당 주의 모든 거래 중 최고)", example = "150000") Long maxPrice, + @Schema(description = "평균 단가 (Daily 평균가의 평균)", example = "135000.50") BigDecimal avgPrice, + @Schema(description = "거래 총량 (총 거래 금액)", example = "3500000000") Long totalVolume, + @Schema(description = "거래 수량 (itemCount 합계)", example = "105000") Long totalQuantity, + @Schema(description = "생성 일시", example = "2025-07-08T14:35:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime createdAt, + @Schema(description = "수정 일시", example = "2025-07-08T15:35:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime updatedAt) {} diff --git a/src/main/java/until/the/eternity/statistics/repository/daily/ItemDailyStatisticsRepository.java b/src/main/java/until/the/eternity/statistics/repository/daily/ItemDailyStatisticsRepository.java new file mode 100644 index 0000000..3df44dc --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/repository/daily/ItemDailyStatisticsRepository.java @@ -0,0 +1,51 @@ +package until.the.eternity.statistics.repository.daily; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.statistics.domain.entity.daily.ItemDailyStatistics; + +public interface ItemDailyStatisticsRepository extends JpaRepository { + + /** 전날 거래된 각 아이템의 통계를 item_daily_statistics 테이블에 upsert */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Transactional + @Query( + value = + """ + INSERT INTO item_daily_statistics ( + item_name, + date_auction_buy, + min_price, + max_price, + avg_price, + total_volume, + total_quantity, + created_at, + updated_at + ) + SELECT + ah.item_name, + DATE(DATE_SUB(NOW(), INTERVAL 9 HOUR)) AS date_auction_buy, + MIN(ah.auction_price_per_unit) AS min_price, + MAX(ah.auction_price_per_unit) AS max_price, + AVG(ah.auction_price_per_unit) AS avg_price, + SUM(ah.auction_price_per_unit * ah.item_count) AS total_volume, + SUM(ah.item_count) AS total_quantity, + CURRENT_TIMESTAMP AS created_at, + CURRENT_TIMESTAMP AS updated_at + FROM auction_history ah + WHERE DATE(ah.date_auction_buy) = DATE(DATE_SUB(NOW(), INTERVAL 9 HOUR)) + GROUP BY ah.item_name, DATE(date_auction_buy) + ON DUPLICATE KEY UPDATE + min_price = VALUES(min_price), + max_price = VALUES(max_price), + avg_price = VALUES(avg_price), + total_volume = VALUES(total_volume), + total_quantity = VALUES(total_quantity), + updated_at = CURRENT_TIMESTAMP; + """, + nativeQuery = true) + void upsertDailyStatistics(); +} diff --git a/src/main/java/until/the/eternity/statistics/repository/daily/SubcategoryDailyStatisticsRepository.java b/src/main/java/until/the/eternity/statistics/repository/daily/SubcategoryDailyStatisticsRepository.java new file mode 100644 index 0000000..39937ea --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/repository/daily/SubcategoryDailyStatisticsRepository.java @@ -0,0 +1,54 @@ +package until.the.eternity.statistics.repository.daily; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.statistics.domain.entity.daily.SubcategoryDailyStatistics; + +public interface SubcategoryDailyStatisticsRepository + extends JpaRepository { + + /** 전날의 ItemDailyStatistics 데이터를 기반으로 서브카테고리별 통계를 집계하여 upsert */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Transactional + @Query( + value = + """ + INSERT INTO subcategory_daily_statistics ( + item_sub_category, + date_auction_buy, + min_price, + max_price, + avg_price, + total_volume, + total_quantity, + created_at, + updated_at + ) + SELECT + ah.item_sub_category, + ids.date_auction_buy, + MIN(ids.min_price) AS min_price, + MAX(ids.max_price) AS max_price, + AVG(ids.avg_price) AS avg_price, + SUM(ids.total_volume) AS total_volume, + SUM(ids.total_quantity) AS total_quantity, + CURRENT_TIMESTAMP AS created_at, + CURRENT_TIMESTAMP AS updated_at + FROM item_daily_statistics ids + INNER JOIN auction_history ah ON ids.item_name = ah.item_name + AND DATE(ah.date_auction_buy) = ids.date_auction_buy + WHERE ids.date_auction_buy = DATE(DATE_SUB(NOW(), INTERVAL 9 HOUR)) + GROUP BY ah.item_sub_category, ids.date_auction_buy + ON DUPLICATE KEY UPDATE + min_price = VALUES(min_price), + max_price = VALUES(max_price), + avg_price = VALUES(avg_price), + total_volume = VALUES(total_volume), + total_quantity = VALUES(total_quantity), + updated_at = CURRENT_TIMESTAMP; + """, + nativeQuery = true) + void upsertDailyStatistics(); +} diff --git a/src/main/java/until/the/eternity/statistics/repository/daily/TopCategoryDailyStatisticsRepository.java b/src/main/java/until/the/eternity/statistics/repository/daily/TopCategoryDailyStatisticsRepository.java new file mode 100644 index 0000000..e9e4f5b --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/repository/daily/TopCategoryDailyStatisticsRepository.java @@ -0,0 +1,54 @@ +package until.the.eternity.statistics.repository.daily; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.statistics.domain.entity.daily.TopCategoryDailyStatistics; + +public interface TopCategoryDailyStatisticsRepository + extends JpaRepository { + + /** 전날의 SubcategoryDailyStatistics 데이터를 기반으로 탑카테고리별 통계를 집계하여 upsert */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Transactional + @Query( + value = + """ + INSERT INTO top_category_daily_statistics ( + item_top_category, + date_auction_buy, + min_price, + max_price, + avg_price, + total_volume, + total_quantity, + created_at, + updated_at + ) + SELECT + ah.item_top_category, + sds.date_auction_buy, + MIN(sds.min_price) AS min_price, + MAX(sds.max_price) AS max_price, + AVG(sds.avg_price) AS avg_price, + SUM(sds.total_volume) AS total_volume, + SUM(sds.total_quantity) AS total_quantity, + CURRENT_TIMESTAMP AS created_at, + CURRENT_TIMESTAMP AS updated_at + FROM subcategory_daily_statistics sds + INNER JOIN auction_history ah ON sds.item_sub_category = ah.item_sub_category + AND DATE(ah.date_auction_buy) = sds.date_auction_buy + WHERE sds.date_auction_buy = DATE(DATE_SUB(NOW(), INTERVAL 9 HOUR)) + GROUP BY ah.item_top_category, sds.date_auction_buy + ON DUPLICATE KEY UPDATE + min_price = VALUES(min_price), + max_price = VALUES(max_price), + avg_price = VALUES(avg_price), + total_volume = VALUES(total_volume), + total_quantity = VALUES(total_quantity), + updated_at = CURRENT_TIMESTAMP; + """, + nativeQuery = true) + void upsertDailyStatistics(); +} diff --git a/src/main/java/until/the/eternity/statistics/repository/weekly/ItemWeeklyStatisticsRepository.java b/src/main/java/until/the/eternity/statistics/repository/weekly/ItemWeeklyStatisticsRepository.java new file mode 100644 index 0000000..7697809 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/repository/weekly/ItemWeeklyStatisticsRepository.java @@ -0,0 +1,56 @@ +package until.the.eternity.statistics.repository.weekly; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.statistics.domain.entity.weekly.ItemWeeklyStatistics; + +public interface ItemWeeklyStatisticsRepository extends JpaRepository { + + /** 전주(지난 주 월~일)의 ItemDailyStatistics 데이터를 기반으로 아이템별 주간 통계를 집계하여 upsert */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Transactional + @Query( + value = + """ + INSERT INTO item_weekly_statistics ( + item_name, + year, + week_number, + week_start_date, + min_price, + max_price, + avg_price, + total_volume, + total_quantity, + created_at, + updated_at + ) + SELECT + ids.item_name, + YEAR(DATE_SUB(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 7 DAY)) AS year, + WEEK(DATE_SUB(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 7 DAY), 1) AS week_number, + DATE_SUB(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 7 DAY) AS week_start_date, + MIN(ids.min_price) AS min_price, + MAX(ids.max_price) AS max_price, + AVG(ids.avg_price) AS avg_price, + SUM(ids.total_volume) AS total_volume, + SUM(ids.total_quantity) AS total_quantity, + CURRENT_TIMESTAMP AS created_at, + CURRENT_TIMESTAMP AS updated_at + FROM item_daily_statistics ids + WHERE ids.date_auction_buy >= DATE_SUB(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 7 DAY) + AND ids.date_auction_buy < DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) + GROUP BY ids.item_name + ON DUPLICATE KEY UPDATE + min_price = VALUES(min_price), + max_price = VALUES(max_price), + avg_price = VALUES(avg_price), + total_volume = VALUES(total_volume), + total_quantity = VALUES(total_quantity), + updated_at = CURRENT_TIMESTAMP; + """, + nativeQuery = true) + void upsertWeeklyStatistics(); +} diff --git a/src/main/java/until/the/eternity/statistics/repository/weekly/SubcategoryWeeklyStatisticsRepository.java b/src/main/java/until/the/eternity/statistics/repository/weekly/SubcategoryWeeklyStatisticsRepository.java new file mode 100644 index 0000000..26fc74d --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/repository/weekly/SubcategoryWeeklyStatisticsRepository.java @@ -0,0 +1,57 @@ +package until.the.eternity.statistics.repository.weekly; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.statistics.domain.entity.weekly.SubcategoryWeeklyStatistics; + +public interface SubcategoryWeeklyStatisticsRepository + extends JpaRepository { + + /** 전주의 ItemWeeklyStatistics 데이터를 기반으로 서브카테고리별 주간 통계를 집계하여 upsert */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Transactional + @Query( + value = + """ + INSERT INTO subcategory_weekly_statistics ( + item_sub_category, + year, + week_number, + week_start_date, + min_price, + max_price, + avg_price, + total_volume, + total_quantity, + created_at, + updated_at + ) + SELECT + ah.item_sub_category, + iws.year, + iws.week_number, + iws.week_start_date, + MIN(iws.min_price) AS min_price, + MAX(iws.max_price) AS max_price, + AVG(iws.avg_price) AS avg_price, + SUM(iws.total_volume) AS total_volume, + SUM(iws.total_quantity) AS total_quantity, + CURRENT_TIMESTAMP AS created_at, + CURRENT_TIMESTAMP AS updated_at + FROM item_weekly_statistics iws + INNER JOIN auction_history ah ON iws.item_name = ah.item_name + WHERE iws.week_start_date = DATE_SUB(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 7 DAY) + GROUP BY ah.item_sub_category, iws.year, iws.week_number, iws.week_start_date + ON DUPLICATE KEY UPDATE + min_price = VALUES(min_price), + max_price = VALUES(max_price), + avg_price = VALUES(avg_price), + total_volume = VALUES(total_volume), + total_quantity = VALUES(total_quantity), + updated_at = CURRENT_TIMESTAMP; + """, + nativeQuery = true) + void upsertWeeklyStatistics(); +} diff --git a/src/main/java/until/the/eternity/statistics/repository/weekly/TopCategoryWeeklyStatisticsRepository.java b/src/main/java/until/the/eternity/statistics/repository/weekly/TopCategoryWeeklyStatisticsRepository.java new file mode 100644 index 0000000..b6bb0dc --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/repository/weekly/TopCategoryWeeklyStatisticsRepository.java @@ -0,0 +1,57 @@ +package until.the.eternity.statistics.repository.weekly; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.statistics.domain.entity.weekly.TopCategoryWeeklyStatistics; + +public interface TopCategoryWeeklyStatisticsRepository + extends JpaRepository { + + /** 전주의 SubcategoryWeeklyStatistics 데이터를 기반으로 탑카테고리별 주간 통계를 집계하여 upsert */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Transactional + @Query( + value = + """ + INSERT INTO top_category_weekly_statistics ( + item_top_category, + year, + week_number, + week_start_date, + min_price, + max_price, + avg_price, + total_volume, + total_quantity, + created_at, + updated_at + ) + SELECT + ah.item_top_category, + sws.year, + sws.week_number, + sws.week_start_date, + MIN(sws.min_price) AS min_price, + MAX(sws.max_price) AS max_price, + AVG(sws.avg_price) AS avg_price, + SUM(sws.total_volume) AS total_volume, + SUM(sws.total_quantity) AS total_quantity, + CURRENT_TIMESTAMP AS created_at, + CURRENT_TIMESTAMP AS updated_at + FROM subcategory_weekly_statistics sws + INNER JOIN auction_history ah ON sws.item_sub_category = ah.item_sub_category + WHERE sws.week_start_date = DATE_SUB(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 7 DAY) + GROUP BY ah.item_top_category, sws.year, sws.week_number, sws.week_start_date + ON DUPLICATE KEY UPDATE + min_price = VALUES(min_price), + max_price = VALUES(max_price), + avg_price = VALUES(avg_price), + total_volume = VALUES(total_volume), + total_quantity = VALUES(total_quantity), + updated_at = CURRENT_TIMESTAMP; + """, + nativeQuery = true) + void upsertWeeklyStatistics(); +} diff --git a/src/main/java/until/the/eternity/statistics/service/DailyStatisticsScheduler.java b/src/main/java/until/the/eternity/statistics/service/DailyStatisticsScheduler.java new file mode 100644 index 0000000..7012fb1 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/service/DailyStatisticsScheduler.java @@ -0,0 +1,34 @@ +package until.the.eternity.statistics.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DailyStatisticsScheduler { + + private final DailyStatisticsService dailyStatisticsService; + + /** 매일 새벽 일간 통계 계산 및 저장 기본 cron: 매일 새벽 3시 (변경 가능) */ + @Scheduled(cron = "${statistics.daily.cron:5 0 3 * * *}", zone = "Asia/Seoul") + public void scheduleDailyStatistics() { + log.info("[Daily Statistics Scheduler] Starting scheduled task..."); + long start = System.currentTimeMillis(); + + try { + dailyStatisticsService.calculateAndSaveDailyStatistics(); + log.info( + "[Daily Statistics Scheduler] Scheduled task completed successfully in {} ms", + System.currentTimeMillis() - start); + } catch (Exception e) { + log.error( + "[Daily Statistics Scheduler] Error occurred during scheduled task: {}", + e.getMessage(), + e); + throw e; + } + } +} diff --git a/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java b/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java new file mode 100644 index 0000000..bdcc7b2 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java @@ -0,0 +1,57 @@ +package until.the.eternity.statistics.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.statistics.repository.daily.ItemDailyStatisticsRepository; +import until.the.eternity.statistics.repository.daily.SubcategoryDailyStatisticsRepository; +import until.the.eternity.statistics.repository.daily.TopCategoryDailyStatisticsRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DailyStatisticsService { + + private final ItemDailyStatisticsRepository itemDailyStatisticsRepository; + private final SubcategoryDailyStatisticsRepository subcategoryDailyStatisticsRepository; + private final TopCategoryDailyStatisticsRepository topCategoryDailyStatisticsRepository; + + /** + * 전날의 경매 거래 내역을 기반으로 일간 통계를 계산하여 저장 순서: auction_history → ItemDaily → SubcategoryDaily → + * TopCategoryDaily + */ + @Transactional + public void calculateAndSaveDailyStatistics() { + log.info("[Daily Statistics] Starting daily statistics calculation..."); + + long start = System.currentTimeMillis(); + + // 1. auction_history → ItemDailyStatistics + log.info("[Daily Statistics] Step 1/3: Calculating item daily statistics..."); + itemDailyStatisticsRepository.upsertDailyStatistics(); + log.info( + "[Daily Statistics] Step 1/3 completed in {} ms", + System.currentTimeMillis() - start); + + // 2. ItemDailyStatistics → SubcategoryDailyStatistics + log.info("[Daily Statistics] Step 2/3: Calculating subcategory daily statistics..."); + long step2Start = System.currentTimeMillis(); + subcategoryDailyStatisticsRepository.upsertDailyStatistics(); + log.info( + "[Daily Statistics] Step 2/3 completed in {} ms", + System.currentTimeMillis() - step2Start); + + // 3. SubcategoryDailyStatistics → TopCategoryDailyStatistics + log.info("[Daily Statistics] Step 3/3: Calculating top category daily statistics..."); + long step3Start = System.currentTimeMillis(); + topCategoryDailyStatisticsRepository.upsertDailyStatistics(); + log.info( + "[Daily Statistics] Step 3/3 completed in {} ms", + System.currentTimeMillis() - step3Start); + + log.info( + "[Daily Statistics] All daily statistics calculated successfully in {} ms", + System.currentTimeMillis() - start); + } +} diff --git a/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsScheduler.java b/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsScheduler.java new file mode 100644 index 0000000..5b65900 --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsScheduler.java @@ -0,0 +1,34 @@ +package until.the.eternity.statistics.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WeeklyStatisticsScheduler { + + private final WeeklyStatisticsService weeklyStatisticsService; + + /** 매주 월요일 새벽 주간 통계 계산 및 저장 (전주 데이터 집계) 기본 cron: 매주 월요일 새벽 4시 (변경 가능) */ + @Scheduled(cron = "${statistics.weekly.cron:5 0 4 * * MON}", zone = "Asia/Seoul") + public void scheduleWeeklyStatistics() { + log.info("[Weekly Statistics Scheduler] Starting scheduled task..."); + long start = System.currentTimeMillis(); + + try { + weeklyStatisticsService.calculateAndSaveWeeklyStatistics(); + log.info( + "[Weekly Statistics Scheduler] Scheduled task completed successfully in {} ms", + System.currentTimeMillis() - start); + } catch (Exception e) { + log.error( + "[Weekly Statistics Scheduler] Error occurred during scheduled task: {}", + e.getMessage(), + e); + throw e; + } + } +} diff --git a/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java b/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java new file mode 100644 index 0000000..5c5ca2b --- /dev/null +++ b/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java @@ -0,0 +1,57 @@ +package until.the.eternity.statistics.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.statistics.repository.weekly.ItemWeeklyStatisticsRepository; +import until.the.eternity.statistics.repository.weekly.SubcategoryWeeklyStatisticsRepository; +import until.the.eternity.statistics.repository.weekly.TopCategoryWeeklyStatisticsRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WeeklyStatisticsService { + + private final ItemWeeklyStatisticsRepository itemWeeklyStatisticsRepository; + private final SubcategoryWeeklyStatisticsRepository subcategoryWeeklyStatisticsRepository; + private final TopCategoryWeeklyStatisticsRepository topCategoryWeeklyStatisticsRepository; + + /** + * 전주(지난 주 월~일)의 일간 통계를 기반으로 주간 통계를 계산하여 저장 순서: ItemDaily → ItemWeekly → SubcategoryWeekly → + * TopCategoryWeekly + */ + @Transactional + public void calculateAndSaveWeeklyStatistics() { + log.info("[Weekly Statistics] Starting weekly statistics calculation..."); + + long start = System.currentTimeMillis(); + + // 1. ItemDailyStatistics → ItemWeeklyStatistics + log.info("[Weekly Statistics] Step 1/3: Calculating item weekly statistics..."); + itemWeeklyStatisticsRepository.upsertWeeklyStatistics(); + log.info( + "[Weekly Statistics] Step 1/3 completed in {} ms", + System.currentTimeMillis() - start); + + // 2. ItemWeeklyStatistics → SubcategoryWeeklyStatistics + log.info("[Weekly Statistics] Step 2/3: Calculating subcategory weekly statistics..."); + long step2Start = System.currentTimeMillis(); + subcategoryWeeklyStatisticsRepository.upsertWeeklyStatistics(); + log.info( + "[Weekly Statistics] Step 2/3 completed in {} ms", + System.currentTimeMillis() - step2Start); + + // 3. SubcategoryWeeklyStatistics → TopCategoryWeeklyStatistics + log.info("[Weekly Statistics] Step 3/3: Calculating top category weekly statistics..."); + long step3Start = System.currentTimeMillis(); + topCategoryWeeklyStatisticsRepository.upsertWeeklyStatistics(); + log.info( + "[Weekly Statistics] Step 3/3 completed in {} ms", + System.currentTimeMillis() - step3Start); + + log.info( + "[Weekly Statistics] All weekly statistics calculated successfully in {} ms", + System.currentTimeMillis() - start); + } +} diff --git a/src/main/resources/db/migration/V13__create_statistics_tables.sql b/src/main/resources/db/migration/V13__create_statistics_tables.sql new file mode 100644 index 0000000..eff3705 --- /dev/null +++ b/src/main/resources/db/migration/V13__create_statistics_tables.sql @@ -0,0 +1,126 @@ +-- ===================================================== +-- V13: 통계 테이블 생성 및 기존 min_price 테이블 삭제 +-- ===================================================== +-- 1. 기존 item_daily_min_price 테이블 삭제 +-- 2. 새로운 통계 테이블 6개 생성 +-- - Daily: Item, Subcategory, TopCategory +-- - Weekly: Item, Subcategory, TopCategory +-- ===================================================== + +-- 기존 item_daily_min_price 테이블 삭제 +DROP TABLE IF EXISTS item_daily_min_price; + +-- ===================================================== +-- Daily Statistics Tables +-- ===================================================== + +-- 아이템별 일간 통계 +-- TopCategory, SubCategory가 다른 ItemName이 존재함으로 유의 +CREATE TABLE item_daily_statistics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '고유 식별자', + item_name VARCHAR(255) NOT NULL COMMENT '아이템 이름', + top_category VARCHAR(255) NOT NULL COMMENT '탑 카테고리', + sub_categpry VARCHAR(255) NOT NULL COMMENT '서브 카테고리', + date_auction_buy DATE NOT NULL COMMENT '거래 일자', + min_price BIGINT NOT NULL COMMENT '최저 단가', + max_price BIGINT NOT NULL COMMENT '최고 단가', + avg_price DECIMAL(15, 2) NOT NULL COMMENT '평균 단가', + total_volume BIGINT NOT NULL COMMENT '거래 총량 (총 거래 금액)', + total_quantity BIGINT NOT NULL COMMENT '거래 수량 (itemCount 합계)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + UNIQUE KEY uk_item_daily_statistics_item_name_date (item_name, date_auction_buy), + INDEX idx_item_daily_statistics_item_name_date (item_name, date_auction_buy) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='아이템별 일간 통계'; + +-- 서브카테고리별 일간 통계 +CREATE TABLE subcategory_daily_statistics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '고유 식별자', + item_sub_category VARCHAR(255) NOT NULL COMMENT '아이템 서브 카테고리', + date_auction_buy DATE NOT NULL COMMENT '거래 일자', + min_price BIGINT NOT NULL COMMENT '최저 단가', + max_price BIGINT NOT NULL COMMENT '최고 단가', + avg_price DECIMAL(15, 2) NOT NULL COMMENT '평균 단가', + total_volume BIGINT NOT NULL COMMENT '거래 총량 (총 거래 금액)', + total_quantity BIGINT NOT NULL COMMENT '거래 수량 (itemCount 합계)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + UNIQUE KEY uk_subcategory_daily_statistics_category_date (item_sub_category, date_auction_buy), + INDEX idx_subcategory_daily_statistics_category_date (item_sub_category, date_auction_buy) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='서브카테고리별 일간 통계'; + +-- 탑카테고리별 일간 통계 +CREATE TABLE top_category_daily_statistics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '고유 식별자', + item_top_category VARCHAR(255) NOT NULL COMMENT '아이템 탑 카테고리', + date_auction_buy DATE NOT NULL COMMENT '거래 일자', + min_price BIGINT NOT NULL COMMENT '최저 단가', + max_price BIGINT NOT NULL COMMENT '최고 단가', + avg_price DECIMAL(15, 2) NOT NULL COMMENT '평균 단가', + total_volume BIGINT NOT NULL COMMENT '거래 총량 (총 거래 금액)', + total_quantity BIGINT NOT NULL COMMENT '거래 수량 (itemCount 합계)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + UNIQUE KEY uk_top_category_daily_statistics_category_date (item_top_category, date_auction_buy), + INDEX idx_top_category_daily_statistics_category_date (item_top_category, date_auction_buy) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='탑카테고리별 일간 통계'; + +-- ===================================================== +-- Weekly Statistics Tables +-- ===================================================== + +-- 아이템별 주간 통계 +CREATE TABLE item_weekly_statistics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '고유 식별자', + item_name VARCHAR(255) NOT NULL COMMENT '아이템 이름', + top_category VARCHAR(255) NOT NULL COMMENT '상위 카테고리', + sub_categpry VARCHAR(255) NOT NULL COMMENT '하위 카테고리', + year INT NOT NULL COMMENT '연도', + week_number INT NOT NULL COMMENT '주차 번호', + week_start_date DATE NOT NULL COMMENT '주 시작일 (월요일)', + min_price BIGINT NOT NULL COMMENT '최저 단가 (해당 주의 모든 거래 중 최저)', + max_price BIGINT NOT NULL COMMENT '최고 단가 (해당 주의 모든 거래 중 최고)', + avg_price DECIMAL(15, 2) NOT NULL COMMENT '평균 단가 (Daily 평균가의 평균)', + total_volume BIGINT NOT NULL COMMENT '거래 총량 (총 거래 금액)', + total_quantity BIGINT NOT NULL COMMENT '거래 수량 (itemCount 합계)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + UNIQUE KEY uk_item_weekly_statistics_item_name_year_week (item_name, year, week_number), + INDEX idx_item_weekly_statistics_item_name_year_week (item_name, year, week_number) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='아이템별 주간 통계'; + +-- 서브카테고리별 주간 통계 +CREATE TABLE subcategory_weekly_statistics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '고유 식별자', + item_sub_category VARCHAR(255) NOT NULL COMMENT '아이템 서브 카테고리', + year INT NOT NULL COMMENT '연도', + week_number INT NOT NULL COMMENT '주차 번호', + week_start_date DATE NOT NULL COMMENT '주 시작일 (월요일)', + min_price BIGINT NOT NULL COMMENT '최저 단가 (해당 주의 모든 거래 중 최저)', + max_price BIGINT NOT NULL COMMENT '최고 단가 (해당 주의 모든 거래 중 최고)', + avg_price DECIMAL(15, 2) NOT NULL COMMENT '평균 단가 (Daily 평균가의 평균)', + total_volume BIGINT NOT NULL COMMENT '거래 총량 (총 거래 금액)', + total_quantity BIGINT NOT NULL COMMENT '거래 수량 (itemCount 합계)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + UNIQUE KEY uk_subcategory_weekly_statistics_category_year_week (item_sub_category, year, week_number), + INDEX idx_subcategory_weekly_statistics_category_year_week (item_sub_category, year, week_number) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='서브카테고리별 주간 통계'; + +-- 탑카테고리별 주간 통계 +CREATE TABLE top_category_weekly_statistics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '고유 식별자', + item_top_category VARCHAR(255) NOT NULL COMMENT '아이템 탑 카테고리', + year INT NOT NULL COMMENT '연도', + week_number INT NOT NULL COMMENT '주차 번호', + week_start_date DATE NOT NULL COMMENT '주 시작일 (월요일)', + min_price BIGINT NOT NULL COMMENT '최저 단가 (해당 주의 모든 거래 중 최저)', + max_price BIGINT NOT NULL COMMENT '최고 단가 (해당 주의 모든 거래 중 최고)', + avg_price DECIMAL(15, 2) NOT NULL COMMENT '평균 단가 (Daily 평균가의 평균)', + total_volume BIGINT NOT NULL COMMENT '거래 총량 (총 거래 금액)', + total_quantity BIGINT NOT NULL COMMENT '거래 수량 (itemCount 합계)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + UNIQUE KEY uk_top_category_weekly_statistics_category_year_week (item_top_category, year, week_number), + INDEX idx_top_category_weekly_statistics_category_year_week (item_top_category, year, week_number) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='탑카테고리별 주간 통계'; diff --git a/src/test/java/until/the/eternity/statistics/application/service/ItemDailyStatisticsServiceTest.java b/src/test/java/until/the/eternity/statistics/application/service/ItemDailyStatisticsServiceTest.java new file mode 100644 index 0000000..d9c3cbc --- /dev/null +++ b/src/test/java/until/the/eternity/statistics/application/service/ItemDailyStatisticsServiceTest.java @@ -0,0 +1,122 @@ +package until.the.eternity.statistics.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.domain.entity.daily.ItemDailyStatistics; +import until.the.eternity.statistics.domain.mapper.ItemDailyStatisticsMapper; +import until.the.eternity.statistics.interfaces.rest.dto.response.ItemDailyStatisticsResponse; +import until.the.eternity.statistics.repository.daily.ItemDailyStatisticsRepository; + +@ExtendWith(MockitoExtension.class) +class ItemDailyStatisticsServiceTest { + + @Mock private ItemDailyStatisticsRepository repository; + @Mock private ItemDailyStatisticsMapper mapper; + + @InjectMocks private ItemDailyStatisticsService service; + + @Test + @DisplayName("findAll은 페이징된 통계 목록을 반환한다") + void findAll_should_return_paged_statistics() { + // given + Pageable pageable = PageRequest.of(0, 10); + ItemDailyStatistics entity = createMockEntity(); + ItemDailyStatisticsResponse response = createMockResponse(); + Page entityPage = new PageImpl<>(List.of(entity), pageable, 1); + + when(repository.findAll(pageable)).thenReturn(entityPage); + when(mapper.toDto(entity)).thenReturn(response); + + // when + PageResponseDto result = service.findAll(pageable); + + // then + assertThat(result.items()).hasSize(1).contains(response); + assertThat(result.meta().totalElements()).isEqualTo(1); + verify(repository).findAll(pageable); + verify(mapper).toDto(entity); + } + + @Test + @DisplayName("findById는 ID에 해당하는 통계를 반환한다") + void findById_should_return_statistics_when_exists() { + // given + Long id = 1L; + ItemDailyStatistics entity = createMockEntity(); + ItemDailyStatisticsResponse response = createMockResponse(); + + when(repository.findById(id)).thenReturn(Optional.of(entity)); + when(mapper.toDto(entity)).thenReturn(response); + + // when + ItemDailyStatisticsResponse result = service.findById(id); + + // then + assertThat(result).isEqualTo(response); + verify(repository).findById(id); + verify(mapper).toDto(entity); + } + + @Test + @DisplayName("findById는 데이터가 없으면 예외를 발생시킨다") + void findById_should_throw_exception_when_not_exists() { + // given + Long id = 999L; + when(repository.findById(id)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.findById(id)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ItemDailyStatistics not found"); + + verify(repository).findById(id); + verify(mapper, never()).toDto(any()); + } + + private ItemDailyStatistics createMockEntity() { + return ItemDailyStatistics.builder() + .id(1L) + .itemName("Test Item") + .dateAuctionBuy(LocalDate.of(2025, 7, 1)) + .minPrice(100000L) + .maxPrice(150000L) + .avgPrice(new BigDecimal("125000.00")) + .totalVolume(5000000L) + .totalQuantity(100L) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } + + private ItemDailyStatisticsResponse createMockResponse() { + return new ItemDailyStatisticsResponse( + 1L, + "Test Item", + LocalDate.of(2025, 7, 1), + 100000L, + 150000L, + new BigDecimal("125000.00"), + 5000000L, + 100L, + LocalDateTime.now(), + LocalDateTime.now()); + } +} diff --git a/src/test/java/until/the/eternity/statistics/application/service/ItemWeeklyStatisticsServiceTest.java b/src/test/java/until/the/eternity/statistics/application/service/ItemWeeklyStatisticsServiceTest.java new file mode 100644 index 0000000..b7067b2 --- /dev/null +++ b/src/test/java/until/the/eternity/statistics/application/service/ItemWeeklyStatisticsServiceTest.java @@ -0,0 +1,98 @@ +package until.the.eternity.statistics.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.domain.entity.weekly.ItemWeeklyStatistics; +import until.the.eternity.statistics.domain.mapper.ItemWeeklyStatisticsMapper; +import until.the.eternity.statistics.interfaces.rest.dto.response.ItemWeeklyStatisticsResponse; +import until.the.eternity.statistics.repository.weekly.ItemWeeklyStatisticsRepository; + +@ExtendWith(MockitoExtension.class) +class ItemWeeklyStatisticsServiceTest { + + @Mock private ItemWeeklyStatisticsRepository repository; + @Mock private ItemWeeklyStatisticsMapper mapper; + + @InjectMocks private ItemWeeklyStatisticsService service; + + @Test + @DisplayName("findAll은 페이징된 주간 통계 목록을 반환한다") + void findAll_should_return_paged_statistics() { + // given + Pageable pageable = PageRequest.of(0, 10); + ItemWeeklyStatistics entity = + ItemWeeklyStatistics.builder() + .id(1L) + .itemName("Test Item") + .year(2025) + .weekNumber(27) + .weekStartDate(LocalDate.of(2025, 7, 1)) + .minPrice(100000L) + .maxPrice(150000L) + .avgPrice(new BigDecimal("125000.00")) + .totalVolume(35000000L) + .totalQuantity(700L) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + ItemWeeklyStatisticsResponse response = + new ItemWeeklyStatisticsResponse( + 1L, + "Test Item", + 2025, + 27, + LocalDate.of(2025, 7, 1), + 100000L, + 150000L, + new BigDecimal("125000.00"), + 35000000L, + 700L, + LocalDateTime.now(), + LocalDateTime.now()); + + Page entityPage = new PageImpl<>(List.of(entity), pageable, 1); + + when(repository.findAll(pageable)).thenReturn(entityPage); + when(mapper.toDto(entity)).thenReturn(response); + + // when + PageResponseDto result = service.findAll(pageable); + + // then + assertThat(result.items()).hasSize(1).contains(response); + verify(repository).findAll(pageable); + } + + @Test + @DisplayName("findById는 데이터가 없으면 예외를 발생시킨다") + void findById_should_throw_exception_when_not_exists() { + // given + Long id = 999L; + when(repository.findById(id)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.findById(id)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ItemWeeklyStatistics not found"); + } +} diff --git a/src/test/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsServiceTest.java b/src/test/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsServiceTest.java new file mode 100644 index 0000000..9eff9c8 --- /dev/null +++ b/src/test/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsServiceTest.java @@ -0,0 +1,94 @@ +package until.the.eternity.statistics.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.statistics.domain.entity.daily.SubcategoryDailyStatistics; +import until.the.eternity.statistics.domain.mapper.SubcategoryDailyStatisticsMapper; +import until.the.eternity.statistics.interfaces.rest.dto.response.SubcategoryDailyStatisticsResponse; +import until.the.eternity.statistics.repository.daily.SubcategoryDailyStatisticsRepository; + +@ExtendWith(MockitoExtension.class) +class SubcategoryDailyStatisticsServiceTest { + + @Mock private SubcategoryDailyStatisticsRepository repository; + @Mock private SubcategoryDailyStatisticsMapper mapper; + + @InjectMocks private SubcategoryDailyStatisticsService service; + + @Test + @DisplayName("findAll은 페이징된 통계 목록을 반환한다") + void findAll_should_return_paged_statistics() { + // given + Pageable pageable = PageRequest.of(0, 10); + SubcategoryDailyStatistics entity = + SubcategoryDailyStatistics.builder() + .id(1L) + .itemSubCategory("한손검") + .dateAuctionBuy(LocalDate.of(2025, 7, 1)) + .minPrice(100000L) + .maxPrice(150000L) + .avgPrice(new BigDecimal("125000.00")) + .totalVolume(50000000L) + .totalQuantity(1000L) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + SubcategoryDailyStatisticsResponse response = + new SubcategoryDailyStatisticsResponse( + 1L, + "한손검", + LocalDate.of(2025, 7, 1), + 100000L, + 150000L, + new BigDecimal("125000.00"), + 50000000L, + 1000L, + LocalDateTime.now(), + LocalDateTime.now()); + + Page entityPage = new PageImpl<>(List.of(entity), pageable, 1); + + when(repository.findAll(pageable)).thenReturn(entityPage); + when(mapper.toDto(entity)).thenReturn(response); + + // when + PageResponseDto result = service.findAll(pageable); + + // then + assertThat(result.items()).hasSize(1).contains(response); + verify(repository).findAll(pageable); + } + + @Test + @DisplayName("findById는 데이터가 없으면 예외를 발생시킨다") + void findById_should_throw_exception_when_not_exists() { + // given + Long id = 999L; + when(repository.findById(id)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.findById(id)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SubcategoryDailyStatistics not found"); + } +} diff --git a/src/test/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsServiceTest.java b/src/test/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsServiceTest.java new file mode 100644 index 0000000..889913b --- /dev/null +++ b/src/test/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsServiceTest.java @@ -0,0 +1,36 @@ +package until.the.eternity.statistics.application.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import until.the.eternity.statistics.domain.mapper.SubcategoryWeeklyStatisticsMapper; +import until.the.eternity.statistics.repository.weekly.SubcategoryWeeklyStatisticsRepository; + +@ExtendWith(MockitoExtension.class) +class SubcategoryWeeklyStatisticsServiceTest { + + @Mock private SubcategoryWeeklyStatisticsRepository repository; + @Mock private SubcategoryWeeklyStatisticsMapper mapper; + + @InjectMocks private SubcategoryWeeklyStatisticsService service; + + @Test + @DisplayName("findById는 데이터가 없으면 예외를 발생시킨다") + void findById_should_throw_exception_when_not_exists() { + // given + Long id = 999L; + when(repository.findById(id)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.findById(id)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SubcategoryWeeklyStatistics not found"); + } +} diff --git a/src/test/java/until/the/eternity/statistics/application/service/TopCategoryDailyStatisticsServiceTest.java b/src/test/java/until/the/eternity/statistics/application/service/TopCategoryDailyStatisticsServiceTest.java new file mode 100644 index 0000000..1e1130c --- /dev/null +++ b/src/test/java/until/the/eternity/statistics/application/service/TopCategoryDailyStatisticsServiceTest.java @@ -0,0 +1,36 @@ +package until.the.eternity.statistics.application.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import until.the.eternity.statistics.domain.mapper.TopCategoryDailyStatisticsMapper; +import until.the.eternity.statistics.repository.daily.TopCategoryDailyStatisticsRepository; + +@ExtendWith(MockitoExtension.class) +class TopCategoryDailyStatisticsServiceTest { + + @Mock private TopCategoryDailyStatisticsRepository repository; + @Mock private TopCategoryDailyStatisticsMapper mapper; + + @InjectMocks private TopCategoryDailyStatisticsService service; + + @Test + @DisplayName("findById는 데이터가 없으면 예외를 발생시킨다") + void findById_should_throw_exception_when_not_exists() { + // given + Long id = 999L; + when(repository.findById(id)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.findById(id)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TopCategoryDailyStatistics not found"); + } +} diff --git a/src/test/java/until/the/eternity/statistics/application/service/TopCategoryWeeklyStatisticsServiceTest.java b/src/test/java/until/the/eternity/statistics/application/service/TopCategoryWeeklyStatisticsServiceTest.java new file mode 100644 index 0000000..1a425c5 --- /dev/null +++ b/src/test/java/until/the/eternity/statistics/application/service/TopCategoryWeeklyStatisticsServiceTest.java @@ -0,0 +1,36 @@ +package until.the.eternity.statistics.application.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import until.the.eternity.statistics.domain.mapper.TopCategoryWeeklyStatisticsMapper; +import until.the.eternity.statistics.repository.weekly.TopCategoryWeeklyStatisticsRepository; + +@ExtendWith(MockitoExtension.class) +class TopCategoryWeeklyStatisticsServiceTest { + + @Mock private TopCategoryWeeklyStatisticsRepository repository; + @Mock private TopCategoryWeeklyStatisticsMapper mapper; + + @InjectMocks private TopCategoryWeeklyStatisticsService service; + + @Test + @DisplayName("findById는 데이터가 없으면 예외를 발생시킨다") + void findById_should_throw_exception_when_not_exists() { + // given + Long id = 999L; + when(repository.findById(id)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.findById(id)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TopCategoryWeeklyStatistics not found"); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..d8f0f51 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,44 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;MODE=MySQL;DATABASE_TO_LOWER=TRUE + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + flyway: + enabled: false + +# Override the custom logback configuration for tests +logging: + config: classpath:logback-test.xml + level: + root: INFO + until.the.eternity: DEBUG + +# Required configuration properties for tests +openapi: + nexon: + base-url: https://test.api.nexon.com + api-key: test_api_key + api-key-sub: test_api_key_sub + max-in-memory-size-mb: 5 + default-timeout-seconds: 5 + auction-history: + delay-ms: 1000 + cron: '0 0 */4 * * *' + +statistics: + daily: + cron: '0 0 3 * * *' + weekly: + cron: '0 0 4 * * MON' + +jwt: + secret-key: test_secret_key_for_testing_purposes_only + access-token-validity: 3600 + refresh-token-validity: 86400 diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..377662c --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,13 @@ + + + + + [%d{yyyy-MM-dd HH:mm:ss.SSS}] [%5p] [%15.15thread] [%-40.40logger{39}] - %m%n + utf8 + + + + + + +