test #286

Merged
klaus merged 6 commits from test into main 2025-03-14 16:11:18 +00:00
6 changed files with 327 additions and 0 deletions

View File

@ -0,0 +1,27 @@
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,
pageable = pageable
)
)
}

View File

@ -0,0 +1,153 @@
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 getPaymentMemberCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
return queryFactory
.select(
QDateAndMemberCount(
getFormattedDate(charge.createdAt),
member.id.countDistinct().castToNum(Int::class.java)
)
)
.from(charge)
.innerJoin(charge.member, member)
.innerJoin(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()
.sumOf { it.memberCount }
}
fun getSignUpCountInRange(
startDate: LocalDateTime,
endDate: LocalDateTime,
offset: Long,
limit: Long
): List<DateAndMemberCount> {
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))
.offset(offset)
.limit(limit)
.fetch()
}
fun getSignOutCountInRange(
startDate: LocalDateTime,
endDate: LocalDateTime,
offset: Long,
limit: Long
): List<DateAndMemberCount> {
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))
.offset(offset)
.limit(limit)
.fetch()
}
fun getPaymentMemberCountInRange(
startDate: LocalDateTime,
endDate: LocalDateTime,
offset: Long,
limit: Long
): List<DateAndMemberCount> {
return queryFactory
.select(
QDateAndMemberCount(
getFormattedDate(charge.createdAt),
member.id.countDistinct().castToNum(Int::class.java)
)
)
.from(charge)
.innerJoin(charge.member, member)
.innerJoin(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))
.offset(offset)
.limit(limit)
.fetch()
}
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): 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"
)
}
}

View File

@ -0,0 +1,114 @@
package kr.co.vividnext.sodalive.admin.statistics.member
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Pageable
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,
pageable: Pageable
): GetMemberStatisticsResponse {
val dateRange = getPagedDateRange(
startDate = startDateStr,
endDate = endDateStr,
page = pageable.pageNumber + 1,
pageSize = pageable.pageSize
)
if (dateRange == null) {
throw SodaException("잘못된 접근입니다.")
}
var startDate = LocalDate.parse(startDateStr).atStartOfDay()
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
var endDate = LocalDate.parse(endDateStr).atTime(LocalTime.MAX)
.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 totalPaymentMemberCount = repository.getPaymentMemberCount(startDate = startDate, endDate = endDate)
startDate = dateRange.startDate
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
endDate = dateRange.endDate
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val signUpCountInRange = repository.getSignUpCountInRange(
startDate = startDate,
endDate = endDate,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
).associateBy({ it.date }, { it.memberCount })
val signOutCountInRange = repository.getSignOutCountInRange(
startDate = startDate,
endDate = endDate,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
).associateBy({ it.date }, { it.memberCount })
val paymentMemberCountInRange = repository.getPaymentMemberCountInRange(
startDate = startDate,
endDate = endDate,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
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)
}
}

View File

@ -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
)

View File

@ -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<GetMemberStatisticsItem>
)
data class GetMemberStatisticsItem(
val date: String,
val signUpCount: Int,
val signOutCount: Int,
val paymentMemberCount: Int
)

View File

@ -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
)