diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java index be4ba74d..a22d2715 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java @@ -1,8 +1,10 @@ package com.devkor.ifive.nadab.domain.auth.application; -import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason; +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEvent; +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEventReason; import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; -import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventReasonRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; import com.devkor.ifive.nadab.global.core.response.ErrorCode; @@ -26,7 +28,8 @@ public class AuthServiceV2 { private final WithdrawalService withdrawalService; private final UserRepository userRepository; - private final UserWithdrawalReasonRepository userWithdrawalReasonRepository; + private final WithdrawalEventRepository withdrawalEventRepository; + private final WithdrawalEventReasonRepository withdrawalEventReasonRepository; public void withdrawUser(Long userId, List reasons, String customReason) { List validatedReasons = validateReasons(reasons); @@ -41,17 +44,15 @@ public void withdrawUser(Long userId, List reasons, String OffsetDateTime effectiveWithdrawnAt = user.getDeletedAt() != null ? user.getDeletedAt() : OffsetDateTime.now(); - List entities = new ArrayList<>(validatedReasons.size()); + WithdrawalEvent event = withdrawalEventRepository.save( + WithdrawalEvent.create(user, effectiveWithdrawnAt) + ); + List eventReasons = new ArrayList<>(validatedReasons.size()); for (WithdrawalReasonType reason : validatedReasons) { String detail = reason == WithdrawalReasonType.OTHER ? normalizedCustomReason : null; - entities.add(UserWithdrawalReason.create( - user, - reason, - detail, - effectiveWithdrawnAt - )); + eventReasons.add(WithdrawalEventReason.create(event, reason, detail)); } - userWithdrawalReasonRepository.saveAll(entities); + withdrawalEventReasonRepository.saveAll(eventReasons); } private List validateReasons(List reasons) { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEvent.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEvent.java new file mode 100644 index 00000000..2189a731 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEvent.java @@ -0,0 +1,52 @@ +package com.devkor.ifive.nadab.domain.auth.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; + +@Entity +@Table( + name = "withdrawal_events", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_withdrawal_events_user_withdrawn_at", + columnNames = {"user_id", "withdrawn_at"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WithdrawalEvent extends CreatableEntity { + + private static final int RETENTION_YEARS = 1; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "withdrawn_at", nullable = false) + private OffsetDateTime withdrawnAt; + + @Column(name = "anonymized_at") + private OffsetDateTime anonymizedAt; + + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + public static WithdrawalEvent create(User user, OffsetDateTime withdrawnAt) { + WithdrawalEvent event = new WithdrawalEvent(); + event.user = user; + event.withdrawnAt = withdrawnAt; + event.expiresAt = withdrawnAt.plusYears(RETENTION_YEARS); + return event; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEventReason.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEventReason.java new file mode 100644 index 00000000..ef743792 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEventReason.java @@ -0,0 +1,49 @@ +package com.devkor.ifive.nadab.domain.auth.core.entity; + +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "withdrawal_event_reasons", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_withdrawal_event_reasons_event_reason", + columnNames = {"event_id", "reason"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WithdrawalEventReason extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "event_id", nullable = false) + private WithdrawalEvent event; + + @Enumerated(EnumType.STRING) + @Column(name = "reason", nullable = false, length = 50) + private WithdrawalReasonType reason; + + @Column(name = "custom_reason", length = 200) + private String customReason; + + public static WithdrawalEventReason create( + WithdrawalEvent event, + WithdrawalReasonType reason, + String customReason + ) { + WithdrawalEventReason eventReason = new WithdrawalEventReason(); + eventReason.event = event; + eventReason.reason = reason; + eventReason.customReason = customReason; + return eventReason; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java index 745450ea..9bfbc000 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java @@ -2,6 +2,20 @@ import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; public interface UserWithdrawalReasonRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + delete from UserWithdrawalReason reason + where reason.withdrawnAt < :anonymizationDate + """) + int deleteReasonsWithdrawnBefore( + @Param("anonymizationDate") OffsetDateTime anonymizationDate + ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventReasonRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventReasonRepository.java new file mode 100644 index 00000000..546fe517 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventReasonRepository.java @@ -0,0 +1,25 @@ +package com.devkor.ifive.nadab.domain.auth.core.repository; + +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEventReason; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; + +public interface WithdrawalEventReasonRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + update withdrawal_event_reasons reason + set custom_reason = null + from withdrawal_events event + where event.id = reason.event_id + and event.withdrawn_at < :anonymizationDate + and reason.custom_reason is not null + """, nativeQuery = true) + int clearCustomReasonsWithdrawnBefore( + @Param("anonymizationDate") OffsetDateTime anonymizationDate + ); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRepository.java new file mode 100644 index 00000000..e94177b4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRepository.java @@ -0,0 +1,32 @@ +package com.devkor.ifive.nadab.domain.auth.core.repository; + +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; + +public interface WithdrawalEventRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + delete from WithdrawalEvent event + where event.expiresAt <= :expirationDate + """) + int deleteExpiredEvents(@Param("expirationDate") OffsetDateTime expirationDate); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update WithdrawalEvent event + set event.user = null, + event.anonymizedAt = :anonymizedAt + where event.withdrawnAt < :anonymizationDate + and (event.user is not null or event.anonymizedAt is null) + """) + int anonymizeEventsWithdrawnBefore( + @Param("anonymizationDate") OffsetDateTime anonymizationDate, + @Param("anonymizedAt") OffsetDateTime anonymizedAt + ); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/scheduler/WithdrawalEventCleanupScheduler.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/scheduler/WithdrawalEventCleanupScheduler.java new file mode 100644 index 00000000..2efddaa1 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/scheduler/WithdrawalEventCleanupScheduler.java @@ -0,0 +1,56 @@ +package com.devkor.ifive.nadab.domain.auth.infra.scheduler; + +import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventReasonRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WithdrawalEventCleanupScheduler { + + private static final int ACCOUNT_RECOVERY_PERIOD_DAYS = 14; + + private final WithdrawalEventRepository withdrawalEventRepository; + private final WithdrawalEventReasonRepository withdrawalEventReasonRepository; + private final UserWithdrawalReasonRepository userWithdrawalReasonRepository; + + @Scheduled(cron = "0 10 0 * * *", zone = "Asia/Seoul") + @Transactional + public void cleanupWithdrawalEvents() { + OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime anonymizationDate = now.minusDays(ACCOUNT_RECOVERY_PERIOD_DAYS); + + int deletedEventCount = withdrawalEventRepository.deleteExpiredEvents(now); + int deletedLegacyReasonCount = userWithdrawalReasonRepository + .deleteReasonsWithdrawnBefore(anonymizationDate); + int clearedCustomReasonCount = withdrawalEventReasonRepository + .clearCustomReasonsWithdrawnBefore(anonymizationDate); + int anonymizedEventCount = withdrawalEventRepository + .anonymizeEventsWithdrawnBefore(anonymizationDate, now); + + if (deletedEventCount == 0 + && deletedLegacyReasonCount == 0 + && clearedCustomReasonCount == 0 + && anonymizedEventCount == 0) { + log.debug("정리할 탈퇴 이벤트가 없습니다."); + return; + } + + log.info( + "탈퇴 이벤트 정리 완료: 만료 이벤트 {}건 삭제, 레거시 사유 {}건 삭제, " + + "자유 입력 {}건 제거, 이벤트 {}건 비식별화", + deletedEventCount, + deletedLegacyReasonCount, + clearedCustomReasonCount, + anonymizedEventCount + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/AskChatStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/AskChatStatsService.java new file mode 100644 index 00000000..d77f22a4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/AskChatStatsService.java @@ -0,0 +1,260 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyMessageStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyRagDocumentStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyRagReferenceStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyRagStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailySessionStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyWalletStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyWalletStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatErrorStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatMessageSummaryDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatRagErrorStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatRagSourceStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatRagStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatRagSummaryDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatSessionSummaryDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatWalletStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatWalletSummaryDto; +import com.devkor.ifive.nadab.domain.stats.core.repository.AskChatStatsRepository; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AskChatStatsService { + + public static final int DEFAULT_CHART_DAYS = 7; + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter REFRESHED_AT_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final AskChatStatsRepository repository; + + public AskChatStatsViewModel getAskChatStats() { + LocalDate endDate = TodayDateTimeProvider.getTodayDate(); + return getAskChatStats(endDate.minusDays(DEFAULT_CHART_DAYS - 1L), endDate); + } + + public AskChatStatsViewModel getAskChatStats(LocalDate startDate, LocalDate endDate) { + Objects.requireNonNull(startDate, "startDate must not be null"); + Objects.requireNonNull(endDate, "endDate must not be null"); + if (startDate.isAfter(endDate)) { + throw new IllegalArgumentException("startDate must be on or before endDate"); + } + + OffsetDateTime startInclusive = startDate.atStartOfDay(SEOUL).toOffsetDateTime(); + OffsetDateTime endExclusive = endDate.plusDays(1).atStartOfDay(SEOUL).toOffsetDateTime(); + + List dailySessionStats = repository.findDailySessionStats( + startInclusive, + endExclusive + ); + List dailyMessageStats = repository.findDailyMessageStats( + startInclusive, + endExclusive + ); + AskChatSessionSummaryDto sessionSummary = repository.findSessionSummary( + startInclusive, + endExclusive + ); + AskChatMessageSummaryDto messageSummary = repository.findMessageSummary( + startInclusive, + endExclusive + ); + List errorStats = repository.findAssistantErrorStats( + startInclusive, + endExclusive + ); + List dailyWalletStats = repository.findDailyWalletStats( + startInclusive, + endExclusive + ); + AskChatWalletSummaryDto walletSummary = repository.findWalletSummary( + startInclusive, + endExclusive + ); + List dailyRagDocumentStats = repository.findDailyRagDocumentStats( + startInclusive, + endExclusive + ); + List dailyRagReferenceStats = repository.findDailyRagReferenceStats( + startInclusive, + endExclusive + ); + AskChatRagSummaryDto ragSummary = repository.findRagSummary(startInclusive, endExclusive); + List ragSourceStats = repository.findRagSourceStats( + startInclusive, + endExclusive + ); + List ragErrorStats = repository.findRagErrorStats( + startInclusive, + endExclusive + ); + + Map sessionStatsByDate = new HashMap<>(); + dailySessionStats.forEach(stats -> sessionStatsByDate.put(stats.date(), stats)); + Map messageStatsByDate = new HashMap<>(); + dailyMessageStats.forEach(stats -> messageStatsByDate.put(stats.date(), stats)); + Map walletStatsByDate = new HashMap<>(); + dailyWalletStats.forEach(stats -> walletStatsByDate.put(stats.date(), stats)); + Map ragDocumentStatsByDate = new HashMap<>(); + dailyRagDocumentStats.forEach(stats -> ragDocumentStatsByDate.put(stats.date(), stats)); + Map ragReferenceStatsByDate = new HashMap<>(); + dailyRagReferenceStats.forEach(stats -> ragReferenceStatsByDate.put(stats.date(), stats)); + + List dailyStats = startDate.datesUntil(endDate.plusDays(1)) + .map(date -> toDailyViewModel( + date, + sessionStatsByDate.get(date), + messageStatsByDate.get(date), + walletStatsByDate.get(date), + ragDocumentStatsByDate.get(date), + ragReferenceStatsByDate.get(date) + )) + .toList(); + + long assistantResponseCount = messageSummary.totalCompletedAssistantMessageCount() + + messageSummary.totalFailedAssistantMessageCount(); + double assistantSuccessRatePercent = assistantResponseCount == 0 + ? 0.0 + : messageSummary.totalCompletedAssistantMessageCount() * 100.0 / assistantResponseCount; + + return new AskChatStatsViewModel( + startDate, + endDate, + dailyStats, + sessionSummary.totalSessionCount(), + sessionSummary.totalUniqueUserCount(), + sessionSummary.totalActiveSessionCount(), + sessionSummary.totalEndedSessionCount(), + messageSummary.totalUserMessageCount(), + messageSummary.totalCompletedAssistantMessageCount(), + messageSummary.totalFailedAssistantMessageCount(), + assistantSuccessRatePercent, + messageSummary.averageGenerationDurationMs(), + messageSummary.p95GenerationDurationMs(), + errorStats, + toWalletStatsViewModel(walletSummary), + toRagStatsViewModel(ragSummary, ragSourceStats, ragErrorStats), + OffsetDateTime.now(SEOUL).format(REFRESHED_AT_FORMATTER) + ); + } + + private AskChatDailyStatsViewModel toDailyViewModel( + LocalDate date, + AskChatDailySessionStatsDto sessionStats, + AskChatDailyMessageStatsDto messageStats, + AskChatDailyWalletStatsDto walletStats, + AskChatDailyRagDocumentStatsDto ragDocumentStats, + AskChatDailyRagReferenceStatsDto ragReferenceStats + ) { + return new AskChatDailyStatsViewModel( + date, + sessionStats == null ? 0L : sessionStats.sessionCount(), + sessionStats == null ? 0L : sessionStats.uniqueUserCount(), + sessionStats == null ? 0L : sessionStats.activeSessionCount(), + sessionStats == null ? 0L : sessionStats.endedSessionCount(), + messageStats == null ? 0L : messageStats.userMessageCount(), + messageStats == null ? 0L : messageStats.completedAssistantMessageCount(), + messageStats == null ? 0L : messageStats.failedAssistantMessageCount(), + messageStats == null ? 0.0 : messageStats.averageGenerationDurationMs(), + messageStats == null ? 0L : messageStats.p95GenerationDurationMs(), + toDailyWalletStatsViewModel(walletStats), + toDailyRagStatsViewModel(ragDocumentStats, ragReferenceStats) + ); + } + + private AskChatDailyWalletStatsViewModel toDailyWalletStatsViewModel( + AskChatDailyWalletStatsDto stats + ) { + if (stats == null) { + return new AskChatDailyWalletStatsViewModel(0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L); + } + return new AskChatDailyWalletStatsViewModel( + stats.totalLogCount(), + stats.pendingLogCount(), + stats.confirmedLogCount(), + stats.refundedLogCount(), + stats.freeTurnsGranted(), + stats.freeTurnsConsumed(), + stats.paidTurnsConsumed(), + stats.freeTurnsRefunded(), + stats.paidTurnsRefunded(), + stats.paidTurnsCharged(), + stats.netFreeTurnDelta(), + stats.netPaidTurnDelta() + ); + } + + private AskChatDailyRagStatsViewModel toDailyRagStatsViewModel( + AskChatDailyRagDocumentStatsDto documentStats, + AskChatDailyRagReferenceStatsDto referenceStats + ) { + return new AskChatDailyRagStatsViewModel( + documentStats == null ? 0L : documentStats.totalDocumentCount(), + documentStats == null ? 0L : documentStats.pendingDocumentCount(), + documentStats == null ? 0L : documentStats.completedDocumentCount(), + documentStats == null ? 0L : documentStats.failedDocumentCount(), + documentStats == null ? 0L : documentStats.deadLetterDocumentCount(), + documentStats == null ? 0.0 : documentStats.averageFailedRetryCount(), + referenceStats == null ? 0L : referenceStats.referenceCount(), + referenceStats == null ? 0L : referenceStats.uniqueReferencedDocumentCount() + ); + } + + private AskChatWalletStatsViewModel toWalletStatsViewModel(AskChatWalletSummaryDto summary) { + return new AskChatWalletStatsViewModel( + summary.totalLogCount(), + summary.pendingLogCount(), + summary.confirmedLogCount(), + summary.refundedLogCount(), + summary.freeTurnsGranted(), + summary.freeTurnsConsumed(), + summary.paidTurnsConsumed(), + summary.freeTurnsRefunded(), + summary.paidTurnsRefunded(), + summary.paidTurnsCharged(), + summary.netFreeTurnDelta(), + summary.netPaidTurnDelta() + ); + } + + private AskChatRagStatsViewModel toRagStatsViewModel( + AskChatRagSummaryDto summary, + List sourceStats, + List errorStats + ) { + double embeddingCompletionRatePercent = summary.totalDocumentCount() == 0 + ? 0.0 + : summary.completedDocumentCount() * 100.0 / summary.totalDocumentCount(); + return new AskChatRagStatsViewModel( + summary.totalDocumentCount(), + summary.pendingDocumentCount(), + summary.completedDocumentCount(), + summary.failedDocumentCount(), + summary.deadLetterDocumentCount(), + embeddingCompletionRatePercent, + summary.averageFailedRetryCount(), + summary.totalReferenceCount(), + summary.uniqueReferencedDocumentCount(), + sourceStats, + errorStats + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java index 2674a93a..725348a5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java @@ -24,23 +24,22 @@ public class WithdrawalStatsService { private final WithdrawalStatsRepository repo; public WithdrawalStatsViewModel getWithdrawalStats() { - List rows = repo.findLatestWithdrawalReasonRows(RECENT_WITHDRAWAL_EVENT_LIMIT); - List totalReasonRows = repo.countAllWithdrawalReasons(); + List rows = repo.findLatestRetainedWithdrawalReasonRows(RECENT_WITHDRAWAL_EVENT_LIMIT); + List totalReasonRows = repo.countRetainedWithdrawalReasons(); - Map eventMap = new LinkedHashMap<>(); + Map eventMap = new LinkedHashMap<>(); Map reasonCountMap = new EnumMap<>(WithdrawalReasonType.class); for (Object[] row : rows) { - long userId = toLong(row[0]); + long eventId = toLong(row[0]); OffsetDateTime withdrawnAt = toOffsetDateTime(row[1]); String reasonCode = String.valueOf(row[2]); WithdrawalReasonType reasonType = parseReasonType(reasonCode); String customReason = row[3] == null ? null : String.valueOf(row[3]).trim(); OffsetDateTime normalizedWithdrawnAt = withdrawnAt.truncatedTo(ChronoUnit.SECONDS); - EventKey key = new EventKey(userId, normalizedWithdrawnAt); EventAccumulator accumulator = eventMap.computeIfAbsent( - key, + eventId, k -> new EventAccumulator(formatDateTime(normalizedWithdrawnAt)) ); @@ -136,9 +135,6 @@ private String toReasonLabel(WithdrawalReasonType reasonType, String rawReasonCo return toReasonLabel(reasonType); } - private record EventKey(long userId, OffsetDateTime withdrawnAt) { - } - private static class EventAccumulator { private final String withdrawnAt; private final List reasons = new ArrayList<>(); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java index e2cee631..50f4323d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java @@ -1,5 +1,6 @@ package com.devkor.ifive.nadab.domain.stats.controller; +import com.devkor.ifive.nadab.domain.stats.application.AskChatStatsService; import com.devkor.ifive.nadab.domain.stats.application.DailyStatsService; import com.devkor.ifive.nadab.domain.stats.application.MonthlyStatsService; import com.devkor.ifive.nadab.domain.stats.application.QuestionStatsService; @@ -9,6 +10,7 @@ import com.devkor.ifive.nadab.domain.stats.application.WeeklyStatsService; import com.devkor.ifive.nadab.domain.stats.application.helper.DailyQuestionOverviewCsvExporter; import com.devkor.ifive.nadab.domain.stats.application.helper.StatsPeriodResolver; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewQuery; @@ -45,6 +47,7 @@ public class StatsController { private static final String QUESTION_OVERVIEW_CSV_FILENAME = "nadab_daily_question_stats.csv"; private final DailyStatsService dailyStatsService; + private final AskChatStatsService askChatStatsService; private final WeeklyStatsService weeklyStatsService; private final MonthlyStatsService monthlyStatsService; private final TotalStatsService totalStatsService; @@ -53,6 +56,13 @@ public class StatsController { private final WithdrawalStatsService withdrawalStatsService; private final DailyQuestionOverviewCsvExporter dailyQuestionOverviewCsvExporter; + @GetMapping("/stats/ask-chat") + public String askChatStats(Model model) { + AskChatStatsViewModel vm = askChatStatsService.getAskChatStats(); + model.addAttribute("vm", vm); + model.addAttribute("activeTab", "ask-chat"); + return "stats/ask-chat"; + } @GetMapping("/stats/daily") public String dailyStats( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyMessageStatsDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyMessageStatsDto.java new file mode 100644 index 00000000..30966b3c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyMessageStatsDto.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +import java.time.LocalDate; + +public record AskChatDailyMessageStatsDto( + LocalDate date, + long userMessageCount, + long completedAssistantMessageCount, + long failedAssistantMessageCount, + double averageGenerationDurationMs, + long p95GenerationDurationMs +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyRagDocumentStatsDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyRagDocumentStatsDto.java new file mode 100644 index 00000000..b1a84c1d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyRagDocumentStatsDto.java @@ -0,0 +1,14 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +import java.time.LocalDate; + +public record AskChatDailyRagDocumentStatsDto( + LocalDate date, + long totalDocumentCount, + long pendingDocumentCount, + long completedDocumentCount, + long failedDocumentCount, + long deadLetterDocumentCount, + double averageFailedRetryCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyRagReferenceStatsDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyRagReferenceStatsDto.java new file mode 100644 index 00000000..30213e17 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyRagReferenceStatsDto.java @@ -0,0 +1,10 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +import java.time.LocalDate; + +public record AskChatDailyRagReferenceStatsDto( + LocalDate date, + long referenceCount, + long uniqueReferencedDocumentCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyRagStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyRagStatsViewModel.java new file mode 100644 index 00000000..daa20679 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyRagStatsViewModel.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +public record AskChatDailyRagStatsViewModel( + long totalDocumentCount, + long pendingDocumentCount, + long completedDocumentCount, + long failedDocumentCount, + long deadLetterDocumentCount, + double averageFailedRetryCount, + long referenceCount, + long uniqueReferencedDocumentCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailySessionStatsDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailySessionStatsDto.java new file mode 100644 index 00000000..243c46de --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailySessionStatsDto.java @@ -0,0 +1,12 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +import java.time.LocalDate; + +public record AskChatDailySessionStatsDto( + LocalDate date, + long sessionCount, + long uniqueUserCount, + long activeSessionCount, + long endedSessionCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyStatsViewModel.java new file mode 100644 index 00000000..38ada65f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyStatsViewModel.java @@ -0,0 +1,19 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +import java.time.LocalDate; + +public record AskChatDailyStatsViewModel( + LocalDate date, + long sessionCount, + long uniqueUserCount, + long activeSessionCount, + long endedSessionCount, + long userMessageCount, + long completedAssistantMessageCount, + long failedAssistantMessageCount, + double averageGenerationDurationMs, + long p95GenerationDurationMs, + AskChatDailyWalletStatsViewModel walletStats, + AskChatDailyRagStatsViewModel ragStats +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyWalletStatsDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyWalletStatsDto.java new file mode 100644 index 00000000..398c67fd --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyWalletStatsDto.java @@ -0,0 +1,20 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +import java.time.LocalDate; + +public record AskChatDailyWalletStatsDto( + LocalDate date, + long totalLogCount, + long pendingLogCount, + long confirmedLogCount, + long refundedLogCount, + long freeTurnsGranted, + long freeTurnsConsumed, + long paidTurnsConsumed, + long freeTurnsRefunded, + long paidTurnsRefunded, + long paidTurnsCharged, + long netFreeTurnDelta, + long netPaidTurnDelta +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyWalletStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyWalletStatsViewModel.java new file mode 100644 index 00000000..e6ba9217 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatDailyWalletStatsViewModel.java @@ -0,0 +1,17 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +public record AskChatDailyWalletStatsViewModel( + long totalLogCount, + long pendingLogCount, + long confirmedLogCount, + long refundedLogCount, + long freeTurnsGranted, + long freeTurnsConsumed, + long paidTurnsConsumed, + long freeTurnsRefunded, + long paidTurnsRefunded, + long paidTurnsCharged, + long netFreeTurnDelta, + long netPaidTurnDelta +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatErrorStatsDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatErrorStatsDto.java new file mode 100644 index 00000000..b52e7cbb --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatErrorStatsDto.java @@ -0,0 +1,7 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +public record AskChatErrorStatsDto( + String errorCode, + long count +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatMessageSummaryDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatMessageSummaryDto.java new file mode 100644 index 00000000..4f4f6fe2 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatMessageSummaryDto.java @@ -0,0 +1,10 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +public record AskChatMessageSummaryDto( + long totalUserMessageCount, + long totalCompletedAssistantMessageCount, + long totalFailedAssistantMessageCount, + double averageGenerationDurationMs, + long p95GenerationDurationMs +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatRagErrorStatsDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatRagErrorStatsDto.java new file mode 100644 index 00000000..9a4eb797 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatRagErrorStatsDto.java @@ -0,0 +1,7 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +public record AskChatRagErrorStatsDto( + String errorCode, + long count +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatRagSourceStatsDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatRagSourceStatsDto.java new file mode 100644 index 00000000..b129f91b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatRagSourceStatsDto.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +public record AskChatRagSourceStatsDto( + String sourceType, + long totalDocumentCount, + long pendingDocumentCount, + long completedDocumentCount, + long failedDocumentCount, + long deadLetterDocumentCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatRagStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatRagStatsViewModel.java new file mode 100644 index 00000000..e944deb8 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatRagStatsViewModel.java @@ -0,0 +1,18 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +import java.util.List; + +public record AskChatRagStatsViewModel( + long totalDocumentCount, + long pendingDocumentCount, + long completedDocumentCount, + long failedDocumentCount, + long deadLetterDocumentCount, + double embeddingCompletionRatePercent, + double averageFailedRetryCount, + long totalReferenceCount, + long uniqueReferencedDocumentCount, + List sourceStats, + List errorStats +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatRagSummaryDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatRagSummaryDto.java new file mode 100644 index 00000000..d5c36a49 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatRagSummaryDto.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +public record AskChatRagSummaryDto( + long totalDocumentCount, + long pendingDocumentCount, + long completedDocumentCount, + long failedDocumentCount, + long deadLetterDocumentCount, + double averageFailedRetryCount, + long totalReferenceCount, + long uniqueReferencedDocumentCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatSessionSummaryDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatSessionSummaryDto.java new file mode 100644 index 00000000..578edd45 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatSessionSummaryDto.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +public record AskChatSessionSummaryDto( + long totalSessionCount, + long totalUniqueUserCount, + long totalActiveSessionCount, + long totalEndedSessionCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatStatsViewModel.java new file mode 100644 index 00000000..7da7af77 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatStatsViewModel.java @@ -0,0 +1,25 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +import java.time.LocalDate; +import java.util.List; + +public record AskChatStatsViewModel( + LocalDate startDate, + LocalDate endDate, + List dailyStats, + long totalSessionCount, + long totalUniqueUserCount, + long totalActiveSessionCount, + long totalEndedSessionCount, + long totalUserMessageCount, + long totalCompletedAssistantMessageCount, + long totalFailedAssistantMessageCount, + double assistantSuccessRatePercent, + double averageGenerationDurationMs, + long p95GenerationDurationMs, + List errorStats, + AskChatWalletStatsViewModel walletStats, + AskChatRagStatsViewModel ragStats, + String refreshedAt +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatWalletStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatWalletStatsViewModel.java new file mode 100644 index 00000000..df0affc9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatWalletStatsViewModel.java @@ -0,0 +1,17 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +public record AskChatWalletStatsViewModel( + long totalLogCount, + long pendingLogCount, + long confirmedLogCount, + long refundedLogCount, + long freeTurnsGranted, + long freeTurnsConsumed, + long paidTurnsConsumed, + long freeTurnsRefunded, + long paidTurnsRefunded, + long paidTurnsCharged, + long netFreeTurnDelta, + long netPaidTurnDelta +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatWalletSummaryDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatWalletSummaryDto.java new file mode 100644 index 00000000..f35f3308 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/askchat/AskChatWalletSummaryDto.java @@ -0,0 +1,17 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.askchat; + +public record AskChatWalletSummaryDto( + long totalLogCount, + long pendingLogCount, + long confirmedLogCount, + long refundedLogCount, + long freeTurnsGranted, + long freeTurnsConsumed, + long paidTurnsConsumed, + long freeTurnsRefunded, + long paidTurnsRefunded, + long paidTurnsCharged, + long netFreeTurnDelta, + long netPaidTurnDelta +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/AskChatStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/AskChatStatsRepository.java new file mode 100644 index 00000000..ab15ebc9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/AskChatStatsRepository.java @@ -0,0 +1,537 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyMessageStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyRagDocumentStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyRagReferenceStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailySessionStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatErrorStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatMessageSummaryDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyWalletStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatRagErrorStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatRagSourceStatsDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatRagSummaryDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatSessionSummaryDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatWalletSummaryDto; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class AskChatStatsRepository { + + private final EntityManager em; + + public List findDailySessionStats( + OffsetDateTime startInclusive, + OffsetDateTime endExclusive + ) { + List rows = em.createNativeQuery(""" + select + (s.created_at at time zone 'Asia/Seoul')::date as stats_date, + count(*) as session_count, + count(distinct s.user_id) as unique_user_count, + count(*) filter (where s.status = 'ACTIVE') as active_session_count, + count(*) filter (where s.status = 'ENDED') as ended_session_count + from ask_chat_sessions s + where s.created_at >= :startInclusive + and s.created_at < :endExclusive + group by 1 + order by 1 + """) + .setParameter("startInclusive", startInclusive) + .setParameter("endExclusive", endExclusive) + .getResultList(); + + return rows.stream() + .map(row -> new AskChatDailySessionStatsDto( + toLocalDate(row[0]), + toLong(row[1]), + toLong(row[2]), + toLong(row[3]), + toLong(row[4]) + )) + .toList(); + } + + public AskChatSessionSummaryDto findSessionSummary( + OffsetDateTime startInclusive, + OffsetDateTime endExclusive + ) { + Object[] row = (Object[]) em.createNativeQuery(""" + select + count(*) as session_count, + count(distinct s.user_id) as unique_user_count, + count(*) filter (where s.status = 'ACTIVE') as active_session_count, + count(*) filter (where s.status = 'ENDED') as ended_session_count + from ask_chat_sessions s + where s.created_at >= :startInclusive + and s.created_at < :endExclusive + """) + .setParameter("startInclusive", startInclusive) + .setParameter("endExclusive", endExclusive) + .getSingleResult(); + + return new AskChatSessionSummaryDto( + toLong(row[0]), + toLong(row[1]), + toLong(row[2]), + toLong(row[3]) + ); + } + + public List findDailyMessageStats( + OffsetDateTime startInclusive, + OffsetDateTime endExclusive + ) { + List rows = em.createNativeQuery(""" + select + (m.created_at at time zone 'Asia/Seoul')::date as stats_date, + count(*) filter (where m.role = 'USER') as user_message_count, + count(*) filter (where m.role = 'ASSISTANT' and m.status = 'COMPLETED') + as completed_assistant_message_count, + count(*) filter (where m.role = 'ASSISTANT' and m.status = 'FAILED') + as failed_assistant_message_count, + avg(m.generation_duration_ms) filter ( + where m.role = 'ASSISTANT' + and m.status = 'COMPLETED' + and m.generation_duration_ms is not null + ) as average_generation_duration_ms, + percentile_cont(0.95) within group (order by m.generation_duration_ms) filter ( + where m.role = 'ASSISTANT' + and m.status = 'COMPLETED' + and m.generation_duration_ms is not null + ) as p95_generation_duration_ms + from ask_chat_messages m + where m.created_at >= :startInclusive + and m.created_at < :endExclusive + group by 1 + order by 1 + """) + .setParameter("startInclusive", startInclusive) + .setParameter("endExclusive", endExclusive) + .getResultList(); + + return rows.stream() + .map(row -> new AskChatDailyMessageStatsDto( + toLocalDate(row[0]), + toLong(row[1]), + toLong(row[2]), + toLong(row[3]), + toDouble(row[4]), + Math.round(toDouble(row[5])) + )) + .toList(); + } + + public AskChatMessageSummaryDto findMessageSummary( + OffsetDateTime startInclusive, + OffsetDateTime endExclusive + ) { + Object[] row = (Object[]) em.createNativeQuery(""" + select + count(*) filter (where m.role = 'USER') as user_message_count, + count(*) filter (where m.role = 'ASSISTANT' and m.status = 'COMPLETED') + as completed_assistant_message_count, + count(*) filter (where m.role = 'ASSISTANT' and m.status = 'FAILED') + as failed_assistant_message_count, + avg(m.generation_duration_ms) filter ( + where m.role = 'ASSISTANT' + and m.status = 'COMPLETED' + and m.generation_duration_ms is not null + ) as average_generation_duration_ms, + percentile_cont(0.95) within group (order by m.generation_duration_ms) filter ( + where m.role = 'ASSISTANT' + and m.status = 'COMPLETED' + and m.generation_duration_ms is not null + ) as p95_generation_duration_ms + from ask_chat_messages m + where m.created_at >= :startInclusive + and m.created_at < :endExclusive + """) + .setParameter("startInclusive", startInclusive) + .setParameter("endExclusive", endExclusive) + .getSingleResult(); + + return new AskChatMessageSummaryDto( + toLong(row[0]), + toLong(row[1]), + toLong(row[2]), + toDouble(row[3]), + Math.round(toDouble(row[4])) + ); + } + + public List findAssistantErrorStats( + OffsetDateTime startInclusive, + OffsetDateTime endExclusive + ) { + List rows = em.createNativeQuery(""" + select + coalesce(nullif(trim(m.error_code), ''), 'UNKNOWN') as error_code, + count(*) as error_count + from ask_chat_messages m + where m.created_at >= :startInclusive + and m.created_at < :endExclusive + and m.role = 'ASSISTANT' + and m.status = 'FAILED' + group by 1 + order by error_count desc, error_code asc + """) + .setParameter("startInclusive", startInclusive) + .setParameter("endExclusive", endExclusive) + .getResultList(); + + return rows.stream() + .map(row -> new AskChatErrorStatsDto(String.valueOf(row[0]), toLong(row[1]))) + .toList(); + } + + public List findDailyWalletStats( + OffsetDateTime startInclusive, + OffsetDateTime endExclusive + ) { + List rows = em.createNativeQuery(""" + select + (l.created_at at time zone 'Asia/Seoul')::date as stats_date, + count(*) as total_log_count, + count(*) filter (where l.status = 'PENDING') as pending_log_count, + count(*) filter (where l.status = 'CONFIRMED') as confirmed_log_count, + count(*) filter (where l.status = 'REFUNDED') as refunded_log_count, + coalesce(sum(case + when l.status = 'CONFIRMED' + and l.reason = 'INITIAL_FREE_GRANT' + and l.free_turn_delta > 0 + then l.free_turn_delta else 0 end), 0) as free_turns_granted, + coalesce(sum(case + when l.status = 'CONFIRMED' + and l.reason = 'ANSWER_SUCCESS_CONSUME' + and l.free_turn_delta < 0 + then -l.free_turn_delta else 0 end), 0) as free_turns_consumed, + coalesce(sum(case + when l.status = 'CONFIRMED' + and l.reason = 'ANSWER_SUCCESS_CONSUME' + and l.paid_turn_delta < 0 + then -l.paid_turn_delta else 0 end), 0) as paid_turns_consumed, + coalesce(sum(case + when l.status = 'CONFIRMED' + and l.reason = 'ANSWER_FAILURE_REFUND' + and l.free_turn_delta > 0 + then l.free_turn_delta else 0 end), 0) as free_turns_refunded, + coalesce(sum(case + when l.status = 'CONFIRMED' + and l.reason = 'ANSWER_FAILURE_REFUND' + and l.paid_turn_delta > 0 + then l.paid_turn_delta else 0 end), 0) as paid_turns_refunded, + coalesce(sum(case + when l.status = 'CONFIRMED' + and l.reason = 'CRYSTAL_CHARGE' + and l.paid_turn_delta > 0 + then l.paid_turn_delta else 0 end), 0) as paid_turns_charged, + coalesce(sum(l.free_turn_delta), 0) as net_free_turn_delta, + coalesce(sum(l.paid_turn_delta), 0) as net_paid_turn_delta + from ask_chat_wallet_logs l + where l.created_at >= :startInclusive + and l.created_at < :endExclusive + group by 1 + order by 1 + """) + .setParameter("startInclusive", startInclusive) + .setParameter("endExclusive", endExclusive) + .getResultList(); + + return rows.stream() + .map(row -> new AskChatDailyWalletStatsDto( + toLocalDate(row[0]), + toLong(row[1]), + toLong(row[2]), + toLong(row[3]), + toLong(row[4]), + toLong(row[5]), + toLong(row[6]), + toLong(row[7]), + toLong(row[8]), + toLong(row[9]), + toLong(row[10]), + toLong(row[11]), + toLong(row[12]) + )) + .toList(); + } + + public AskChatWalletSummaryDto findWalletSummary( + OffsetDateTime startInclusive, + OffsetDateTime endExclusive + ) { + Object[] row = (Object[]) em.createNativeQuery(""" + select + count(*) as total_log_count, + count(*) filter (where l.status = 'PENDING') as pending_log_count, + count(*) filter (where l.status = 'CONFIRMED') as confirmed_log_count, + count(*) filter (where l.status = 'REFUNDED') as refunded_log_count, + coalesce(sum(case + when l.status = 'CONFIRMED' + and l.reason = 'INITIAL_FREE_GRANT' + and l.free_turn_delta > 0 + then l.free_turn_delta else 0 end), 0) as free_turns_granted, + coalesce(sum(case + when l.status = 'CONFIRMED' + and l.reason = 'ANSWER_SUCCESS_CONSUME' + and l.free_turn_delta < 0 + then -l.free_turn_delta else 0 end), 0) as free_turns_consumed, + coalesce(sum(case + when l.status = 'CONFIRMED' + and l.reason = 'ANSWER_SUCCESS_CONSUME' + and l.paid_turn_delta < 0 + then -l.paid_turn_delta else 0 end), 0) as paid_turns_consumed, + coalesce(sum(case + when l.status = 'CONFIRMED' + and l.reason = 'ANSWER_FAILURE_REFUND' + and l.free_turn_delta > 0 + then l.free_turn_delta else 0 end), 0) as free_turns_refunded, + coalesce(sum(case + when l.status = 'CONFIRMED' + and l.reason = 'ANSWER_FAILURE_REFUND' + and l.paid_turn_delta > 0 + then l.paid_turn_delta else 0 end), 0) as paid_turns_refunded, + coalesce(sum(case + when l.status = 'CONFIRMED' + and l.reason = 'CRYSTAL_CHARGE' + and l.paid_turn_delta > 0 + then l.paid_turn_delta else 0 end), 0) as paid_turns_charged, + coalesce(sum(l.free_turn_delta), 0) as net_free_turn_delta, + coalesce(sum(l.paid_turn_delta), 0) as net_paid_turn_delta + from ask_chat_wallet_logs l + where l.created_at >= :startInclusive + and l.created_at < :endExclusive + """) + .setParameter("startInclusive", startInclusive) + .setParameter("endExclusive", endExclusive) + .getSingleResult(); + + return new AskChatWalletSummaryDto( + toLong(row[0]), + toLong(row[1]), + toLong(row[2]), + toLong(row[3]), + toLong(row[4]), + toLong(row[5]), + toLong(row[6]), + toLong(row[7]), + toLong(row[8]), + toLong(row[9]), + toLong(row[10]), + toLong(row[11]) + ); + } + + public List findDailyRagDocumentStats( + OffsetDateTime startInclusive, + OffsetDateTime endExclusive + ) { + List rows = em.createNativeQuery(""" + select + (d.created_at at time zone 'Asia/Seoul')::date as stats_date, + count(*) as total_document_count, + count(*) filter (where d.embedding_status = 'PENDING') as pending_document_count, + count(*) filter (where d.embedding_status = 'COMPLETED') as completed_document_count, + count(*) filter (where d.embedding_status = 'FAILED') as failed_document_count, + count(*) filter (where d.embedding_status = 'DEAD_LETTER') as dead_letter_document_count, + avg(d.retry_count) filter ( + where d.embedding_status in ('FAILED', 'DEAD_LETTER') + ) as average_failed_retry_count + from ask_chat_rag_documents d + where d.created_at >= :startInclusive + and d.created_at < :endExclusive + group by 1 + order by 1 + """) + .setParameter("startInclusive", startInclusive) + .setParameter("endExclusive", endExclusive) + .getResultList(); + + return rows.stream() + .map(row -> new AskChatDailyRagDocumentStatsDto( + toLocalDate(row[0]), + toLong(row[1]), + toLong(row[2]), + toLong(row[3]), + toLong(row[4]), + toLong(row[5]), + toDouble(row[6]) + )) + .toList(); + } + + public List findDailyRagReferenceStats( + OffsetDateTime startInclusive, + OffsetDateTime endExclusive + ) { + List rows = em.createNativeQuery(""" + select + (r.created_at at time zone 'Asia/Seoul')::date as stats_date, + count(*) as reference_count, + count(distinct r.rag_document_id) as unique_referenced_document_count + from ask_chat_message_references r + where r.created_at >= :startInclusive + and r.created_at < :endExclusive + group by 1 + order by 1 + """) + .setParameter("startInclusive", startInclusive) + .setParameter("endExclusive", endExclusive) + .getResultList(); + + return rows.stream() + .map(row -> new AskChatDailyRagReferenceStatsDto( + toLocalDate(row[0]), + toLong(row[1]), + toLong(row[2]) + )) + .toList(); + } + + public AskChatRagSummaryDto findRagSummary( + OffsetDateTime startInclusive, + OffsetDateTime endExclusive + ) { + Object[] row = (Object[]) em.createNativeQuery(""" + with document_stats as ( + select + count(*) as total_document_count, + count(*) filter (where d.embedding_status = 'PENDING') as pending_document_count, + count(*) filter (where d.embedding_status = 'COMPLETED') as completed_document_count, + count(*) filter (where d.embedding_status = 'FAILED') as failed_document_count, + count(*) filter (where d.embedding_status = 'DEAD_LETTER') as dead_letter_document_count, + avg(d.retry_count) filter ( + where d.embedding_status in ('FAILED', 'DEAD_LETTER') + ) as average_failed_retry_count + from ask_chat_rag_documents d + where d.created_at >= :startInclusive + and d.created_at < :endExclusive + ), reference_stats as ( + select + count(*) as total_reference_count, + count(distinct r.rag_document_id) as unique_referenced_document_count + from ask_chat_message_references r + where r.created_at >= :startInclusive + and r.created_at < :endExclusive + ) + select + d.total_document_count, + d.pending_document_count, + d.completed_document_count, + d.failed_document_count, + d.dead_letter_document_count, + d.average_failed_retry_count, + r.total_reference_count, + r.unique_referenced_document_count + from document_stats d + cross join reference_stats r + """) + .setParameter("startInclusive", startInclusive) + .setParameter("endExclusive", endExclusive) + .getSingleResult(); + + return new AskChatRagSummaryDto( + toLong(row[0]), + toLong(row[1]), + toLong(row[2]), + toLong(row[3]), + toLong(row[4]), + toDouble(row[5]), + toLong(row[6]), + toLong(row[7]) + ); + } + + public List findRagSourceStats( + OffsetDateTime startInclusive, + OffsetDateTime endExclusive + ) { + List rows = em.createNativeQuery(""" + select + d.source_type, + count(*) as total_document_count, + count(*) filter (where d.embedding_status = 'PENDING') as pending_document_count, + count(*) filter (where d.embedding_status = 'COMPLETED') as completed_document_count, + count(*) filter (where d.embedding_status = 'FAILED') as failed_document_count, + count(*) filter (where d.embedding_status = 'DEAD_LETTER') as dead_letter_document_count + from ask_chat_rag_documents d + where d.created_at >= :startInclusive + and d.created_at < :endExclusive + group by d.source_type + order by total_document_count desc, d.source_type asc + """) + .setParameter("startInclusive", startInclusive) + .setParameter("endExclusive", endExclusive) + .getResultList(); + + return rows.stream() + .map(row -> new AskChatRagSourceStatsDto( + String.valueOf(row[0]), + toLong(row[1]), + toLong(row[2]), + toLong(row[3]), + toLong(row[4]), + toLong(row[5]) + )) + .toList(); + } + + public List findRagErrorStats( + OffsetDateTime startInclusive, + OffsetDateTime endExclusive + ) { + List rows = em.createNativeQuery(""" + select + coalesce(nullif(trim(d.error_code), ''), 'UNKNOWN') as error_code, + count(*) as error_count + from ask_chat_rag_documents d + where d.created_at >= :startInclusive + and d.created_at < :endExclusive + and d.embedding_status in ('FAILED', 'DEAD_LETTER') + group by 1 + order by error_count desc, error_code asc + """) + .setParameter("startInclusive", startInclusive) + .setParameter("endExclusive", endExclusive) + .getResultList(); + + return rows.stream() + .map(row -> new AskChatRagErrorStatsDto(String.valueOf(row[0]), toLong(row[1]))) + .toList(); + } + + private static LocalDate toLocalDate(Object value) { + if (value instanceof LocalDate localDate) { + return localDate; + } + if (value instanceof Date sqlDate) { + return sqlDate.toLocalDate(); + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().toLocalDate(); + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime.toLocalDate(); + } + String text = String.valueOf(value); + return LocalDate.parse(text.substring(0, 10)); + } + + private static long toLong(Object value) { + return value == null ? 0L : ((Number) value).longValue(); + } + + private static double toDouble(Object value) { + return value == null ? 0.0 : ((Number) value).doubleValue(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java index c9a6fcf9..903851ef 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java @@ -12,39 +12,40 @@ public class WithdrawalStatsRepository { private final EntityManager em; - public List findLatestWithdrawalReasonRows(int limitEvents) { + public List findLatestRetainedWithdrawalReasonRows(int limitEvents) { return em.createNativeQuery(""" with ranked_events as ( select - uwr.user_id, - uwr.withdrawn_at, - row_number() over (order by uwr.withdrawn_at desc, uwr.user_id desc) as rn - from user_withdrawal_reasons uwr - group by uwr.user_id, uwr.withdrawn_at + we.id, + we.withdrawn_at, + row_number() over (order by we.withdrawn_at desc, we.id desc) as rn + from withdrawal_events we + where we.expires_at > current_timestamp ) select - uwr.user_id, - uwr.withdrawn_at, - uwr.reason, - uwr.custom_reason - from user_withdrawal_reasons uwr - join ranked_events re - on re.user_id = uwr.user_id - and re.withdrawn_at = uwr.withdrawn_at + re.id, + re.withdrawn_at, + wer.reason, + case when we.user_id is null then null else wer.custom_reason end + from ranked_events re + join withdrawal_events we on we.id = re.id + join withdrawal_event_reasons wer on wer.event_id = re.id where re.rn <= :limitEvents - order by uwr.withdrawn_at desc, uwr.user_id desc, uwr.reason asc + order by re.withdrawn_at desc, re.id desc, wer.reason asc """) .setParameter("limitEvents", limitEvents) .getResultList(); } - public List countAllWithdrawalReasons() { + public List countRetainedWithdrawalReasons() { return em.createNativeQuery(""" select - uwr.reason, + wer.reason, count(*) as cnt - from user_withdrawal_reasons uwr - group by uwr.reason + from withdrawal_event_reasons wer + join withdrawal_events we on we.id = wer.event_id + where we.expires_at > current_timestamp + group by wer.reason """) .getResultList(); } diff --git a/src/main/resources/db/migration/V20260831_1300__IS_add_ask_chat_stats_indexes.sql b/src/main/resources/db/migration/V20260831_1300__IS_add_ask_chat_stats_indexes.sql new file mode 100644 index 00000000..f0018e7a --- /dev/null +++ b/src/main/resources/db/migration/V20260831_1300__IS_add_ask_chat_stats_indexes.sql @@ -0,0 +1,13 @@ +-- Ask Chat 통계는 사용자·세션을 특정하지 않고 created_at 기간으로 집계한다. +-- 기존 사용자·세션 선두 인덱스와 별도로 전역 기간 조회 경로를 제공한다. +CREATE INDEX idx_ask_chat_sessions_stats_created_id + ON ask_chat_sessions (created_at DESC, id DESC); + +CREATE INDEX idx_ask_chat_messages_stats_created_id + ON ask_chat_messages (created_at DESC, id DESC); + +CREATE INDEX idx_ask_chat_message_references_stats_created_id + ON ask_chat_message_references (created_at DESC, id DESC); + +CREATE INDEX idx_ask_chat_rag_documents_stats_created_id + ON ask_chat_rag_documents (created_at DESC, id DESC); diff --git a/src/main/resources/db/migration/V20260906_1200__IS_create_withdrawal_events.sql b/src/main/resources/db/migration/V20260906_1200__IS_create_withdrawal_events.sql new file mode 100644 index 00000000..d0a651f1 --- /dev/null +++ b/src/main/resources/db/migration/V20260906_1200__IS_create_withdrawal_events.sql @@ -0,0 +1,88 @@ +CREATE TABLE withdrawal_events ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT, + withdrawn_at TIMESTAMPTZ NOT NULL, + anonymized_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT fk_withdrawal_events_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT uq_withdrawal_events_user_withdrawn_at + UNIQUE (user_id, withdrawn_at), + CONSTRAINT chk_withdrawal_events_expiration + CHECK (expires_at > withdrawn_at), + CONSTRAINT chk_withdrawal_events_anonymization + CHECK ( + anonymized_at IS NULL + OR (user_id IS NULL AND anonymized_at >= withdrawn_at) + ) +); + +CREATE TABLE withdrawal_event_reasons ( + id BIGSERIAL PRIMARY KEY, + event_id BIGINT NOT NULL, + reason VARCHAR(50) NOT NULL, + custom_reason VARCHAR(200), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT fk_withdrawal_event_reasons_event + FOREIGN KEY (event_id) REFERENCES withdrawal_events(id) ON DELETE CASCADE, + CONSTRAINT uq_withdrawal_event_reasons_event_reason + UNIQUE (event_id, reason), + CONSTRAINT chk_withdrawal_event_reasons_other_custom_reason + CHECK ( + (reason = 'OTHER' AND custom_reason IS NOT NULL AND LENGTH(BTRIM(custom_reason)) > 0) + OR + (reason <> 'OTHER' AND custom_reason IS NULL) + ) +); + +CREATE INDEX idx_withdrawal_events_withdrawn_at + ON withdrawal_events (withdrawn_at DESC); + +CREATE INDEX idx_withdrawal_events_expires_at + ON withdrawal_events (expires_at); + +CREATE INDEX idx_withdrawal_event_reasons_reason_event_id + ON withdrawal_event_reasons (reason, event_id); + +COMMENT ON TABLE withdrawal_events IS + 'Withdrawal request events retained independently from user accounts for up to one year'; +COMMENT ON COLUMN withdrawal_events.user_id IS + 'Temporary user link removed when the event is anonymized or the user is hard-deleted'; +COMMENT ON COLUMN withdrawal_events.expires_at IS + 'Retention deadline calculated from the withdrawal request time'; + +-- LEGACY BACKFILL START +INSERT INTO withdrawal_events ( + user_id, + withdrawn_at, + expires_at, + created_at +) +SELECT + uwr.user_id, + uwr.withdrawn_at, + uwr.withdrawn_at + INTERVAL '1 year', + MIN(uwr.created_at) +FROM user_withdrawal_reasons uwr +GROUP BY uwr.user_id, uwr.withdrawn_at; + +INSERT INTO withdrawal_event_reasons ( + event_id, + reason, + custom_reason, + created_at +) +SELECT DISTINCT ON (we.id, uwr.reason) + we.id, + uwr.reason, + uwr.custom_reason, + uwr.created_at +FROM user_withdrawal_reasons uwr +JOIN withdrawal_events we + ON we.user_id = uwr.user_id + AND we.withdrawn_at = uwr.withdrawn_at +ORDER BY we.id, uwr.reason, uwr.created_at DESC, uwr.id DESC; +-- LEGACY BACKFILL END diff --git a/src/main/resources/db/migration/V20260906_1300__IS_add_withdrawal_event_retention_cleanup.sql b/src/main/resources/db/migration/V20260906_1300__IS_add_withdrawal_event_retention_cleanup.sql new file mode 100644 index 00000000..7fb966bc --- /dev/null +++ b/src/main/resources/db/migration/V20260906_1300__IS_add_withdrawal_event_retention_cleanup.sql @@ -0,0 +1,40 @@ +ALTER TABLE withdrawal_event_reasons + DROP CONSTRAINT chk_withdrawal_event_reasons_other_custom_reason; + +ALTER TABLE withdrawal_event_reasons + ADD CONSTRAINT chk_withdrawal_event_reasons_other_custom_reason + CHECK ( + ( + reason = 'OTHER' + AND ( + custom_reason IS NULL + OR LENGTH(BTRIM(custom_reason)) > 0 + ) + ) + OR + (reason <> 'OTHER' AND custom_reason IS NULL) + ); + +COMMENT ON COLUMN withdrawal_event_reasons.custom_reason IS + 'Optional free-text detail removed after the 14-day account recovery period'; + +-- RETENTION CLEANUP START +DELETE FROM withdrawal_events +WHERE expires_at <= CURRENT_TIMESTAMP; + +DELETE FROM user_withdrawal_reasons +WHERE withdrawn_at < CURRENT_TIMESTAMP - INTERVAL '14 days'; + +UPDATE withdrawal_event_reasons wer +SET custom_reason = NULL +FROM withdrawal_events we +WHERE we.id = wer.event_id + AND we.withdrawn_at < CURRENT_TIMESTAMP - INTERVAL '14 days' + AND wer.custom_reason IS NOT NULL; + +UPDATE withdrawal_events +SET user_id = NULL, + anonymized_at = COALESCE(anonymized_at, CURRENT_TIMESTAMP) +WHERE withdrawn_at < CURRENT_TIMESTAMP - INTERVAL '14 days' + AND (user_id IS NOT NULL OR anonymized_at IS NULL); +-- RETENTION CLEANUP END diff --git a/src/main/resources/db/migration/V20260906_1400__IS_mark_user_withdrawal_reasons_legacy.sql b/src/main/resources/db/migration/V20260906_1400__IS_mark_user_withdrawal_reasons_legacy.sql new file mode 100644 index 00000000..1ed58a22 --- /dev/null +++ b/src/main/resources/db/migration/V20260906_1400__IS_mark_user_withdrawal_reasons_legacy.sql @@ -0,0 +1,2 @@ +COMMENT ON TABLE user_withdrawal_reasons IS + 'LEGACY: rollback compatibility only. Do not write new records; use withdrawal_events and withdrawal_event_reasons.'; diff --git a/src/main/resources/templates/stats/ask-chat.html b/src/main/resources/templates/stats/ask-chat.html new file mode 100644 index 00000000..e80d7dae --- /dev/null +++ b/src/main/resources/templates/stats/ask-chat.html @@ -0,0 +1,570 @@ + + + + + + NADAB — Ask Chat Stats + + + + + + + + + +
+ +
+
Ask Chat 집계 기간
+
2026-08-25 ~ 2026-08-31
+
최근 7일 · Asia/Seoul 기준 · 세션, 메시지, 대화권, RAG read model을 한 화면에서 확인합니다.
+
+ +
+
+
총 세션
+
0
+
진행 중 0 · 종료 0
+
+
+
고유 사용자
+
0
+
기간 내 Ask Chat 세션을 만든 사용자
+
+
+
사용자 메시지
+
0
+
답변 생성 요청 수
+
+
+
답변 성공률
+
0.0%
+
완료 0 · 실패 0
+
+
+
완료 답변
+
0
+
정상적으로 저장된 assistant 메시지
+
+
+
실패 답변
+
0
+
오류 코드별 분포는 아래에서 확인
+
+
+
평균 생성 시간
+
0.0 ms
+
assistant 생성 완료 요청 기준
+
+
+
p95 생성 시간
+
0 ms
+
느린 요청의 상위 5% 기준
+
+
+ +
+
+
+
일별 세션 · 메시지
+
기간 내 세션 생성과 사용자 메시지 추이
+
+
+
+
+
+
일별 답변 결과
+
완료와 실패 assistant 메시지 비교
+
+
+
+
+
+
일별 생성 지연
+
평균과 p95 assistant 생성 시간 (ms)
+
+
+
+
+
+
일별 대화권 소비
+
무료·유료 대화권 소비량
+
+
+
+
+
+
일별 RAG 문서 처리
+
임베딩 상태별 문서 수
+
+
+
+
+ +
+
+
+
대화권 현황
+
wallet log의 상태·delta·reason 기반 기간 집계
+
+
+
+
전체 로그
0
+
확정 로그
0
+
대기 로그
0
+
환불 로그
0
+
무료 지급
0
+
무료 소비
0
+
유료 소비
0
+
유료 충전
0
+
유료 순증감
0
+
+
+
+ +
+
+
RAG 현황
+
문서 임베딩과 메시지 참조 read model 집계
+
+
+
+
전체 문서
0
+
임베딩 완료율
0.0%
+
대기 문서
0
+
실패 문서
0
+
Dead-letter
0
+
평균 실패 retry
0.0
+
전체 참조
0
+
참조 문서 수
0
+
+
+
+
+ +
+
+
일별 Ask Chat 상세
+
조회 기간에 데이터가 없는 날도 0으로 표시합니다.
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
기준일세션사용자사용자 메시지완료 답변실패 답변평균 msp95 ms유료 소비RAG 완료참조
2026-08-31000000.00000
+
+
표시할 일별 통계가 없습니다.
+
+
+ +
+
+
+
답변 오류 코드
+
assistant 생성 실패 원인별 건수
+
+
+
+ + + +
오류 코드건수
TIMEOUT0
+
+
답변 오류가 없습니다.
+
+
+ +
+
+
RAG source별 처리 현황
+
source type별 임베딩 상태 분포
+
+
+
+ + + + + + + + + + + + +
source전체대기완료실패dead-letter
ASK_CHAT_MESSAGE00000
+
+
RAG source 데이터가 없습니다.
+
+
+ +
+
+
RAG 오류 코드
+
임베딩 실패 원인별 건수
+
+
+
+ + + +
오류 코드건수
EMBEDDING_TIMEOUT0
+
+
RAG 오류가 없습니다.
+
+
+
+ + + + diff --git a/src/main/resources/templates/stats/daily.html b/src/main/resources/templates/stats/daily.html index 904f853e..b3a0b3a7 100644 --- a/src/main/resources/templates/stats/daily.html +++ b/src/main/resources/templates/stats/daily.html @@ -269,29 +269,7 @@ - +
diff --git a/src/main/resources/templates/stats/fragments/tabs.html b/src/main/resources/templates/stats/fragments/tabs.html new file mode 100644 index 00000000..31412b1e --- /dev/null +++ b/src/main/resources/templates/stats/fragments/tabs.html @@ -0,0 +1,31 @@ + + + + + + diff --git a/src/main/resources/templates/stats/monthly.html b/src/main/resources/templates/stats/monthly.html index f7734d98..56e46512 100644 --- a/src/main/resources/templates/stats/monthly.html +++ b/src/main/resources/templates/stats/monthly.html @@ -259,29 +259,7 @@
- +
diff --git a/src/main/resources/templates/stats/question-overview.html b/src/main/resources/templates/stats/question-overview.html index e7a93ce5..0fde0d12 100644 --- a/src/main/resources/templates/stats/question-overview.html +++ b/src/main/resources/templates/stats/question-overview.html @@ -328,15 +328,7 @@
- +