diff --git a/build.gradle b/build.gradle index e678725..fff3c2c 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + // MongoDB + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' // test testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java b/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java index 11234d3..63bb2d7 100644 --- a/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java +++ b/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java @@ -1,10 +1,24 @@ 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.data.mongodb.repository.config.EnableMongoRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.withtime.be.withtimebe.domain.log.repository.VisitLogRepository; + +import jakarta.annotation.PostConstruct; +@EnableScheduling @EnableJpaAuditing +@EnableJpaRepositories(basePackages = {"org.withtime.be"}, excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = VisitLogRepository.class)}) +@EnableMongoRepositories(basePackageClasses = VisitLogRepository.class) @SpringBootApplication public class WithTimeBeApplication { @@ -12,4 +26,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/entity/BusinessTime.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/BusinessTime.java index 2c955d3..cca0cd7 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/BusinessTime.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/BusinessTime.java @@ -3,6 +3,7 @@ import jakarta.persistence.*; import lombok.*; import org.withtime.be.withtimebe.domain.date.entity.enums.Day; +import org.withtime.be.withtimebe.domain.dateplace.entity.DatePlace; import org.withtime.be.withtimebe.global.common.BaseEntity; import java.time.LocalTime; diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlaceDateCourse.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlaceDateCourse.java index fbb5009..1fcbb73 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlaceDateCourse.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlaceDateCourse.java @@ -2,6 +2,8 @@ import jakarta.persistence.*; import lombok.*; + +import org.withtime.be.withtimebe.domain.dateplace.entity.DatePlace; import org.withtime.be.withtimebe.global.common.BaseEntity; @Entity diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlacePlaceCategory.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlacePlaceCategory.java index 92a6303..34e8a88 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlacePlaceCategory.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlacePlaceCategory.java @@ -3,6 +3,7 @@ import jakarta.persistence.*; import lombok.*; +import org.withtime.be.withtimebe.domain.dateplace.entity.DatePlace; import org.withtime.be.withtimebe.global.common.BaseEntity; @Entity diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/Item.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/Item.java index adb9fc1..f34a0cf 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/Item.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/Item.java @@ -2,6 +2,8 @@ import jakarta.persistence.*; import lombok.*; + +import org.withtime.be.withtimebe.domain.dateplace.entity.DatePlace; import org.withtime.be.withtimebe.global.common.BaseEntity; @Entity diff --git a/src/main/java/org/withtime/be/withtimebe/domain/dateplace/controller/DatePlaceQueryController.java b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/controller/DatePlaceQueryController.java new file mode 100644 index 0000000..50d7b1f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/controller/DatePlaceQueryController.java @@ -0,0 +1,41 @@ +package org.withtime.be.withtimebe.domain.dateplace.controller; + +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +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.dateplace.converter.DatePlaceConverter; +import org.withtime.be.withtimebe.domain.dateplace.dto.response.DatePlaceResponseDTO; +import org.withtime.be.withtimebe.domain.dateplace.entity.DatePlace; +import org.withtime.be.withtimebe.domain.dateplace.service.query.DatePlaceQueryService; +import org.withtime.be.withtimebe.global.annotation.SwaggerPageable; + +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/dateplaces") +public class DatePlaceQueryController { + + private final DatePlaceQueryService datePlaceQueryService; + + @Operation(summary = "어드민 페이지 데이트 장소 조회 API by 피우 [Only Admin]", description = "어드민 페이지에서 데이트 장소를 조회하는 API입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다.") + }) + @SwaggerPageable + @GetMapping("/management") + public DefaultResponse findDatePlaceManagement( + @PageableDefault(page = 0, size = 10) Pageable pageable + ) { + Page result = datePlaceQueryService.findDatePlaces(pageable); + DatePlaceResponseDTO.DatePlaceManagementList response = DatePlaceConverter.toDatePlaceManagementList(result); + return DefaultResponse.ok(response); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/dateplace/converter/DatePlaceConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/converter/DatePlaceConverter.java new file mode 100644 index 0000000..f148760 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/converter/DatePlaceConverter.java @@ -0,0 +1,52 @@ +package org.withtime.be.withtimebe.domain.dateplace.converter; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.withtime.be.withtimebe.domain.date.entity.PlaceCategory; +import org.withtime.be.withtimebe.domain.dateplace.dto.response.DatePlaceResponseDTO; +import org.withtime.be.withtimebe.domain.dateplace.entity.DatePlace; + +public class DatePlaceConverter { + + public static DatePlaceResponseDTO.DatePlaceManagementList toDatePlaceManagementList(Page datePlacePage) { + + List datePlaceManagementList = datePlacePage.stream() + .map(DatePlaceConverter::toDatePlaceManagement) + .toList(); + + return DatePlaceResponseDTO.DatePlaceManagementList.builder() + .datePlaceManagementList(datePlaceManagementList) + .totalPages(datePlacePage.getTotalPages()) + .currentPage(datePlacePage.getNumber()) + .currentSize(datePlacePage.getNumberOfElements()) + .hasNextPage(datePlacePage.hasNext()) + .build(); + } + + public static DatePlaceResponseDTO.DatePlaceManagement toDatePlaceManagement(DatePlace datePlace) { + + List placeCategoryList = datePlace.getDatePlacePlaceCategoryList().stream() + .map(datePlacePlaceCategory -> toPlaceCategory(datePlacePlaceCategory.getPlaceCategory())) + .toList(); + + return DatePlaceResponseDTO.DatePlaceManagement.builder() + .datePlaceId(datePlace.getId()) + .name(datePlace.getName()) + .tel(datePlace.getTel()) + .averagePrice(datePlace.getAveragePrice()) + .lotNumberAddress(datePlace.getLotNumberAddress()) + .placeType(datePlace.getPlaceType().getLabel()) + .placeCategoryList(placeCategoryList) + .build(); + } + + public static DatePlaceResponseDTO.PlaceCategory toPlaceCategory(PlaceCategory placeCategory) { + + return DatePlaceResponseDTO.PlaceCategory.builder() + .placeCategoryId(placeCategory.getId()) + .placeCategoryType(placeCategory.getCategoryType().name()) + .label(placeCategory.getLabel()) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/dateplace/dto/response/DatePlaceResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/dto/response/DatePlaceResponseDTO.java new file mode 100644 index 0000000..e8e09d5 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/dto/response/DatePlaceResponseDTO.java @@ -0,0 +1,35 @@ +package org.withtime.be.withtimebe.domain.dateplace.dto.response; + +import java.util.List; + +import lombok.Builder; + +public class DatePlaceResponseDTO { + + @Builder + public record DatePlaceManagementList( + List datePlaceManagementList, + Integer totalPages, // 전체 페이지 개수 + Integer currentPage, // 현재 페이지 번호 + Integer currentSize, // 현재 페이지의 크기 + Boolean hasNextPage // 다음 페이지 존재 여부 + ) {} + + @Builder + public record DatePlaceManagement( + Long datePlaceId, // 데이트 장소 식별자 값 + String name, // 장소 이름 + String tel, // 전화 번호 + Integer averagePrice, // 평균 가격 + String lotNumberAddress, // 지번 주소 + String placeType, // 장소 유형 + List placeCategoryList // 카테고리 목록 + ) {} + + @Builder + public record PlaceCategory( + Long placeCategoryId, + String placeCategoryType, + String label + ) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlace.java b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/entity/DatePlace.java similarity index 67% rename from src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlace.java rename to src/main/java/org/withtime/be/withtimebe/domain/dateplace/entity/DatePlace.java index 736aec3..1dd74e4 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlace.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/entity/DatePlace.java @@ -1,9 +1,15 @@ -package org.withtime.be.withtimebe.domain.date.entity; +package org.withtime.be.withtimebe.domain.dateplace.entity; + +import java.util.ArrayList; +import java.util.List; import jakarta.persistence.*; import lombok.*; -import org.withtime.be.withtimebe.domain.date.entity.enums.PlaceType; +import org.hibernate.annotations.BatchSize; +import org.withtime.be.withtimebe.domain.date.entity.DatePlacePlaceCategory; +import org.withtime.be.withtimebe.domain.date.entity.PlaceCategory; +import org.withtime.be.withtimebe.domain.dateplace.entity.enums.PlaceType; import org.withtime.be.withtimebe.global.common.BaseEntity; @Entity @@ -49,4 +55,9 @@ public class DatePlace extends BaseEntity { @Enumerated(EnumType.STRING) @Column(name = "place_type") private PlaceType placeType; + + @OneToMany(mappedBy = "datePlace") + @BatchSize(size = 10) + @Builder.Default + private List datePlacePlaceCategoryList = new ArrayList<>(); } \ No newline at end of file diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/PlaceType.java b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/entity/enums/PlaceType.java similarity index 82% rename from src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/PlaceType.java rename to src/main/java/org/withtime/be/withtimebe/domain/dateplace/entity/enums/PlaceType.java index c01432c..8856361 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/PlaceType.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/entity/enums/PlaceType.java @@ -1,4 +1,4 @@ -package org.withtime.be.withtimebe.domain.date.entity.enums; +package org.withtime.be.withtimebe.domain.dateplace.entity.enums; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/org/withtime/be/withtimebe/domain/dateplace/repository/DatePlaceRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/repository/DatePlaceRepository.java new file mode 100644 index 0000000..5f78a8a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/repository/DatePlaceRepository.java @@ -0,0 +1,7 @@ +package org.withtime.be.withtimebe.domain.dateplace.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.dateplace.entity.DatePlace; + +public interface DatePlaceRepository extends JpaRepository { +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/dateplace/service/query/DatePlaceQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/service/query/DatePlaceQueryService.java new file mode 100644 index 0000000..ae02b0f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/service/query/DatePlaceQueryService.java @@ -0,0 +1,9 @@ +package org.withtime.be.withtimebe.domain.dateplace.service.query; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.withtime.be.withtimebe.domain.dateplace.entity.DatePlace; + +public interface DatePlaceQueryService { + Page findDatePlaces(Pageable pageable); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/dateplace/service/query/DatePlaceQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/service/query/DatePlaceQueryServiceImpl.java new file mode 100644 index 0000000..6dd7c4f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/dateplace/service/query/DatePlaceQueryServiceImpl.java @@ -0,0 +1,24 @@ +package org.withtime.be.withtimebe.domain.dateplace.service.query; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import org.withtime.be.withtimebe.domain.dateplace.entity.DatePlace; +import org.withtime.be.withtimebe.domain.dateplace.repository.DatePlaceRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DatePlaceQueryServiceImpl implements DatePlaceQueryService { + + private final DatePlaceRepository datePlaceRepository; + + @Override + public Page findDatePlaces(Pageable pageable) { + return datePlaceRepository.findAll(pageable); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/controller/query/VisitLogQueryController.java b/src/main/java/org/withtime/be/withtimebe/domain/log/controller/query/VisitLogQueryController.java new file mode 100644 index 0000000..ea97f6f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/controller/query/VisitLogQueryController.java @@ -0,0 +1,54 @@ +package org.withtime.be.withtimebe.domain.log.controller.query; + +import java.time.LocalDate; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.withtime.be.withtimebe.domain.log.converter.VisitLogConverter; +import org.withtime.be.withtimebe.domain.log.dto.response.VisitLogResponseDTO; +import org.withtime.be.withtimebe.domain.log.model.VisitLog; +import org.withtime.be.withtimebe.domain.log.service.query.VisitLogQueryService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/visit-logs") +public class VisitLogQueryController { + + private final VisitLogQueryService visitLogQueryService; + + @Operation(summary = "최근 일주일 간 일별 방문자 수 조회 API by 피우 [Only Admin]", description = "일주일 간 일별 방문자 수 조회 API입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다.") + }) + @GetMapping("/daily") + public DefaultResponse findDailyVisitLogList() { + List result = visitLogQueryService.findDailyVisitLogList(); + VisitLogResponseDTO.DailyVisitLogList response = VisitLogConverter.toDailyVisitLogList(result); + return DefaultResponse.ok(response); + } + + @Operation(summary = "하루동안 시간대 별 방문자 수 추이 조회 API by 피우 [Only Admin]", description = "하루동안 시간대 별 방문자 수 추이 조회 API 입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다.") + }) + @Parameter(name = "date", description = "예) 2025-01-01") + @GetMapping("/hourly") + public DefaultResponse findHourlyVisitLog( + @RequestParam("date") LocalDate date + ) { + List result = visitLogQueryService.findHourlyVisitLogList(date); + VisitLogResponseDTO.HourlyVisitLogList response = VisitLogConverter.toHourlyVisitLogList(date, result); + return DefaultResponse.ok(response); + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/converter/VisitLogConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/log/converter/VisitLogConverter.java new file mode 100644 index 0000000..ffac28f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/converter/VisitLogConverter.java @@ -0,0 +1,57 @@ +package org.withtime.be.withtimebe.domain.log.converter; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.withtime.be.withtimebe.domain.log.dto.response.VisitLogResponseDTO; +import org.withtime.be.withtimebe.domain.log.model.VisitLog; + +public class VisitLogConverter { + + public static VisitLogResponseDTO.DailyVisitLogList toDailyVisitLogList(List visitLogList) { + + Map result = visitLogList.stream() + .collect(Collectors.groupingBy(VisitLog::getDate, Collectors.summingLong(VisitLog::getCount))); + + List dailyVisitLogList = result.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> toDailyVisitLog(entry.getKey(), entry.getValue())) + .toList(); + + return VisitLogResponseDTO.DailyVisitLogList.builder() + .dailyVisitLogList(dailyVisitLogList) + .build(); + } + + public static VisitLogResponseDTO.DailyVisitLog toDailyVisitLog(LocalDate localDate, Long totalCount) { + + return VisitLogResponseDTO.DailyVisitLog.builder() + .date(localDate) + .count(totalCount) + .build(); + } + + public static VisitLogResponseDTO.HourlyVisitLogList toHourlyVisitLogList(LocalDate date, List visitLogList) { + + List hourlyVisitLogList = visitLogList.stream() + .sorted(Comparator.comparing(VisitLog::getHour)) + .map(VisitLogConverter::toHourlyVisitLog) + .toList(); + + return VisitLogResponseDTO.HourlyVisitLogList.builder() + .date(date) + .hourlyVisitLogList(hourlyVisitLogList) + .build(); + } + + public static VisitLogResponseDTO.HourlyVisitLog toHourlyVisitLog(VisitLog visitLog) { + + return VisitLogResponseDTO.HourlyVisitLog.builder() + .hour(visitLog.getHour()) + .count(visitLog.getCount()) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/dto/response/VisitLogResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/log/dto/response/VisitLogResponseDTO.java new file mode 100644 index 0000000..cb8a035 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/dto/response/VisitLogResponseDTO.java @@ -0,0 +1,33 @@ +package org.withtime.be.withtimebe.domain.log.dto.response; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import lombok.Builder; + +public class VisitLogResponseDTO { + + @Builder + public record DailyVisitLogList( + List dailyVisitLogList + ) {} + + @Builder + public record DailyVisitLog( + LocalDate date, + Long count + ) {} + + @Builder + public record HourlyVisitLogList( + LocalDate date, + List hourlyVisitLogList + ) {} + + @Builder + public record HourlyVisitLog( + LocalTime hour, + Long count + ) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/interceptor/VisitCountInterceptor.java b/src/main/java/org/withtime/be/withtimebe/domain/log/interceptor/VisitCountInterceptor.java new file mode 100644 index 0000000..d2f19e5 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/interceptor/VisitCountInterceptor.java @@ -0,0 +1,62 @@ +package org.withtime.be.withtimebe.domain.log.interceptor; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalTime; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; + +@Component +@AllArgsConstructor +public class VisitCountInterceptor implements HandlerInterceptor { + + private final RedisTemplate redisTemplate; + + // 리버스 프록시 확장 고려 + private static final String[] PROXY_HEADER_NAMES = { + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", + "HTTP_X_FORWARDED_FOR" + }; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + + // IP 기반 방문자 기록 + String clientIp = getClientIp(request); + LocalDate today = LocalDate.now(); + Integer hour = LocalTime.now().getHour(); + + // Key + String redisKey = String.format("visitCount:%s:%02d", today.toString(), hour); + + // Set + redisTemplate.opsForSet().add(redisKey, clientIp); + + // TTL + Long expire = redisTemplate.getExpire(redisKey); + if (expire < 0) { + redisTemplate.expire(redisKey, Duration.ofDays(1)); + } + + return true; + } + + private String getClientIp(HttpServletRequest request) { + for (String header : PROXY_HEADER_NAMES) { + String ip = request.getHeader(header); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + return ip.split(",")[0].trim(); + } + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/model/VisitLog.java b/src/main/java/org/withtime/be/withtimebe/domain/log/model/VisitLog.java new file mode 100644 index 0000000..69e9962 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/model/VisitLog.java @@ -0,0 +1,31 @@ +package org.withtime.be.withtimebe.domain.log.model; + +import java.time.LocalDate; +import java.time.LocalTime; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.withtime.be.withtimebe.global.common.BaseEntity; + +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 = "visit_logs") +public class VisitLog extends BaseEntity { + + @Id + private String id; + + private LocalDate date; + + private LocalTime hour; + + private Long count; +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/repository/VisitLogRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/log/repository/VisitLogRepository.java new file mode 100644 index 0000000..50a1f81 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/repository/VisitLogRepository.java @@ -0,0 +1,12 @@ +package org.withtime.be.withtimebe.domain.log.repository; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.mongodb.repository.MongoRepository; +import org.withtime.be.withtimebe.domain.log.model.VisitLog; + +public interface VisitLogRepository extends MongoRepository { + List findByDateBetween(LocalDate dateAfter, LocalDate dateBefore); + List findByDate(LocalDate date); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/scheduler/VisitCountScheduler.java b/src/main/java/org/withtime/be/withtimebe/domain/log/scheduler/VisitCountScheduler.java new file mode 100644 index 0000000..e43b042 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/scheduler/VisitCountScheduler.java @@ -0,0 +1,61 @@ +package org.withtime.be.withtimebe.domain.log.scheduler; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Set; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.log.model.VisitLog; +import org.withtime.be.withtimebe.domain.log.repository.VisitLogRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "scheduler.visit-logs.enabled", havingValue = "true") +public class VisitCountScheduler { + + private final RedisTemplate redisTemplate; + private final VisitLogRepository visitLogRepository; + + // 매 정각마다 Redis 방문자 로그를 DB에 저장하는 스케쥴러 (기획 완성되면 변경) + @Scheduled(cron = "${scheduler.visit-logs.visit-logs-cron}") + public void saveHourlyVisitCounts() { + + LocalDate today = LocalDate.now(); + Integer hour = LocalTime.now().getHour(); + + if (hour == 0) { + today = today.minusDays(1); + hour = 23; + } else { + hour--; + } + + // Key + String redisKey = String.format("visitCount:%s:%02d", today, hour); + + // Set + Set ipSet = redisTemplate.opsForSet().members(redisKey); + + // Count + Long count = (ipSet != null) ? ipSet.size() : 0L; + + // Model + VisitLog visitLog = VisitLog.builder() + .date(today) + .hour(LocalTime.of(hour, 0)) + .count(count) + .build(); + + // JPA Save + visitLogRepository.save(visitLog); + + // Redis Delete + redisTemplate.delete(redisKey); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/service/query/VisitLogQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/log/service/query/VisitLogQueryService.java new file mode 100644 index 0000000..4aafa3f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/service/query/VisitLogQueryService.java @@ -0,0 +1,11 @@ +package org.withtime.be.withtimebe.domain.log.service.query; + +import java.time.LocalDate; +import java.util.List; + +import org.withtime.be.withtimebe.domain.log.model.VisitLog; + +public interface VisitLogQueryService { + List findDailyVisitLogList(); + List findHourlyVisitLogList(LocalDate date); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/log/service/query/VisitLogQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/log/service/query/VisitLogQueryServiceImpl.java new file mode 100644 index 0000000..75ba1b8 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/log/service/query/VisitLogQueryServiceImpl.java @@ -0,0 +1,33 @@ +package org.withtime.be.withtimebe.domain.log.service.query; + +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.model.VisitLog; +import org.withtime.be.withtimebe.domain.log.repository.VisitLogRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VisitLogQueryServiceImpl implements VisitLogQueryService { + + private final VisitLogRepository visitLogRepository; + + @Override + public List findDailyVisitLogList() { + + LocalDate dateBefore = LocalDate.now(); + LocalDate dateAfter = dateBefore.minusDays(6); + + return visitLogRepository.findByDateBetween(dateBefore, dateAfter); + } + + @Override + public List findHourlyVisitLogList(LocalDate date) { + return visitLogRepository.findByDate(date); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/controller/command/MemberCommandController.java b/src/main/java/org/withtime/be/withtimebe/domain/member/controller/command/MemberCommandController.java new file mode 100644 index 0000000..ba6c4d4 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/controller/command/MemberCommandController.java @@ -0,0 +1,48 @@ +package org.withtime.be.withtimebe.domain.member.controller.command; + +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.withtime.be.withtimebe.domain.member.converter.MemberConverter; +import org.withtime.be.withtimebe.domain.member.dto.request.MemberRequestDTO; +import org.withtime.be.withtimebe.domain.member.dto.response.MemberResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.service.command.MemberCommandService; +import org.withtime.be.withtimebe.global.annotation.SwaggerPageable; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/members") +public class MemberCommandController { + + private final MemberCommandService memberCommandService; + + @Operation(summary = "어드민 멤버십 관리하기 API by 피우 [Only Admin]", description = "어드민 창의 멤버십 수정하기 API입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse(responseCode = "400", + description = """ + - MEMBER404_1 : 사용자를 찾지 못했습니다. + - MEMBER404_2 : 멤버십을 찾지 못했습니다. + """) + }) + @SwaggerPageable + @PutMapping("/{memberId}/membership") + public DefaultResponse updateMembership( + @PathVariable Long memberId, + @RequestBody @Valid MemberRequestDTO.UpdateMembership request + ) { + Member result = memberCommandService.updateMembership(request, memberId); + MemberResponseDTO.Membership response = MemberConverter.toMembership(result); + return DefaultResponse.ok(response); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/controller/query/MemberQueryController.java b/src/main/java/org/withtime/be/withtimebe/domain/member/controller/query/MemberQueryController.java new file mode 100644 index 0000000..782fc0a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/controller/query/MemberQueryController.java @@ -0,0 +1,40 @@ +package org.withtime.be.withtimebe.domain.member.controller.query; + +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +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.member.converter.MemberConverter; +import org.withtime.be.withtimebe.domain.member.dto.response.MemberResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.service.query.MemberQueryService; +import org.withtime.be.withtimebe.global.annotation.SwaggerPageable; + +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/members") +public class MemberQueryController { + + private final MemberQueryService memberQueryService; + + @Operation(summary = "멤버십 목록 전체 조회 API by 피우 [Only Admin]", description = "멤버십 목록 전체 조회 API입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다.") + }) + @SwaggerPageable + @GetMapping("/membership") + public DefaultResponse findMembershipList(@PageableDefault(page = 0, size = 10) Pageable pageable) { + Page result = memberQueryService.findMemberList(pageable); + MemberResponseDTO.MembershipList response = MemberConverter.toMembershipList(result); + return DefaultResponse.ok(response); + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/converter/MemberConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/member/converter/MemberConverter.java new file mode 100644 index 0000000..dcdb03c --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/converter/MemberConverter.java @@ -0,0 +1,61 @@ +package org.withtime.be.withtimebe.domain.member.converter; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.withtime.be.withtimebe.domain.member.dto.response.MemberResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.entity.Payments; +import org.withtime.be.withtimebe.domain.member.entity.enums.BillingStatus; +import org.withtime.be.withtimebe.domain.member.entity.enums.UserRank; + +public class MemberConverter { + + // Response DTO : MemberResponseDTO.MembershipList + public static MemberResponseDTO.MembershipList toMembershipList(Page memberPage) { + + List memberList = memberPage.stream() + .map(MemberConverter::toMembership) + .toList(); + + return MemberResponseDTO.MembershipList.builder() + .membershipList(memberList) + .totalPages(memberPage.getTotalPages()) + .currentPage(memberPage.getNumber()) + .currentSize(memberPage.getNumberOfElements()) + .hasNextPage(memberPage.hasNext()) + .build(); + } + + // Response DTO : MemberResponseDTO.Membership + public static MemberResponseDTO.Membership toMembership(Member member) { + + List paymentsList = member.getPaymentList(); + + // 멤버십 보유 여부 + boolean hasMembership = member.getUserRank().equals(UserRank.PREMIUM); + + // 멤버십 총 가입 기간 + LocalDate today = LocalDate.now(); + + Long totalDays = paymentsList.stream() + .filter(payment -> payment.getBillingStatus().equals(BillingStatus.COMPLETED)) + .mapToLong(payment -> { + LocalDate start = payment.getBillingDate().toLocalDate(); + LocalDate end = payment.getMembershipExpireDate().toLocalDate(); + if (end.isBefore(today)) return ChronoUnit.DAYS.between(start, end); // 만료 멤버십 + else return ChronoUnit.DAYS.between(start, today); // 아직 유효한 멤버십 + }) + .sum(); + + return MemberResponseDTO.Membership.builder() + .memberId(member.getId()) + .name(member.getNickname()) + .hasMembership(hasMembership) + .membershipDuration(totalDays) + .createdAt(member.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/dto/request/MemberRequestDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/member/dto/request/MemberRequestDTO.java new file mode 100644 index 0000000..314b7c1 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/dto/request/MemberRequestDTO.java @@ -0,0 +1,17 @@ +package org.withtime.be.withtimebe.domain.member.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; + +public class MemberRequestDTO { + + @Builder + public record UpdateMembership( + @NotNull(message = "멤버십 연장 일수를 입력해주세요") + @PositiveOrZero(message = "0 이상의 수를 입력해주세요") + Long extendDays, + @NotNull(message = "멤비십 취소 여부를 입력해주세요") + Boolean cancelMembership + ) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/dto/response/MemberResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/member/dto/response/MemberResponseDTO.java new file mode 100644 index 0000000..b223cb9 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/dto/response/MemberResponseDTO.java @@ -0,0 +1,27 @@ +package org.withtime.be.withtimebe.domain.member.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Builder; + +public class MemberResponseDTO { + + @Builder + public record MembershipList( + List membershipList, // 멤버십 목록 + Integer totalPages, // 전체 페이지 개수 + Integer currentPage, // 현재 페이지 번호 + Integer currentSize, // 현재 페이지의 크기 + Boolean hasNextPage // 다음 페이지 존재 여부 + ) {} + + @Builder + public record Membership( + Long memberId, // 회원 식별자 값 + String name, // 사용자 닉네임 + Boolean hasMembership, // 멤버십 유무 + Long membershipDuration, // 멤버십 유지기간 + LocalDateTime createdAt // 회원가입 일자 + ) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Member.java b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Member.java index fb0aba4..bbd2f65 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Member.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Member.java @@ -2,14 +2,17 @@ import jakarta.persistence.*; import lombok.*; + +import org.hibernate.annotations.BatchSize; import org.withtime.be.withtimebe.domain.member.entity.enums.Gender; -import org.withtime.be.withtimebe.domain.member.entity.enums.ProviderType; import org.withtime.be.withtimebe.domain.member.entity.enums.Role; import org.withtime.be.withtimebe.domain.member.entity.enums.UserRank; import org.withtime.be.withtimebe.global.common.BaseEntity; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Getter @@ -59,4 +62,13 @@ public class Member extends BaseEntity { @Enumerated(EnumType.STRING) @Column(name = "role", nullable = false) private Role role; + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + @BatchSize(size = 10) + @Builder.Default + private List paymentList = new ArrayList<>(); + + public void updateUserRank(UserRank userRank) { + this.userRank = userRank; + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Payments.java b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Payments.java index 6eb2b39..0df6403 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Payments.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Payments.java @@ -1,7 +1,9 @@ package org.withtime.be.withtimebe.domain.member.entity; import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; import lombok.*; + import org.withtime.be.withtimebe.domain.member.entity.enums.BillingStatus; import org.withtime.be.withtimebe.global.common.BaseEntity; @@ -39,4 +41,11 @@ public class Payments extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") private Member member; + + @Column(name = "membership_expire_date") + private LocalDateTime membershipExpireDate; + + public void updateExpireDate(LocalDateTime membershipExpireDate) { + this.membershipExpireDate = membershipExpireDate; + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/service/MemberQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/member/service/MemberQueryService.java deleted file mode 100644 index d5b99a8..0000000 --- a/src/main/java/org/withtime/be/withtimebe/domain/member/service/MemberQueryService.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.withtime.be.withtimebe.domain.member.service; - -import org.withtime.be.withtimebe.domain.member.entity.Member; - -public interface MemberQueryService { - Member findById(Long id); -} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/service/command/MemberCommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/member/service/command/MemberCommandService.java new file mode 100644 index 0000000..859793e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/service/command/MemberCommandService.java @@ -0,0 +1,9 @@ +package org.withtime.be.withtimebe.domain.member.service.command; + +import org.withtime.be.withtimebe.domain.member.dto.request.MemberRequestDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +public interface MemberCommandService { + + Member updateMembership(MemberRequestDTO.UpdateMembership request, Long memberId); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/service/command/MemberCommandServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/member/service/command/MemberCommandServiceImpl.java new file mode 100644 index 0000000..07f241a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/service/command/MemberCommandServiceImpl.java @@ -0,0 +1,48 @@ +package org.withtime.be.withtimebe.domain.member.service.command; + +import java.time.LocalDateTime; +import java.util.Comparator; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.member.dto.request.MemberRequestDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.entity.Payments; +import org.withtime.be.withtimebe.domain.member.entity.enums.BillingStatus; +import org.withtime.be.withtimebe.domain.member.entity.enums.UserRank; +import org.withtime.be.withtimebe.domain.member.repository.MemberRepository; +import org.withtime.be.withtimebe.global.error.code.MemberErrorCode; +import org.withtime.be.withtimebe.global.error.exception.MemberException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = false) +public class MemberCommandServiceImpl implements MemberCommandService { + + private final MemberRepository memberRepository; + + @Override + public Member updateMembership(MemberRequestDTO.UpdateMembership request, Long memberId) { + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND)); + + // 가장 최근 멤버십 결제 정보 탐색 + Payments latestPayment = member.getPaymentList().stream() + .filter(payment -> payment.getBillingStatus().equals(BillingStatus.COMPLETED)) + .max(Comparator.comparing(Payments::getMembershipExpireDate)) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBERSHIP_NOT_FOUND)); + + if (request.extendDays() > 0) { + latestPayment.updateExpireDate(latestPayment.getMembershipExpireDate().plusDays(request.extendDays())); + } + if (request.cancelMembership() == true) { + latestPayment.updateExpireDate(LocalDateTime.now()); + member.updateUserRank(UserRank.COMMON); // 일반 등급으로 변경 + } + + return member; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/service/query/MemberQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/member/service/query/MemberQueryService.java new file mode 100644 index 0000000..6d81102 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/service/query/MemberQueryService.java @@ -0,0 +1,11 @@ +package org.withtime.be.withtimebe.domain.member.service.query; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +public interface MemberQueryService { + Member findById(Long id); + + Page findMemberList(Pageable pageable); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/service/MemberQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/member/service/query/MemberQueryServiceImpl.java similarity index 64% rename from src/main/java/org/withtime/be/withtimebe/domain/member/service/MemberQueryServiceImpl.java rename to src/main/java/org/withtime/be/withtimebe/domain/member/service/query/MemberQueryServiceImpl.java index b853a3d..4569111 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/member/service/MemberQueryServiceImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/service/query/MemberQueryServiceImpl.java @@ -1,7 +1,11 @@ -package org.withtime.be.withtimebe.domain.member.service; +package org.withtime.be.withtimebe.domain.member.service.query; import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.withtime.be.withtimebe.domain.member.entity.Member; import org.withtime.be.withtimebe.domain.member.repository.MemberRepository; import org.withtime.be.withtimebe.global.error.code.MemberErrorCode; @@ -9,6 +13,7 @@ @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class MemberQueryServiceImpl implements MemberQueryService { private final MemberRepository memberRepository; @@ -17,4 +22,9 @@ public class MemberQueryServiceImpl implements MemberQueryService { public Member findById(Long id) { return memberRepository.findById(id).orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND)); } + + @Override + public Page findMemberList(Pageable pageable) { + return memberRepository.findAll(pageable); + } } 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..5abca13 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/config/MongoConfig.java @@ -0,0 +1,47 @@ +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.AbstractMongoClientConfiguration; +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.withtime.be.withtimebe.global.converter.MongoConverters; + +import lombok.AllArgsConstructor; + +@Configuration +@AllArgsConstructor +public class MongoConfig { + + // KST <-> UTC 커스텀 컨버터 빈 등록 + @Bean + public MongoCustomConversions mongoCustomConversions() { + return new MongoCustomConversions(List.of( + new MongoConverters.LocalDateToDateKstConverter(), + new MongoConverters.LocalTimeToDateKstConverter(), + new MongoConverters.DateToLocalDateKstConverter(), + new MongoConverters.DateToLocalTimeKstConverter() + )); + } + + // 커스텀 컨버터 등록 및 _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/config/WebConfig.java b/src/main/java/org/withtime/be/withtimebe/global/config/WebConfig.java index 70b377d..abc7531 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/config/WebConfig.java +++ b/src/main/java/org/withtime/be/withtimebe/global/config/WebConfig.java @@ -4,7 +4,9 @@ import org.springframework.context.annotation.Configuration; import org.springframework.format.FormatterRegistry; import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.withtime.be.withtimebe.domain.log.interceptor.VisitCountInterceptor; import org.withtime.be.withtimebe.domain.notice.converter.NoticeCategoryConverter; import org.withtime.be.withtimebe.domain.faq.converter.FaqCategoryConverter; import org.withtime.be.withtimebe.global.security.annotation.resolver.AuthenticatedMemberResolver; @@ -16,12 +18,19 @@ public class WebConfig implements WebMvcConfigurer { private final AuthenticatedMemberResolver authenticatedMemberResolver; + private final VisitCountInterceptor visitCountInterceptor; @Override public void addArgumentResolvers(List resolvers) { resolvers.add(authenticatedMemberResolver); } + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(visitCountInterceptor) + .addPathPatterns("/**"); + } + @Override public void addFormatters(FormatterRegistry registry) { registry.addConverter(new NoticeCategoryConverter()); 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..c69dd16 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/converter/MongoConverters.java @@ -0,0 +1,57 @@ +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); + } + } + + @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(); + } + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/code/MemberErrorCode.java b/src/main/java/org/withtime/be/withtimebe/global/error/code/MemberErrorCode.java index 587e3be..1637cae 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/error/code/MemberErrorCode.java +++ b/src/main/java/org/withtime/be/withtimebe/global/error/code/MemberErrorCode.java @@ -9,7 +9,7 @@ public enum MemberErrorCode implements BaseErrorCode { NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "사용자를 찾지 못했습니다."), - ; + MEMBERSHIP_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_2", "멤버십을 찾지 못했습니다."); private final HttpStatus httpStatus; private final String code; 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 b256970..81eea4c 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 @@ -27,7 +27,7 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.withtime.be.withtimebe.domain.auth.service.query.TokenStorageQueryService; -import org.withtime.be.withtimebe.domain.member.service.MemberQueryService; +import org.withtime.be.withtimebe.domain.member.service.query.MemberQueryService; import org.withtime.be.withtimebe.global.security.filter.JsonLoginFilter; import org.withtime.be.withtimebe.global.security.filter.JwtFilter; import org.withtime.be.withtimebe.global.security.handler.CustomAccessDeniedHandler; @@ -53,7 +53,15 @@ public class SecurityConfig { API_PREFIX + "/faqs/**", "/swagger-ui/**", "/swagger-resources/**", - "/v3/api-docs/**" + "/v3/api-docs/**", + }; + + private RequestMatcher[] admin = { + requestMatcher(HttpMethod.GET, API_PREFIX + "/members/membership"), + requestMatcher(HttpMethod.PUT, API_PREFIX + "/members/*/membership"), + requestMatcher(HttpMethod.GET, API_PREFIX + "/visit-logs/**"), + + requestMatcher(HttpMethod.GET, API_PREFIX + "/dateplaces/manage"), }; private RequestMatcher[] admin = { diff --git a/src/main/java/org/withtime/be/withtimebe/global/security/filter/JwtFilter.java b/src/main/java/org/withtime/be/withtimebe/global/security/filter/JwtFilter.java index fd23f49..5792111 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/security/filter/JwtFilter.java +++ b/src/main/java/org/withtime/be/withtimebe/global/security/filter/JwtFilter.java @@ -20,7 +20,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import org.withtime.be.withtimebe.domain.auth.service.query.TokenStorageQueryService; import org.withtime.be.withtimebe.domain.member.entity.Member; -import org.withtime.be.withtimebe.domain.member.service.MemberQueryService; +import org.withtime.be.withtimebe.domain.member.service.query.MemberQueryService; import org.withtime.be.withtimebe.global.security.constants.AuthenticationConstants; import org.withtime.be.withtimebe.global.security.domain.CustomUserDetails; import org.withtime.be.withtimebe.global.util.CookieUtil; diff --git a/src/main/resources/application-develop.yml b/src/main/resources/application-develop.yml index 8290dfd..a25e957 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 @@ -82,4 +84,8 @@ scheduler: retention: short-term-days: 7 # 단기 예보 보관 기간 medium-term-days: 7 # 중기 예보 보관 기간 - recommendation-days: 30 # 추천 정보 보관 기간 \ No newline at end of file + recommendation-days: 30 # 추천 정보 보관 기간 + + visit-logs: + enabled: true # 방문자 기록 스케쥴러 활성화 여부 + visit-logs-cron: "0 0 * * * *" # 매 정각 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 91d21de..2924288 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,6 +11,23 @@ spring: show-sql: true hibernate: ddl-auto: update + data: + redis: + host: ${REDIS_HOST} + port: 6379 + mongodb: + uri: ${MONGO_URI} + mail: + host: ${MAIL_SENDER_HOST} + port: 587 + username: ${MAIL_SENDER_USERNAME} + password: ${MAIL_SENDER_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true jwt: secret: ${JWT_SECRET} @@ -67,4 +84,8 @@ scheduler: retention: short-term-days: 7 # 단기 예보 보관 기간 medium-term-days: 7 # 중기 예보 보관 기간 - recommendation-days: 30 # 추천 정보 보관 기간 \ No newline at end of file + recommendation-days: 30 # 추천 정보 보관 기간 + + visit-logs: + enabled: true # 방문자 기록 스케쥴러 활성화 여부 + visit-logs-cron: "0 0 * * * *" # 매 정각