From 1dec8913c5e8444b1e4d2026f7be97ce34b0cf95 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 14 Mar 2025 21:48:52 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20-=20=EC=9D=BC?= =?UTF-8?q?=EB=B3=84=20=EC=A0=84=EC=B2=B4=20=ED=9A=8C=EC=9B=90=EC=88=98=20?= =?UTF-8?q?API=20-=20=EC=9D=BC=EB=B3=84=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85,=20=ED=9A=8C=EC=9B=90=ED=83=88=ED=87=B4,=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=EC=9E=90=20=EC=88=98=EB=A5=BC=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=ED=95=98=EB=8A=94=20API=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/AdminMemberStatisticsController.kt | 28 +++++ .../member/AdminMemberStatisticsRepository.kt | 110 ++++++++++++++++++ .../member/AdminMemberStatisticsService.kt | 94 +++++++++++++++ .../statistics/member/DateAndMemberCount.kt | 8 ++ .../member/GetMemberStatisticsResponse.kt | 16 +++ .../admin/statistics/member/PagedDateRange.kt | 9 ++ 6 files changed, 265 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/DateAndMemberCount.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/GetMemberStatisticsResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/PagedDateRange.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsController.kt new file mode 100644 index 0000000..ccab70f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsController.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.admin.statistics.member + +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +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 + +@RestController +@PreAuthorize("hasRole('ADMIN')") +@RequestMapping("/admin/member/statistics") +class AdminMemberStatisticsController(private val service: AdminMemberStatisticsService) { + @GetMapping + fun getStatistics( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + pageable: Pageable + ) = ApiResponse.ok( + service.getStatistics( + startDateStr = startDateStr, + endDateStr = endDateStr, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsRepository.kt new file mode 100644 index 0000000..d0e55db --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsRepository.kt @@ -0,0 +1,110 @@ +package kr.co.vividnext.sodalive.admin.statistics.member + +import com.querydsl.core.types.dsl.DateTimePath +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.core.types.dsl.StringTemplate +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.charge.ChargeStatus +import kr.co.vividnext.sodalive.can.charge.QCharge.charge +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.can.payment.QPayment.payment +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.member.QSignOut.signOut +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class AdminMemberStatisticsRepository(private val queryFactory: JPAQueryFactory) { + fun getTotalSignUpCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { + return queryFactory + .select(member.id) + .from(member) + .where( + member.createdAt.goe(startDate), + member.createdAt.loe(endDate) + ) + .fetch() + .size + } + + fun getTotalSignOutCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { + return queryFactory + .select(signOut.id) + .from(signOut) + .where( + signOut.createdAt.goe(startDate), + signOut.createdAt.loe(endDate) + ) + .fetch() + .size + } + + fun getSignUpCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List { + return queryFactory + .select( + QDateAndMemberCount( + getFormattedDate(member.createdAt), + member.id.countDistinct().castToNum(Int::class.java) + ) + ) + .from(member) + .where( + member.createdAt.goe(startDate), + member.createdAt.loe(endDate) + ) + .groupBy(getFormattedDate(member.createdAt)) + .fetch() + } + + fun getSignOutCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List { + return queryFactory + .select( + QDateAndMemberCount( + getFormattedDate(signOut.createdAt), + signOut.id.countDistinct().castToNum(Int::class.java) + ) + ) + .from(signOut) + .where( + signOut.createdAt.goe(startDate), + signOut.createdAt.loe(endDate) + ) + .groupBy(getFormattedDate(signOut.createdAt)) + .fetch() + } + + fun getPaymentMemberCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List { + return queryFactory + .select( + QDateAndMemberCount( + getFormattedDate(charge.createdAt), + member.id.countDistinct().castToNum(Int::class.java) + ) + ) + .from(charge) + .innerJoin(charge.member, member) + .leftJoin(charge.payment, payment) + .where( + charge.status.eq(ChargeStatus.CHARGE), + payment.status.eq(PaymentStatus.COMPLETE), + charge.createdAt.goe(startDate), + charge.createdAt.loe(endDate) + ) + .groupBy(getFormattedDate(charge.createdAt)) + .fetch() + } + + private fun getFormattedDate(dateTimePath: DateTimePath): StringTemplate { + return Expressions.stringTemplate( + "DATE_FORMAT({0}, {1})", + Expressions.dateTimeTemplate( + LocalDateTime::class.java, + "CONVERT_TZ({0},{1},{2})", + dateTimePath, + "UTC", + "Asia/Seoul" + ), + "%Y-%m-%d" + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsService.kt new file mode 100644 index 0000000..d61f1da --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsService.kt @@ -0,0 +1,94 @@ +package kr.co.vividnext.sodalive.admin.statistics.member + +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +@Service +class AdminMemberStatisticsService(private val repository: AdminMemberStatisticsRepository) { + fun getStatistics( + startDateStr: String, + endDateStr: String, + offset: Long, + limit: Long + ): GetMemberStatisticsResponse { + val dateRange = getPagedDateRange( + startDate = startDateStr, + endDate = endDateStr, + page = (offset + 1).toInt(), + pageSize = limit.toInt() + ) + + if (dateRange == null) { + throw SodaException("잘못된 접근입니다.") + } + + val startDate = dateRange.startDate + .atZone(ZoneId.of("Asia/Seoul")) + .withZoneSameInstant(ZoneId.of("UTC")) + .toLocalDateTime() + + val endDate = dateRange.endDate + .atZone(ZoneId.of("Asia/Seoul")) + .withZoneSameInstant(ZoneId.of("UTC")) + .toLocalDateTime() + + val totalSignUpCount = repository.getTotalSignUpCount(startDate = startDate, endDate = endDate) + val totalSignOutCount = repository.getTotalSignOutCount(startDate = startDate, endDate = endDate) + + val signUpCountInRange = repository.getSignUpCountInRange(startDate = startDate, endDate = endDate) + .associateBy({ it.date }, { it.memberCount }) + + val signOutCountInRange = repository.getSignOutCountInRange(startDate = startDate, endDate = endDate) + .associateBy({ it.date }, { it.memberCount }) + + val paymentMemberCountInRange = repository.getPaymentMemberCountInRange( + startDate = startDate, + endDate = endDate + ) + + val totalPaymentMemberCount = paymentMemberCountInRange.sumOf { it.memberCount } + val paymentMemberCountInRangeMap = paymentMemberCountInRange.associateBy({ it.date }, { it.memberCount }) + + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val items = generateSequence(dateRange.startDate) { it.plusDays(1) } + .takeWhile { !it.isAfter(dateRange.endDate) } + .map { + val date = it.format(formatter) + GetMemberStatisticsItem( + date = date, + signUpCount = signUpCountInRange[date] ?: 0, + signOutCount = signOutCountInRange[date] ?: 0, + paymentMemberCount = paymentMemberCountInRangeMap[date] ?: 0 + ) + } + .toList() + + return GetMemberStatisticsResponse( + totalCount = dateRange.totalDays, + totalSignUpCount = totalSignUpCount, + totalSignOutCount = totalSignOutCount, + totalPaymentMemberCount = totalPaymentMemberCount, + items = items + ) + } + + private fun getPagedDateRange(startDate: String, endDate: String, page: Int, pageSize: Int): PagedDateRange? { + val start = LocalDate.parse(startDate) + val end = LocalDate.parse(endDate) + + val totalDays = ChronoUnit.DAYS.between(start, end).toInt() + 1 + val totalPages = (totalDays + pageSize - 1) / pageSize // 전체 페이지 개수 계산 + + if (page < 1 || page > totalPages) return null // 페이지 범위를 벗어나면 null 반환 + + val rangeStart = start.plusDays((page - 1) * pageSize.toLong()).atStartOfDay() + val rangeEnd = start.plusDays((page * pageSize - 1).toLong()).coerceAtMost(end).atTime(LocalTime.MAX) + + return PagedDateRange(rangeStart, rangeEnd, totalDays) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/DateAndMemberCount.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/DateAndMemberCount.kt new file mode 100644 index 0000000..531db9a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/DateAndMemberCount.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.admin.statistics.member + +import com.querydsl.core.annotations.QueryProjection + +data class DateAndMemberCount @QueryProjection constructor( + val date: String, + val memberCount: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/GetMemberStatisticsResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/GetMemberStatisticsResponse.kt new file mode 100644 index 0000000..7a0d7ea --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/GetMemberStatisticsResponse.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.admin.statistics.member + +data class GetMemberStatisticsResponse( + val totalCount: Int, + val totalSignUpCount: Int, + val totalSignOutCount: Int, + val totalPaymentMemberCount: Int, + val items: List +) + +data class GetMemberStatisticsItem( + val date: String, + val signUpCount: Int, + val signOutCount: Int, + val paymentMemberCount: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/PagedDateRange.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/PagedDateRange.kt new file mode 100644 index 0000000..8bc8609 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/PagedDateRange.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.admin.statistics.member + +import java.time.LocalDateTime + +data class PagedDateRange( + val startDate: LocalDateTime, + val endDate: LocalDateTime, + val totalDays: Int +)