diff --git a/build.gradle b/build.gradle index 08ff0e8..a3d6214 100644 --- a/build.gradle +++ b/build.gradle @@ -68,6 +68,9 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation 'com.h2database:h2' + // MongoDB + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + // firebase implementation 'com.google.firebase:firebase-admin:9.1.0' } diff --git a/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java b/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java index 2cf51ed..76f82d1 100644 --- a/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java +++ b/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java @@ -1,12 +1,26 @@ package org.withtime.be.withtimebe; +import java.util.TimeZone; + import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableScheduling; +import jakarta.annotation.PostConstruct; + + @EnableScheduling @EnableJpaAuditing +@EnableJpaRepositories( + basePackages = {"org.withtime.be"}, + excludeFilters = @ComponentScan.Filter( + type = FilterType.REGEX, + pattern = "org\\.withtime\\.be\\.withtimebe\\.domain\\.log\\..*" + )) @SpringBootApplication public class WithTimeBeApplication { @@ -14,4 +28,6 @@ public static void main(String[] args) { SpringApplication.run(WithTimeBeApplication.class, args); } + @PostConstruct + public void init() { TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); } // JVM 기본 TimeZone 설정 } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepository.java new file mode 100644 index 0000000..adeb353 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepository.java @@ -0,0 +1,11 @@ +package org.withtime.be.withtimebe.domain.date.repository; + +import java.time.LocalDate; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.date.entity.DateCourse; + +public interface DateCourseRepository extends JpaRepository { + Long countByCreatedAtBetween(LocalDate startTime, LocalDate endTime); + Long countByMemberId(Long memberId); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DatePlaceDateCourseRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DatePlaceDateCourseRepository.java new file mode 100644 index 0000000..c6dd9b8 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DatePlaceDateCourseRepository.java @@ -0,0 +1,16 @@ +package org.withtime.be.withtimebe.domain.date.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.withtime.be.withtimebe.domain.date.entity.DatePlaceDateCourse; + +public interface DatePlaceDateCourseRepository extends JpaRepository { + @Query(""" + SELECT COUNT(dpc) + FROM DatePlaceDateCourse dpc + JOIN dpc.dateCourse dc + WHERE dc.member.id = :memberId + """) + Long countByCreatorMemberId(@Param("memberId") Long memberId); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DatePlaceRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DatePlaceRepository.java new file mode 100644 index 0000000..d6d9a9b --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DatePlaceRepository.java @@ -0,0 +1,7 @@ +package org.withtime.be.withtimebe.domain.date.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.date.entity.DatePlace; + +public interface DatePlaceRepository extends JpaRepository { +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/repository/PlaceCategoryRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/PlaceCategoryRepository.java new file mode 100644 index 0000000..135ae95 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/PlaceCategoryRepository.java @@ -0,0 +1,7 @@ +package org.withtime.be.withtimebe.domain.date.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.date.entity.PlaceCategory; + +public interface PlaceCategoryRepository extends JpaRepository { +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/controller/DateCourseLogQueryController.java b/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/controller/DateCourseLogQueryController.java new file mode 100644 index 0000000..9656523 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/controller/DateCourseLogQueryController.java @@ -0,0 +1,48 @@ +package org.withtime.be.withtimebe.domain.log.datecourselog.controller; + +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.withtime.be.withtimebe.domain.date.repository.DateCourseRepository; +import org.withtime.be.withtimebe.domain.log.datecourselog.dto.DateCourseLogResponseDTO; +import org.withtime.be.withtimebe.domain.log.datecourselog.service.query.DateCourseLogQueryService; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.global.security.annotation.AuthenticatedMember; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/logs/datecourses") +public class DateCourseLogQueryController { + + private final DateCourseLogQueryService dateCourseLogQueryService; + + @Operation(summary = "최근 1개월 WithTime 사용자의 데이트 평균 횟수와 나의 데이트 횟수 조회 API by 피우", description = "메인 페이지의 데이트 나침반에 해당하는 API입니다. 최근 1개월 WithTime 사용자의 데이트 평균 횟수와 나의 데이트 횟수 조회하는 API입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다.") + }) + @GetMapping("/average") + public DefaultResponse findAverageDateCourseCount( + @AuthenticatedMember Member member + ) { + DateCourseLogResponseDTO.FindAverageDateCourseCount response = dateCourseLogQueryService.findAverageDateCourseCount(member); + return DefaultResponse.ok(response); + } + + @Operation(summary = "다른 사람의 내 데이트 코스 저장 횟수 조회 API by 피우", description = "나의 데이트 코스를 다른 사람이 얼마나 저장했는지 조회하는 API입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다.") + }) + @GetMapping("/saved-count") + public DefaultResponse findSavedDateCourseCount( + @AuthenticatedMember Member member + ) { + DateCourseLogResponseDTO.FindSavedDateCourseCount response = dateCourseLogQueryService.findSavedDateCourseCount(member); + return DefaultResponse.ok(response); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/converter/DateCourseLogConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/converter/DateCourseLogConverter.java new file mode 100644 index 0000000..4fc4ba7 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/converter/DateCourseLogConverter.java @@ -0,0 +1,19 @@ +package org.withtime.be.withtimebe.domain.log.datecourselog.converter; + +import org.withtime.be.withtimebe.domain.log.datecourselog.dto.DateCourseLogResponseDTO; + +public class DateCourseLogConverter { + + public static DateCourseLogResponseDTO.FindAverageDateCourseCount toFindAverageDateCourseCount(Double averageDateCount, Long myDateCount) { + return DateCourseLogResponseDTO.FindAverageDateCourseCount.builder() + .averageDateCount(averageDateCount) + .myDateCount(myDateCount) + .build(); + } + + public static DateCourseLogResponseDTO.FindSavedDateCourseCount toFindSavedDateCourseCount(Long count) { + return DateCourseLogResponseDTO.FindSavedDateCourseCount.builder() + .count(count) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/dto/DateCourseLogResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/dto/DateCourseLogResponseDTO.java new file mode 100644 index 0000000..ec8c9cb --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/dto/DateCourseLogResponseDTO.java @@ -0,0 +1,17 @@ +package org.withtime.be.withtimebe.domain.log.datecourselog.dto; + +import lombok.Builder; + +public class DateCourseLogResponseDTO { + + @Builder + public record FindAverageDateCourseCount( + Double averageDateCount, + Long myDateCount + ) {} + + @Builder + public record FindSavedDateCourseCount( + Long count + ) {} +} \ No newline at end of file diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/service/query/DateCourseLogQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/service/query/DateCourseLogQueryService.java new file mode 100644 index 0000000..64c0f38 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/service/query/DateCourseLogQueryService.java @@ -0,0 +1,9 @@ +package org.withtime.be.withtimebe.domain.log.datecourselog.service.query; + +import org.withtime.be.withtimebe.domain.log.datecourselog.dto.DateCourseLogResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +public interface DateCourseLogQueryService { + DateCourseLogResponseDTO.FindAverageDateCourseCount findAverageDateCourseCount(Member member); + DateCourseLogResponseDTO.FindSavedDateCourseCount findSavedDateCourseCount(Member member); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/service/query/DateCourseLogQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/service/query/DateCourseLogQueryServiceImpl.java new file mode 100644 index 0000000..a721d4f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/datecourselog/service/query/DateCourseLogQueryServiceImpl.java @@ -0,0 +1,52 @@ +package org.withtime.be.withtimebe.domain.log.datecourselog.service.query; + +import java.time.LocalDate; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.date.repository.DateCourseRepository; +import org.withtime.be.withtimebe.domain.date.repository.DatePlaceDateCourseRepository; +import org.withtime.be.withtimebe.domain.log.datecourselog.converter.DateCourseLogConverter; +import org.withtime.be.withtimebe.domain.log.datecourselog.dto.DateCourseLogResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DateCourseLogQueryServiceImpl implements DateCourseLogQueryService { + + private final DateCourseRepository dateCourseRepository; + private final MemberRepository memberRepository; + private final DatePlaceDateCourseRepository datePlaceDateCourseRepository; + + @Override + public DateCourseLogResponseDTO.FindAverageDateCourseCount findAverageDateCourseCount(Member member) { + + LocalDate now = LocalDate.now(); + LocalDate oneMonthAgo = now.minusDays(30); + + // 최근 1개월동안 생성된 데이트 코스 + Long dateCourseCount = dateCourseRepository.countByCreatedAtBetween(oneMonthAgo, now); + + // 전체 멤버 수 + Long memberCount = memberRepository.count(); + + // 평균 데이트 횟수 + Double averageDateCount = (double) dateCourseCount / memberCount; + averageDateCount = Math.round(averageDateCount * 10.0) / 10.0; // 첫째 자리까지 + + // 나의 데이트 횟수 + Long myDateCount = (member == null) ? 0L : dateCourseRepository.countByMemberId(member.getId()); + + return DateCourseLogConverter.toFindAverageDateCourseCount(averageDateCount, myDateCount); + } + + @Override + public DateCourseLogResponseDTO.FindSavedDateCourseCount findSavedDateCourseCount(Member member) { + Long savedCount = (member == null) ? 0L : datePlaceDateCourseRepository.countByCreatorMemberId(member.getId()); + return DateCourseLogConverter.toFindSavedDateCourseCount(savedCount); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/controller/DatePlaceLogQueryController.java b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/controller/DatePlaceLogQueryController.java new file mode 100644 index 0000000..c3a1c1d --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/controller/DatePlaceLogQueryController.java @@ -0,0 +1,36 @@ +package org.withtime.be.withtimebe.domain.log.dateplacelog.controller; + +import java.util.List; + +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.withtime.be.withtimebe.domain.log.dateplacelog.converter.DatePlaceLogConverter; +import org.withtime.be.withtimebe.domain.log.dateplacelog.dto.DatePlaceLogResponseDTO; +import org.withtime.be.withtimebe.domain.log.dateplacelog.entity.DatePlaceLog; +import org.withtime.be.withtimebe.domain.log.dateplacelog.service.query.DatePlaceLogQueryService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/logs/dateplaces") +public class DatePlaceLogQueryController { + + private final DatePlaceLogQueryService datePlaceLogQueryService; + + @Operation(summary = "WithTime에 등록된 월별 데이트 장소 수 조회 API by 피우", description = "WithTime에 등록된 월별 데이트 장소를 조회하는 API입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다.") + }) + @GetMapping("/monthly") + public DefaultResponse findMonthlyDatePlaceLogList() { + List result = datePlaceLogQueryService.findMonthlyDatePlaceLogList(); + DatePlaceLogResponseDTO.MonthlyDatePlaceLogList response = DatePlaceLogConverter.toMonthlyDatePlaceLogList(result); + return DefaultResponse.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/converter/DatePlaceLogConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/converter/DatePlaceLogConverter.java new file mode 100644 index 0000000..046ada7 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/converter/DatePlaceLogConverter.java @@ -0,0 +1,41 @@ +package org.withtime.be.withtimebe.domain.log.dateplacelog.converter; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import org.withtime.be.withtimebe.domain.log.dateplacelog.dto.DatePlaceLogResponseDTO; +import org.withtime.be.withtimebe.domain.log.dateplacelog.entity.DatePlaceLog; + +public class DatePlaceLogConverter { + + public static DatePlaceLogResponseDTO.MonthlyDatePlaceLogList toMonthlyDatePlaceLogList(List datePlaceLogs) { + + List datePlaceLogList = datePlaceLogs.stream() + .map(DatePlaceLogConverter::toMonthlyDatePlaceLog) + .toList(); + + return DatePlaceLogResponseDTO.MonthlyDatePlaceLogList.builder() + .datePlaceLogList(datePlaceLogList) + .build(); + } + + public static DatePlaceLogResponseDTO.MonthlyDatePlaceLog toMonthlyDatePlaceLog(DatePlaceLog datePlaceLog) { + + Long year = (long)datePlaceLog.getDate().getYear(); + Long month = (long)datePlaceLog.getDate().getMonthValue(); + + return DatePlaceLogResponseDTO.MonthlyDatePlaceLog.builder() + .year(year) + .month(month) + .count(datePlaceLog.getCount()) + .build(); + } + + public static DatePlaceLog toDatePlaceLog(LocalDate date, Long count) { + return DatePlaceLog.builder() + .date(date) + .count(count) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/dto/DatePlaceLogResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/dto/DatePlaceLogResponseDTO.java new file mode 100644 index 0000000..43cd32e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/dto/DatePlaceLogResponseDTO.java @@ -0,0 +1,20 @@ +package org.withtime.be.withtimebe.domain.log.dateplacelog.dto; + +import java.util.List; + +import lombok.Builder; + +public class DatePlaceLogResponseDTO { + + @Builder + public record MonthlyDatePlaceLogList( + List datePlaceLogList + ) {} + + @Builder + public record MonthlyDatePlaceLog( + Long year, + Long month, + Long count + ) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/entity/DatePlaceLog.java b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/entity/DatePlaceLog.java new file mode 100644 index 0000000..c18530a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/entity/DatePlaceLog.java @@ -0,0 +1,27 @@ +package org.withtime.be.withtimebe.domain.log.dateplacelog.entity; + +import java.time.LocalDate; + +import org.springframework.data.mongodb.core.mapping.Document; +import org.withtime.be.withtimebe.global.common.BaseEntity; + +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Document(collection = "date_place_logs") +public class DatePlaceLog extends BaseEntity { + + @Id + private String id; + + private LocalDate date; + private Long count; +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/repository/DatePlaceLogRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/repository/DatePlaceLogRepository.java new file mode 100644 index 0000000..ca92174 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/repository/DatePlaceLogRepository.java @@ -0,0 +1,7 @@ +package org.withtime.be.withtimebe.domain.log.dateplacelog.repository; + +import org.springframework.data.mongodb.repository.MongoRepository; +import org.withtime.be.withtimebe.domain.log.dateplacelog.entity.DatePlaceLog; + +public interface DatePlaceLogRepository extends MongoRepository { +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/scheduler/DatePlaceLogScheduler.java b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/scheduler/DatePlaceLogScheduler.java new file mode 100644 index 0000000..75911f1 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/scheduler/DatePlaceLogScheduler.java @@ -0,0 +1,39 @@ +package org.withtime.be.withtimebe.domain.log.dateplacelog.scheduler; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.date.repository.DatePlaceRepository; +import org.withtime.be.withtimebe.domain.log.dateplacelog.converter.DatePlaceLogConverter; +import org.withtime.be.withtimebe.domain.log.dateplacelog.entity.DatePlaceLog; +import org.withtime.be.withtimebe.domain.log.dateplacelog.repository.DatePlaceLogRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "scheduler.logs.date-place.enabled", havingValue = "true") +public class DatePlaceLogScheduler { + + private final DatePlaceRepository datePlaceRepository; + private final DatePlaceLogRepository datePlaceLogRepository; + + @Scheduled(cron = "${scheduler.logs.date-place.sync-cron}") + @Transactional(readOnly = true) + public void syncPlaceCategoryLogsToDB() { + + LocalDate now = LocalDate.from(LocalDateTime.now().minusMinutes(1)); + Long count = datePlaceRepository.count(); + + DatePlaceLog datePlaceLog = DatePlaceLogConverter.toDatePlaceLog(now, count); + datePlaceLogRepository.save(datePlaceLog); + + log.info("[DatePlaceLogScheduler] {} - 누적 데이트 장소 {}건 저장 완료", now, count); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/service/query/DatePlaceLogQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/service/query/DatePlaceLogQueryService.java new file mode 100644 index 0000000..80d1f8e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/service/query/DatePlaceLogQueryService.java @@ -0,0 +1,9 @@ +package org.withtime.be.withtimebe.domain.log.dateplacelog.service.query; + +import java.util.List; + +import org.withtime.be.withtimebe.domain.log.dateplacelog.entity.DatePlaceLog; + +public interface DatePlaceLogQueryService { + List findMonthlyDatePlaceLogList(); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/service/query/DatePlaceLogQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/service/query/DatePlaceLogQueryServiceImpl.java new file mode 100644 index 0000000..372fa40 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/dateplacelog/service/query/DatePlaceLogQueryServiceImpl.java @@ -0,0 +1,63 @@ +package org.withtime.be.withtimebe.domain.log.dateplacelog.service.query; + +import java.util.List; + +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.aggregation.GroupOperation; +import org.springframework.data.mongodb.core.aggregation.ProjectionOperation; +import org.springframework.data.mongodb.core.aggregation.SortOperation; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.log.dateplacelog.entity.DatePlaceLog; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DatePlaceLogQueryServiceImpl implements DatePlaceLogQueryService { + + private final MongoTemplate mongoTemplate; + + @Override + public List findMonthlyDatePlaceLogList() { + + // 1. 추출할 필드 정의 + ProjectionOperation projectToMonth = Aggregation.project() + .andExpression("date").as("date") + .andExpression("count").as("count") + .andExpression("year(date)").as("year") + .andExpression("month(date)").as("month"); + + // 2. date 기준 내림차순 정렬 + SortOperation sortByDateDesc = Aggregation.sort(Sort.Direction.DESC, "date"); + + // 3. 월별 groupBy, 이후 최신 다큐먼트 하나 선택 + GroupOperation groupByYearMonth = Aggregation.group("year", "month") + .first("date").as("date") + .first("count").as("count"); + + // 4. 결과 월별 오름차순 정렬 + SortOperation sortByYearMonthAsc = Aggregation.sort(Sort.by(Sort.Direction.ASC, "_id.year", "_id.month")); + + // 5. 파이프라인 생성 + Aggregation aggregation = Aggregation.newAggregation( + projectToMonth, + sortByDateDesc, + groupByYearMonth, + sortByYearMonthAsc + ); + + AggregationResults results = + mongoTemplate.aggregate( + aggregation, + "date_place_logs", + DatePlaceLog.class + ); + + return results.getMappedResults(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/annotation/LogPlaceCategory.java b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/annotation/LogPlaceCategory.java new file mode 100644 index 0000000..a9b461d --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/annotation/LogPlaceCategory.java @@ -0,0 +1,13 @@ +package org.withtime.be.withtimebe.domain.log.placecategorylog.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface LogPlaceCategory { +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/aop/LogPlaceCategoryAspect.java b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/aop/LogPlaceCategoryAspect.java new file mode 100644 index 0000000..4937c5b --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/aop/LogPlaceCategoryAspect.java @@ -0,0 +1,118 @@ +package org.withtime.be.withtimebe.domain.log.placecategorylog.aop; + +import java.lang.reflect.Field; +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class LogPlaceCategoryAspect { + + private final RedisTemplate redisTemplate; + + @Before("@annotation(org.withtime.be.withtimebe.domain.log.placecategorylog.annotation.LogPlaceCategory)") + public void logPlaceCategory(JoinPoint joinPoint) { + + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String[] paramNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + List placeCategoryIds = IntStream.range(0, args.length) + .mapToObj(i -> extractIdsFromParam(paramNames[i], args[i])) + .flatMap(Collection::stream) + .toList(); + + savePlaceCategoryIds(placeCategoryIds); + } + + // 1. 파라미터로부터 placeCategoryId 추출 + private List extractIdsFromParam(String paramName, Object arg) { + + if (paramName.contains("placeCategoryId") && arg instanceof Long id) { + return List.of(id); + } + + if (paramName.contains("placeCategoryId") && arg instanceof List list) { + return list.stream() + .filter(Long.class::isInstance) + .map(Long.class::cast) + .toList(); + } + + // DTO로 간주하고 추출 시도 + return extractIdsFromDTO(arg); + } + + // 2. DTO로부터 placeCategoryId 추출 + private List extractIdsFromDTO(Object dto) { + + if (dto == null) return Collections.emptyList(); + List ids = new ArrayList<>(); + + // DTO에 정의된 필드에 접근 + for (Field field : dto.getClass().getDeclaredFields()) { + if (!field.getName().contains("placeCategoryId")) continue; + + field.setAccessible(true); // private 필드 접근 설정 + try { + Object value = field.get(dto); // 값 추출 + if (value instanceof Long placeCategoryId) { + ids.add(placeCategoryId); + } + else if (value instanceof List list) { + for (Object id : list) { + if (id instanceof Long placeCategoryId) { + ids.add(placeCategoryId); + } + } + } + } catch (Exception e) { + log.warn("DTO에서 placeCategoryId 추출 실패"); + } + } + return ids; + } + + private void savePlaceCategoryIds(List placeCategoryIds) { + if (placeCategoryIds.isEmpty()) return; + + LocalDateTime now = LocalDateTime.now(); + String today = now.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String redisKey = "log:place-category:" + today; + + // ZSET - 카테고리 별 검색 횟수 기록 + placeCategoryIds + .forEach(id -> redisTemplate.opsForZSet().incrementScore(redisKey, id, 1)); + + // TTL - 이번 주까지로 설정 + Long expire = redisTemplate.getExpire(redisKey, TimeUnit.SECONDS); + if (expire == null || expire <= 0) { + LocalDateTime endOfWeek = now.with(DayOfWeek.SUNDAY).with(LocalTime.MAX); + Duration duration = Duration.between(now, endOfWeek); + redisTemplate.expire(redisKey, duration.getSeconds(), TimeUnit.SECONDS); + } + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/controller/PlaceCategoryLogQueryController.java b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/controller/PlaceCategoryLogQueryController.java new file mode 100644 index 0000000..f3435a5 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/controller/PlaceCategoryLogQueryController.java @@ -0,0 +1,36 @@ +package org.withtime.be.withtimebe.domain.log.placecategorylog.controller; + +import java.util.List; + +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.withtime.be.withtimebe.domain.log.placecategorylog.converter.PlaceCategoryLogConverter; +import org.withtime.be.withtimebe.domain.log.placecategorylog.dto.PlaceCategoryLogResponseDTO; +import org.withtime.be.withtimebe.domain.log.placecategorylog.model.PlaceCategoryLog; +import org.withtime.be.withtimebe.domain.log.placecategorylog.service.query.PlaceCategoryLogQueryService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/logs/keyword") +public class PlaceCategoryLogQueryController { + + private final PlaceCategoryLogQueryService placeCategoryLogQueryService; + + @Operation(summary = "이번 주 인기 키워드 조회 API by 피우", description = "이번 주 많이 찾은 키워드를 조회하는 API입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다.") + }) + @GetMapping("/weekly") + public DefaultResponse findWeeklyPlaceCategoryLogList() { + List result = placeCategoryLogQueryService.findWeeklyPlaceCategoryLogList(); + PlaceCategoryLogResponseDTO.WeeklyPlaceCategoryLogList response = PlaceCategoryLogConverter.toWeeklyPlaceCategoryLogList(result); + return DefaultResponse.ok(response); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/converter/PlaceCategoryLogConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/converter/PlaceCategoryLogConverter.java new file mode 100644 index 0000000..e4dc3b0 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/converter/PlaceCategoryLogConverter.java @@ -0,0 +1,40 @@ +package org.withtime.be.withtimebe.domain.log.placecategorylog.converter; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; + +import org.withtime.be.withtimebe.domain.date.entity.PlaceCategory; +import org.withtime.be.withtimebe.domain.log.placecategorylog.dto.PlaceCategoryLogResponseDTO; +import org.withtime.be.withtimebe.domain.log.placecategorylog.model.PlaceCategoryLog; + +public class PlaceCategoryLogConverter { + + public static PlaceCategoryLogResponseDTO.WeeklyPlaceCategoryLogList toWeeklyPlaceCategoryLogList(List placeCategoryLogList) { + + List weeklyPlaceCategoryLogList = placeCategoryLogList.stream() + .sorted(Comparator.comparing(PlaceCategoryLog::getCount).reversed()) // count 기준 내림차순 + .map(PlaceCategoryLogConverter::toWeeklyPlaceCategoryLog) + .toList(); + + return PlaceCategoryLogResponseDTO.WeeklyPlaceCategoryLogList.builder() + .placeCategoryLogList(weeklyPlaceCategoryLogList) + .build(); + } + + public static PlaceCategoryLogResponseDTO.WeeklyPlaceCategoryLog toWeeklyPlaceCategoryLog(PlaceCategoryLog placeCategoryLog) { + return PlaceCategoryLogResponseDTO.WeeklyPlaceCategoryLog.builder() + .placeCategoryLabel(placeCategoryLog.getPlaceCategoryLabel()) + .count(placeCategoryLog.getCount()) + .build(); + } + + public static PlaceCategoryLog toPlaceCategoryLog(PlaceCategory placeCategory, Integer count, LocalDate date) { + return PlaceCategoryLog.builder() + .placeCategoryId(placeCategory.getId()) + .placeCategoryLabel(placeCategory.getLabel()) + .count(count) + .date(date) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/dto/PlaceCategoryLogResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/dto/PlaceCategoryLogResponseDTO.java new file mode 100644 index 0000000..30194e2 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/dto/PlaceCategoryLogResponseDTO.java @@ -0,0 +1,19 @@ +package org.withtime.be.withtimebe.domain.log.placecategorylog.dto; + +import java.util.List; + +import lombok.Builder; + +public class PlaceCategoryLogResponseDTO { + + @Builder + public record WeeklyPlaceCategoryLogList( + List placeCategoryLogList + ) {} + + @Builder + public record WeeklyPlaceCategoryLog( + String placeCategoryLabel, + Integer count + ) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/model/PlaceCategoryLog.java b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/model/PlaceCategoryLog.java new file mode 100644 index 0000000..ddb999a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/model/PlaceCategoryLog.java @@ -0,0 +1,33 @@ +package org.withtime.be.withtimebe.domain.log.placecategorylog.model; + +import java.time.LocalDate; + +import org.springframework.data.mongodb.core.mapping.Document; +import org.withtime.be.withtimebe.global.common.BaseEntity; + +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Document(collection = "place_category_logs") +public class PlaceCategoryLog extends BaseEntity { + + @Id + private String id; + + private Long placeCategoryId; + private String placeCategoryLabel; + private LocalDate date; + private Integer count; + + public void incrementCount(Integer count) { + this.count += count; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/repository/PlaceCategoryLogRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/repository/PlaceCategoryLogRepository.java new file mode 100644 index 0000000..e7d48ec --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/repository/PlaceCategoryLogRepository.java @@ -0,0 +1,12 @@ +package org.withtime.be.withtimebe.domain.log.placecategorylog.repository; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.mongodb.repository.MongoRepository; +import org.withtime.be.withtimebe.domain.log.placecategorylog.model.PlaceCategoryLog; + +public interface PlaceCategoryLogRepository extends MongoRepository { + List findByDateBetween(LocalDate startDate, LocalDate endDate); + List findByPlaceCategoryIdInAndDate(List placeCategoryIds, LocalDate date); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/scheduler/PlaceCategoryLogScheduler.java b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/scheduler/PlaceCategoryLogScheduler.java new file mode 100644 index 0000000..2c63abe --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/scheduler/PlaceCategoryLogScheduler.java @@ -0,0 +1,97 @@ +package org.withtime.be.withtimebe.domain.log.placecategorylog.scheduler; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.date.entity.PlaceCategory; +import org.withtime.be.withtimebe.domain.date.repository.PlaceCategoryRepository; +import org.withtime.be.withtimebe.domain.log.placecategorylog.converter.PlaceCategoryLogConverter; +import org.withtime.be.withtimebe.domain.log.placecategorylog.model.PlaceCategoryLog; +import org.withtime.be.withtimebe.domain.log.placecategorylog.repository.PlaceCategoryLogRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "scheduler.logs.place-category.enabled", havingValue = "true") +public class PlaceCategoryLogScheduler { + + private final RedisTemplate redisTemplate; + private final PlaceCategoryLogRepository placeCategoryLogRepository; + private final PlaceCategoryRepository placeCategoryRepository; + + @Scheduled(cron = "${scheduler.logs.place-category.sync-cron}") // 매 5분마다 + public void syncPlaceCategoryLogsToDB() { + + // 현재 날짜 및 레디스 키 생성 + LocalDate now = LocalDate.from(LocalDateTime.now().minusMinutes(1)); + String formattedDate = now.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String redisKey = "log:place-category:" + formattedDate; + + // ZSET 추출 + Set> zSet = + redisTemplate.opsForZSet().rangeWithScores(redisKey, 0, -1); + + // 없는 경우 Skip + if (zSet == null || zSet.isEmpty()) + return; + + // ZSET 데이터 가공 + Map placeCategoryScoreMap = zSet.stream() + .collect(Collectors.toMap( + tuple -> Long.valueOf(String.valueOf(tuple.getValue())), + tuple -> tuple.getScore().intValue() + )); + + // IN 쿼리로 이미 존재하는 PlaceCategoryLog 조회 + List placeCategoryIds = new ArrayList<>(placeCategoryScoreMap.keySet()); + List placeCategoryLogList = placeCategoryLogRepository.findByPlaceCategoryIdInAndDate(placeCategoryIds, now); + + // placeCategoryId - placeCategoryLog 매핑 + Map placeCategoryLogMap = placeCategoryLogList.stream() + .collect(Collectors.toMap(PlaceCategoryLog::getPlaceCategoryId, Function.identity())); + + // 레디스 데이터를 다큐먼트에 반영 + for (Map.Entry entry : placeCategoryScoreMap.entrySet()) { + Long placeCategoryId = entry.getKey(); + Integer score = entry.getValue(); + + // 기존 로그가 존재하는 경우 count만 증가 + if (placeCategoryLogMap.containsKey(placeCategoryId)) { + placeCategoryLogMap.get(placeCategoryId).incrementCount(score); + } + // 그렇지 않으면 새로 로그 생성 + else { + Optional placeCategoryOptional = placeCategoryRepository.findById(placeCategoryId); + if(placeCategoryOptional.isPresent()) { + PlaceCategory placeCategory = placeCategoryOptional.get(); + PlaceCategoryLog newPlaceCategoryLog = PlaceCategoryLogConverter + .toPlaceCategoryLog(placeCategory, score, now); + placeCategoryLogList.add(newPlaceCategoryLog); + } + } + } + + // 다큐먼트를 DB에 반영 + if (!placeCategoryLogList.isEmpty()) { + placeCategoryLogRepository.saveAll(placeCategoryLogList); + redisTemplate.delete(redisKey); + log.info("[PlaceCategoryLogScheduler] 카테고리 로그 저장 완료, 개수 : {}", placeCategoryLogList.size()); + } + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/service/query/PlaceCategoryLogQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/service/query/PlaceCategoryLogQueryService.java new file mode 100644 index 0000000..cb5c910 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/service/query/PlaceCategoryLogQueryService.java @@ -0,0 +1,9 @@ +package org.withtime.be.withtimebe.domain.log.placecategorylog.service.query; + +import java.util.List; + +import org.withtime.be.withtimebe.domain.log.placecategorylog.model.PlaceCategoryLog; + +public interface PlaceCategoryLogQueryService { + List findWeeklyPlaceCategoryLogList(); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/service/query/PlaceCategoryLogQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/service/query/PlaceCategoryLogQueryServiceImpl.java new file mode 100644 index 0000000..88a4f4b --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/placecategorylog/service/query/PlaceCategoryLogQueryServiceImpl.java @@ -0,0 +1,31 @@ +package org.withtime.be.withtimebe.domain.log.placecategorylog.service.query; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.log.placecategorylog.model.PlaceCategoryLog; +import org.withtime.be.withtimebe.domain.log.placecategorylog.repository.PlaceCategoryLogRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PlaceCategoryLogQueryServiceImpl implements PlaceCategoryLogQueryService { + + private final PlaceCategoryLogRepository placeCategoryLogRepository; + + @Override + public List findWeeklyPlaceCategoryLogList() { + + LocalDate now = LocalDate.now(); + + LocalDate startOfWeek = now.with(DayOfWeek.MONDAY); + LocalDate endOfWeek = now.with(DayOfWeek.SUNDAY); + + return placeCategoryLogRepository.findByDateBetween(startOfWeek, endOfWeek); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/config/MongoConfig.java b/src/main/java/org/withtime/be/withtimebe/global/config/MongoConfig.java new file mode 100644 index 0000000..a0d7b08 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/config/MongoConfig.java @@ -0,0 +1,52 @@ +package org.withtime.be.withtimebe.global.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.config.EnableMongoAuditing; +import org.springframework.data.mongodb.core.convert.DbRefResolver; +import org.springframework.data.mongodb.core.convert.DefaultDbRefResolver; +import org.springframework.data.mongodb.core.convert.DefaultMongoTypeMapper; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; +import org.withtime.be.withtimebe.global.converter.MongoConverters; + +import lombok.AllArgsConstructor; + +@Configuration +@AllArgsConstructor +@EnableMongoAuditing +@EnableMongoRepositories(basePackages = "org.withtime.be.withtimebe.domain.log") +public class MongoConfig { + + // KST <-> UTC 커스텀 컨버터 빈 등록 + @Bean + public MongoCustomConversions mongoCustomConversions() { + return new MongoCustomConversions(List.of( + new MongoConverters.LocalDateToDateKstConverter(), + new MongoConverters.LocalTimeToDateKstConverter(), + new MongoConverters.LocalDateTimeToDateKstConverter(), + new MongoConverters.DateToLocalDateKstConverter(), + new MongoConverters.DateToLocalTimeKstConverter(), + new MongoConverters.DateToLocalDateTimeKstConverter() + )); + } + + // 커스텀 컨버터 등록 및 _class 필드 제거 + @Bean + public MappingMongoConverter mappingMongoConverter( + MongoDatabaseFactory mongoDatabaseFactory, + MongoMappingContext mongoMappingContext, + MongoCustomConversions conversions + ) { + DbRefResolver dbRefResolver = new DefaultDbRefResolver(mongoDatabaseFactory); + MappingMongoConverter converter = new MappingMongoConverter(dbRefResolver, mongoMappingContext); + converter.setTypeMapper(new DefaultMongoTypeMapper(null)); + converter.setCustomConversions(conversions); + return converter; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/converter/MongoConverters.java b/src/main/java/org/withtime/be/withtimebe/global/converter/MongoConverters.java new file mode 100644 index 0000000..8ea6e32 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/converter/MongoConverters.java @@ -0,0 +1,78 @@ +package org.withtime.be.withtimebe.global.converter; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Date; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; + +public class MongoConverters { + + @WritingConverter + public static class LocalDateToDateKstConverter implements Converter { + @Override + public Date convert(LocalDate source) { + return Timestamp.valueOf(source.atStartOfDay().plusHours(9)); + } + } + + @WritingConverter + public static class LocalTimeToDateKstConverter implements Converter { + @Override + public Date convert(LocalTime source) { + LocalDateTime localDateTime = LocalDateTime.of(LocalDate.now(), source).plusHours(9); + return Timestamp.valueOf(localDateTime); + } + } + + @WritingConverter + public static class LocalDateTimeToDateKstConverter implements Converter { + @Override + public Date convert(LocalDateTime source) { + return Timestamp.valueOf(source.plusHours(9)); + } + } + + @ReadingConverter + public static class DateToLocalDateKstConverter implements Converter { + @Override + public LocalDate convert(Date source) { + return source.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + .minusHours(9) + .toLocalDate(); + } + } + + @ReadingConverter + public static class DateToLocalTimeKstConverter implements Converter { + + @Override + public LocalTime convert(Date source) { + return source.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + .minusHours(9) + .toLocalTime(); + } + } + + @ReadingConverter + public static class DateToLocalDateTimeKstConverter implements Converter { + @Override + public LocalDateTime convert(Date source) { + return source.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + .minusHours(9); + } + } +} + + diff --git a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java index 7d0ff94..32f0c8a 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java +++ b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java @@ -53,6 +53,8 @@ public class SecurityConfig { API_PREFIX + "/notices/**", API_PREFIX + "/oauth2/**", API_PREFIX + "/faqs/**", + API_PREFIX + "/logs/keyword/**", + API_PREFIX + "/logs/dateplaces/**", "/oauth2/authorization/**", "/swagger-ui/**", "/swagger-resources/**", diff --git a/src/main/resources/application-develop.yml b/src/main/resources/application-develop.yml index 3f5e593..42eff89 100644 --- a/src/main/resources/application-develop.yml +++ b/src/main/resources/application-develop.yml @@ -15,6 +15,8 @@ spring: redis: host: ${REDIS_HOST} port: 6379 + mongodb: + uri: ${MONGO_URI} mail: host: ${MAIL_SENDER_HOST} port: 587 @@ -121,4 +123,12 @@ scheduler: retention: short-term-days: 7 # 단기 예보 보관 기간 medium-term-days: 7 # 중기 예보 보관 기간 - recommendation-days: 30 # 추천 정보 보관 기간 \ No newline at end of file + recommendation-days: 30 # 추천 정보 보관 기간 + + logs: + place-category: + enabled: true + sync-cron: "0 */5 * * * *" # 5분마다 Redis -> Mongo 동기화 + date-place: + enabled: true + sync-cron: "0 0 0 * * *" # 매일 자정마다 동기화 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8811d38..311aed9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,6 +15,8 @@ spring: redis: host: ${REDIS_HOST} port: 6379 + mongodb: + uri: ${MONGO_URI} mail: host: ${MAIL_SENDER_HOST} port: 587 @@ -124,4 +126,12 @@ scheduler: retention: short-term-days: 7 # 단기 예보 보관 기간 medium-term-days: 7 # 중기 예보 보관 기간 - recommendation-days: 30 # 추천 정보 보관 기간 \ No newline at end of file + recommendation-days: 30 # 추천 정보 보관 기간 + + logs: + place-category: + enabled: true + sync-cron: "0 */5 * * * *" # 5분마다 Redis -> Mongo 동기화 + date-place: + enabled: true + sync-cron: "0 0 0 * * *" # 매일 자정마다 동기화 \ No newline at end of file diff --git a/src/test/java/org/withtime/be/withtimebe/domain/log/datecourselog/service/query/DateCourseLogQueryServiceImplTest.java b/src/test/java/org/withtime/be/withtimebe/domain/log/datecourselog/service/query/DateCourseLogQueryServiceImplTest.java new file mode 100644 index 0000000..9fd44ff --- /dev/null +++ b/src/test/java/org/withtime/be/withtimebe/domain/log/datecourselog/service/query/DateCourseLogQueryServiceImplTest.java @@ -0,0 +1,130 @@ +package org.withtime.be.withtimebe.domain.log.datecourselog.service.query; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.withtime.be.withtimebe.domain.date.repository.DateCourseRepository; +import org.withtime.be.withtimebe.domain.date.repository.DatePlaceDateCourseRepository; +import org.withtime.be.withtimebe.domain.log.datecourselog.dto.DateCourseLogResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("[DateCourseLogQueryService] 단위 테스트") +class DateCourseLogQueryServiceImplTest { + + @InjectMocks + private DateCourseLogQueryServiceImpl dateCourseLogQueryService; + + @Mock + private DateCourseRepository dateCourseRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private DatePlaceDateCourseRepository datePlaceDateCourseRepository; + + @Nested + @DisplayName("findAverageDateCourseCount()") + class FindAverageDateCourseCount { + + @Test + @DisplayName("member가 성공적으로 주어지면 평균 데이트 횟수와 나의 데이트 횟수가 계산된다.") + void averageCountWithMember() { + // given + Long memberId = 1L; + + Member member = Member.builder() + .id(memberId) + .build(); + + LocalDate now = LocalDate.now(); + LocalDate oneMonthAgo = now.minusDays(30); + + given(dateCourseRepository.countByCreatedAtBetween(oneMonthAgo, now)).willReturn(300L); + given(memberRepository.count()).willReturn(15L); + given(dateCourseRepository.countByMemberId(memberId)).willReturn(10L); // 횟수 : 10번 + + // when + DateCourseLogResponseDTO.FindAverageDateCourseCount result = + dateCourseLogQueryService.findAverageDateCourseCount(member); + + // then + assertThat(result.averageDateCount()).isEqualTo(20.0); + assertThat(result.myDateCount()).isEqualTo(10L); + } + + @Test + @DisplayName("비회원인 경우 (member == null) 나의 데이트 횟수는 0으로 계산된다.") + void averageCountWithNullMember() { + // given + LocalDate now = LocalDate.now(); + LocalDate oneMonthAgo = now.minusDays(30); + + given(dateCourseRepository.countByCreatedAtBetween(oneMonthAgo, now)).willReturn(300L); + given(memberRepository.count()).willReturn(15L); + + // when + DateCourseLogResponseDTO.FindAverageDateCourseCount result = + dateCourseLogQueryService.findAverageDateCourseCount(null); + + // then + assertThat(result.averageDateCount()).isEqualTo(20.0); + assertThat(result.myDateCount()).isEqualTo(0L); + } + } + + @Nested + @DisplayName("findSavedDateCourseCount()") + class FindSavedDateCourseCount { + + @Test + @DisplayName("member가 성공적으로 주어지면 count 쿼리가 실행되고, 다른 사람이 저장한 내 데이트 코스 횟수가 계산된다.") + void savedCountWithMember() { + // given + Long memberId = 1L; + Long expectedSavedCount = 5L; + Integer expectedMethodCall = 1; + + Member member = Member.builder() + .id(memberId) + .build(); + + given(datePlaceDateCourseRepository.countByCreatorMemberId(memberId)).willReturn(expectedSavedCount); + + // when + DateCourseLogResponseDTO.FindSavedDateCourseCount result = + dateCourseLogQueryService.findSavedDateCourseCount(member); + + // then + assertThat(result.count()).isEqualTo(expectedSavedCount); + verify(datePlaceDateCourseRepository, times(expectedMethodCall)).countByCreatorMemberId(anyLong()); + } + + @Test + @DisplayName("비회원인 경우 (member == null) count 쿼리는 실행되지 않고, 저장 수는 0으로 계산된다.") + void savedCountWithNullMember() { + // given + Member member = null; + Integer expectedMethodCall = 0; + + // when + DateCourseLogResponseDTO.FindSavedDateCourseCount result = + dateCourseLogQueryService.findSavedDateCourseCount(member); + + // then + assertThat(result.count()).isEqualTo(0L); + verify(datePlaceDateCourseRepository, times(expectedMethodCall)).countByCreatorMemberId(anyLong()); + } + } +} \ No newline at end of file diff --git a/src/test/java/org/withtime/be/withtimebe/domain/log/dateplacelog/service/query/DatePlaceLogQueryServiceImplTest.java b/src/test/java/org/withtime/be/withtimebe/domain/log/dateplacelog/service/query/DatePlaceLogQueryServiceImplTest.java new file mode 100644 index 0000000..42bdadd --- /dev/null +++ b/src/test/java/org/withtime/be/withtimebe/domain/log/dateplacelog/service/query/DatePlaceLogQueryServiceImplTest.java @@ -0,0 +1,66 @@ +package org.withtime.be.withtimebe.domain.log.dateplacelog.service.query; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDate; +import java.util.List; + +import org.bson.Document; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.withtime.be.withtimebe.domain.log.dateplacelog.entity.DatePlaceLog; + +@ExtendWith(MockitoExtension.class) +@DisplayName("[DatePlaceLogQueryService] 단위 테스트") +class DatePlaceLogQueryServiceImplTest { + + @InjectMocks + private DatePlaceLogQueryServiceImpl datePlaceLogQueryService; + + @Mock + private MongoTemplate mongoTemplate; + + @Nested + @DisplayName("findMonthlyDatePlaceLogList()") + class FindMonthlyDatePlaceLogList { + + @Test + @DisplayName("Aggregation을 사용하여 월별 DatePlace 개수를 반환한다.") + void returnsMonthlyDatePlaceLogList() { + // given + DatePlaceLog log1 = DatePlaceLog.builder() + .date(LocalDate.of(2025, 6, 1)) + .count(300L) + .build(); + DatePlaceLog log2 = DatePlaceLog.builder() + .date(LocalDate.of(2025, 7, 1)) + .count(350L) + .build(); + List expectedLogs = List.of(log1, log2); + + AggregationResults aggregationResult = new AggregationResults<>(expectedLogs, new Document()); + + given(mongoTemplate.aggregate(any(Aggregation.class), eq("date_place_logs"), eq(DatePlaceLog.class))).willReturn(aggregationResult); + + // when + List result = datePlaceLogQueryService.findMonthlyDatePlaceLogList(); + + // then + assertThat(result).hasSize(2); + assertThat(result).containsExactly(log1, log2); + verify(mongoTemplate, times(1)).aggregate(any(Aggregation.class), eq("date_place_logs"), eq(DatePlaceLog.class)); + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/withtime/be/withtimebe/domain/log/placecategorylog/aop/LogPlaceCategoryAspectTest.java b/src/test/java/org/withtime/be/withtimebe/domain/log/placecategorylog/aop/LogPlaceCategoryAspectTest.java new file mode 100644 index 0000000..b8c87e6 --- /dev/null +++ b/src/test/java/org/withtime/be/withtimebe/domain/log/placecategorylog/aop/LogPlaceCategoryAspectTest.java @@ -0,0 +1,161 @@ +package org.withtime.be.withtimebe.domain.log.placecategorylog.aop; + +import static org.mockito.BDDMockito.*; + +import java.util.List; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +@ExtendWith(MockitoExtension.class) +@DisplayName("[AOP] LogPlaceCategoryAspect 단위 테스트") +class LogPlaceCategoryAspectTest { + + @InjectMocks + private LogPlaceCategoryAspect aspect; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ZSetOperations zSetOperations; + + // JoinPoint 생성 메서드 + private JoinPoint createJoinPoint(String[] paramNames, Object[] args) { + JoinPoint joinPoint = mock(JoinPoint.class); + MethodSignature methodSignature = mock(MethodSignature.class); + + given(joinPoint.getSignature()).willReturn(methodSignature); + given(joinPoint.getArgs()).willReturn(args); + given(methodSignature.getParameterNames()).willReturn(paramNames); + + return joinPoint; + } + + @Nested + @DisplayName("logPlaceCategory()") + class LogPlaceCategoryTest { + + @Test + @DisplayName("'placeCategoryId'가 단일 인자로 들어오면, Redis에 ZSET으로 기록된다.") + void single_placeCategoryId_param_test() { + // given + Long expectedId = 1L; + Integer expectedMethodCall = 1; + + JoinPoint joinPoint = createJoinPoint( + new String[] {"placeCategoryId", "anotherId", "anotherDTO"}, + new Object[] {expectedId, 1L, new Object()} + ); + + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + given(redisTemplate.getExpire(anyString(), any())).willReturn(-1L); + + // when + aspect.logPlaceCategory(joinPoint); + + // then + verify(zSetOperations, times(expectedMethodCall)).incrementScore(anyString(), eq(expectedId), eq(1.0)); + verify(redisTemplate, times(expectedMethodCall)).expire(anyString(), anyLong(), any()); + } + + @Test + @DisplayName("'placeCategoryId'가 List로 들어오면, Redis에 ZSET으로 기록된다.") + void list_placeCategoryId_param_test() { + // given + List expectedIdList = List.of(1L, 2L, 3L); + Integer expectedMethodCall = 1; + + JoinPoint joinPoint = createJoinPoint( + new String[] {"placeCategoryIdList", "anotherDTO", "anotherIdList"}, + new Object[] {expectedIdList, new Object(), List.of(1L, 2L)} + ); + + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + given(redisTemplate.getExpire(anyString(), any())).willReturn(-1L); + + // when + aspect.logPlaceCategory(joinPoint); + + // then + expectedIdList + .forEach(id -> verify(zSetOperations, times(expectedMethodCall)).incrementScore(anyString(), eq(id), eq(1.0))); + verify(redisTemplate, times(expectedMethodCall)).expire(anyString(), anyLong(), any()); + } + + @Test + @DisplayName("'placeCategoryId'가 DTO 내부 필드에 있으면 Redis에 기록된다.") + void dto_placeCategoryId_field_test() { + // given + Long expectedId = 1L; + Integer expectedMethodCall = 1; + + class RequestDTO { + Long placeCategoryId = expectedId; + } + class AnotherDTO { + Long anotherId = 1L; + } + Object requestDTO = new RequestDTO(); + Object anotherDTO = new AnotherDTO(); + + JoinPoint joinPoint = createJoinPoint( + new String[] {"requestDTO", "anotherDTO"}, + new Object[] {requestDTO, anotherDTO} + ); + + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + given(redisTemplate.getExpire(anyString(), any())).willReturn(-1L); + + // when + aspect.logPlaceCategory(joinPoint); + + // then + verify(zSetOperations, times(expectedMethodCall)).incrementScore(anyString(), eq(expectedId), eq(1.0)); + verify(redisTemplate, times(expectedMethodCall)).expire(anyString(), anyLong(), any()); + } + + @Test + @DisplayName("'placeCategoryIdList'가 DTO 내부 List 필드로 있으면, Redis에 각각 기록된다.") + void dto_placeCategoryId_list_test() { + // given + List expectedIdList = List.of(1L, 2L, 3L); + Integer expectedMethodCall = 1; + + class RequestDTO { + List placeCategoryIdList = expectedIdList; + } + class AnotherDTO { + List anotherIdList = expectedIdList; + } + Object requestDTO = new RequestDTO(); + Object anotherDTO = new AnotherDTO(); + + JoinPoint joinPoint = createJoinPoint( + new String[] {"requestDTO", "anotherDTO", "anotherId"}, + new Object[] {requestDTO, anotherDTO, 1L} + ); + + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + given(redisTemplate.getExpire(anyString(), any())).willReturn(-1L); + + // when + aspect.logPlaceCategory(joinPoint); + + // then + expectedIdList.forEach(id -> + verify(zSetOperations, times(expectedMethodCall)).incrementScore(anyString(), eq(id), eq(1.0)) + ); + verify(redisTemplate, times(expectedMethodCall)).expire(anyString(), anyLong(), any()); + } + } +} \ No newline at end of file diff --git a/src/test/java/org/withtime/be/withtimebe/domain/log/placecategorylog/service/query/PlaceCategoryLogQueryServiceImplTest.java b/src/test/java/org/withtime/be/withtimebe/domain/log/placecategorylog/service/query/PlaceCategoryLogQueryServiceImplTest.java new file mode 100644 index 0000000..bb7ebea --- /dev/null +++ b/src/test/java/org/withtime/be/withtimebe/domain/log/placecategorylog/service/query/PlaceCategoryLogQueryServiceImplTest.java @@ -0,0 +1,67 @@ +package org.withtime.be.withtimebe.domain.log.placecategorylog.service.query; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.withtime.be.withtimebe.domain.log.placecategorylog.model.PlaceCategoryLog; +import org.withtime.be.withtimebe.domain.log.placecategorylog.repository.PlaceCategoryLogRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("[PlaceCategoryLogQueryService] 단위 테스트") +class PlaceCategoryLogQueryServiceImplTest { + + @InjectMocks + private PlaceCategoryLogQueryServiceImpl placeCategoryLogQueryService; + + @Mock + private PlaceCategoryLogRepository placeCategoryLogRepository; + + @Nested + @DisplayName("findWeeklyPlaceCategoryLogList()") + class FindWeeklyPlaceCategoryLogListTest { + + @Test + @DisplayName("이번 주 월요일부터 일요일까지, 조회된 placeCategoryLog를 반환한다.") + void find_weekly_placeCategoryLogList_test() { + // given + LocalDate now = LocalDate.now(); + LocalDate monday = now.with(DayOfWeek.MONDAY); + LocalDate sunday = now.with(DayOfWeek.SUNDAY); + + PlaceCategoryLog expectedLog1 = PlaceCategoryLog.builder() + .placeCategoryId(1L) + .placeCategoryLabel("감성적인") + .date(monday) + .count(30) + .build(); + PlaceCategoryLog expectedLog2 = PlaceCategoryLog.builder() + .placeCategoryId(2L) + .placeCategoryLabel("잔잔한") + .date(monday.plusDays(1)) + .count(50) + .build(); + + given(placeCategoryLogRepository.findByDateBetween(monday, sunday)) + .willReturn(List.of(expectedLog1, expectedLog2)); + + // when + List result = placeCategoryLogQueryService.findWeeklyPlaceCategoryLogList(); + + // then + assertThat(result).hasSize(2); + assertThat(result).containsExactlyInAnyOrder(expectedLog1, expectedLog2); + } + } +} \ No newline at end of file