diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/AdminStatController.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/AdminStatController.java index f83134f6..4a992172 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/AdminStatController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/AdminStatController.java @@ -11,6 +11,8 @@ import org.devkor.apu.saerok_server.domain.admin.stat.api.dto.response.StatSeriesResponse; import org.devkor.apu.saerok_server.domain.admin.stat.application.StatAggregationService; import org.devkor.apu.saerok_server.domain.admin.stat.application.StatQueryService; +import org.devkor.apu.saerok_server.domain.admin.stat.application.CurrentUserStatQueryService; +import org.devkor.apu.saerok_server.domain.admin.stat.api.dto.response.CurrentUserStatResponse; import org.devkor.apu.saerok_server.domain.admin.stat.core.entity.StatMetric; import org.devkor.apu.saerok_server.global.shared.util.EnumParser; import org.springframework.http.HttpStatus; @@ -28,6 +30,25 @@ public class AdminStatController { private final StatQueryService queryService; private final StatAggregationService aggregationService; + private final CurrentUserStatQueryService currentUserStatQueryService; + + @GetMapping("/current-users") + @PreAuthorize("@perm.has('ADMIN_STAT_READ')") + @Operation( + summary = "현재 사용자 현황 조회", + security = @SecurityRequirement(name = "bearerAuth"), + description = """ + 조회 시점의 가입 완료 사용자 현황을 반환합니다. 일별 통계 테이블을 사용하지 않습니다. + 플랫폼별 수는 활성 푸시 토큰을 보유한 사용자 수이며, 한 사용자가 여러 플랫폼에 중복 포함될 수 있습니다. + """, + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = CurrentUserStatResponse.class))) + } + ) + public CurrentUserStatResponse getCurrentUserStats() { + return currentUserStatQueryService.getCurrentUserStats(); + } @GetMapping("/series") @PreAuthorize("@perm.has('ADMIN_STAT_READ')") @@ -37,7 +58,8 @@ public class AdminStatController { description = """ metric 목록을 지정하면, 각 metric에 대한 시계열을 반환합니다. - 단일값: COLLECTION_TOTAL_COUNT, COLLECTION_PRIVATE_RATIO, BIRD_ID_PENDING_COUNT, BIRD_ID_RESOLVED_COUNT → payload.value - - 멀티값: BIRD_ID_RESOLUTION_STATS (min_hours, max_hours, avg_hours, stddev_hours) + - 멀티값: BIRD_ID_RESOLUTION_STATS_28D (min_hours, max_hours, avg_hours, stddev_hours), + USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE (IOS, ANDROID 누적 가입 사용자 수) """, responses = { diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/dto/response/CurrentUserStatResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/dto/response/CurrentUserStatResponse.java new file mode 100644 index 00000000..fce0c528 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/api/dto/response/CurrentUserStatResponse.java @@ -0,0 +1,16 @@ +package org.devkor.apu.saerok_server.domain.admin.stat.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Map; + +@Schema(description = "관리자용 현재 사용자 현황 응답 DTO") +public record CurrentUserStatResponse( + @Schema(description = "현재 가입 완료 사용자 수", example = "1250") + long completedUserCount, + @Schema(description = "가입 경로별 현재 가입 완료 사용자 수. 가입 경로가 없는 사용자는 UNKNOWN 키로 반환") + Map signupSourceCounts, + @Schema(description = "플랫폼별 활성 푸시 토큰 보유 사용자 수. 한 사용자가 여러 플랫폼에 중복 포함될 수 있음") + Map activePushUserCountsByPlatform +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryService.java new file mode 100644 index 00000000..c1191ccd --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryService.java @@ -0,0 +1,88 @@ +package org.devkor.apu.saerok_server.domain.admin.stat.application; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.stat.api.dto.response.CurrentUserStatResponse; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupSourceType; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class CurrentUserStatQueryService { + + private static final String UNKNOWN_SIGNUP_SOURCE = "UNKNOWN"; + + private final EntityManager em; + + public CurrentUserStatResponse getCurrentUserStats() { + return new CurrentUserStatResponse( + countCompletedUsers(), + countCompletedUsersBySignupSource(), + countActivePushUsersByPlatform() + ); + } + + private long countCompletedUsers() { + return em.createQuery(""" + SELECT COUNT(u) FROM User u + WHERE u.signupStatus = :completed + AND u.deletedAt IS NULL + """, Long.class) + .setParameter("completed", SignupStatusType.COMPLETED) + .getSingleResult(); + } + + private Map countCompletedUsersBySignupSource() { + Map counts = new LinkedHashMap<>(); + for (SignupSourceType source : SignupSourceType.values()) { + counts.put(source.name(), 0L); + } + counts.put(UNKNOWN_SIGNUP_SOURCE, 0L); + + List rows = em.createQuery(""" + SELECT u.signupSource, COUNT(u) FROM User u + WHERE u.signupStatus = :completed + AND u.deletedAt IS NULL + GROUP BY u.signupSource + """, Object[].class) + .setParameter("completed", SignupStatusType.COMPLETED) + .getResultList(); + + for (Object[] row : rows) { + String key = row[0] == null ? UNKNOWN_SIGNUP_SOURCE : row[0].toString(); + counts.put(key, ((Number) row[1]).longValue()); + } + return counts; + } + + private Map countActivePushUsersByPlatform() { + Map counts = new LinkedHashMap<>(); + for (DevicePlatform platform : DevicePlatform.values()) { + counts.put(platform.name(), 0L); + } + + List rows = em.createQuery(""" + SELECT ud.platform, COUNT(DISTINCT u.id) FROM UserDevice ud + JOIN ud.user u + WHERE ud.token IS NOT NULL + AND u.signupStatus = :completed + AND u.deletedAt IS NULL + GROUP BY ud.platform + """, Object[].class) + .setParameter("completed", SignupStatusType.COMPLETED) + .getResultList(); + + for (Object[] row : rows) { + counts.put(row[0].toString(), ((Number) row[1]).longValue()); + } + return counts; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatAggregationService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatAggregationService.java index adee1378..4be111c1 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatAggregationService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatAggregationService.java @@ -46,9 +46,8 @@ public void aggregateFor(LocalDate date, Set metrics) { case USER_DAU -> aggregateUserDau(date); case USER_WAU -> aggregateUserWau(date); case USER_MAU -> aggregateUserMau(date); + case USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE -> aggregateUserDevicePlatformSignupDaily(date); - case USER_SIGNUP_SOURCE_TOTAL -> aggregateUserSignupSourceTotal(date); - case USER_DEVICE_PLATFORM_TOTAL -> aggregateUserDevicePlatformTotal(date); } } } @@ -224,45 +223,79 @@ SELECT COUNT(DISTINCT user_id) FROM user_activity_ping dailyRepo.upsertValue(StatMetric.USER_MAU, date, n.longValue()); } - /** 누적 가입 경로별 가입자 수 (스냅샷): signupCompletedAt < end, signupSource IS NOT NULL */ - private void aggregateUserSignupSourceTotal(LocalDate date) { - var end = endExclusive(date); - - @SuppressWarnings("unchecked") - List rows = em.createQuery(""" - SELECT u.signupSource, COUNT(u) FROM User u - WHERE u.signupCompletedAt < :end - AND u.signupSource IS NOT NULL - GROUP BY u.signupSource - """) - .setParameter("end", end) - .getResultList(); - - Map payload = new HashMap<>(); - for (Object[] row : rows) { - payload.put(row[0].toString(), ((Number) row[1]).longValue()); - } - dailyRepo.upsertPayload(StatMetric.USER_SIGNUP_SOURCE_TOTAL, date, payload); - } - - /** 누적 플랫폼별 유니크 유저 수 (스냅샷): UserDevice.createdAt < end */ - private void aggregateUserDevicePlatformTotal(LocalDate date) { + /** + * 플랫폼별 일일 신규 가입 사용자 수를 저장한다. + * + *

사용자-플랫폼마다 첫 기기 등록만 사용하며, 가입 완료와 첫 기기 등록 중 더 늦은 날을 + * 플랫폼 가입일로 본다. 조회 단계에서 이 일별 증분을 누적해 증가 추이를 만든다.

+ */ + private void aggregateUserDevicePlatformSignupDaily(LocalDate date) { + var start = date.atStartOfDay(KST).toOffsetDateTime(); var end = endExclusive(date); + boolean initialAggregation = dailyRepo + .findLastDateOf(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE) + .isEmpty(); - @SuppressWarnings("unchecked") List rows = em.createQuery(""" - SELECT ud.platform, COUNT(DISTINCT ud.user.id) FROM UserDevice ud - WHERE ud.createdAt < :end + SELECT ud.platform, COUNT(DISTINCT u.id) FROM UserDevice ud + JOIN ud.user u + WHERE ud.createdAt = ( + SELECT MIN(ud2.createdAt) FROM UserDevice ud2 + WHERE ud2.user.id = u.id + AND ud2.platform = ud.platform + ) + AND ( + ( + :initialAggregation = TRUE + AND ( + (u.signupCompletedAt IS NOT NULL + AND u.signupCompletedAt < :end + AND ud.createdAt < :end) + OR (u.signupCompletedAt IS NULL + AND u.signupStatus IN (:completed, :withdrawn) + AND u.joinedAt < :end + AND ud.createdAt < :end) + ) + ) + OR ( + :initialAggregation = FALSE + AND ( + ( + u.signupCompletedAt IS NOT NULL + AND ( + (u.signupCompletedAt >= :start AND u.signupCompletedAt < :end + AND ud.createdAt <= u.signupCompletedAt) + OR (ud.createdAt >= :start AND ud.createdAt < :end + AND u.signupCompletedAt < ud.createdAt) + ) + ) + OR ( + u.signupCompletedAt IS NULL + AND u.signupStatus IN (:completed, :withdrawn) + AND ( + (u.joinedAt >= :start AND u.joinedAt < :end + AND ud.createdAt <= u.joinedAt) + OR (ud.createdAt >= :start AND ud.createdAt < :end + AND u.joinedAt < ud.createdAt) + ) + ) + ) + ) + ) GROUP BY ud.platform - """) + """, Object[].class) + .setParameter("start", start) .setParameter("end", end) + .setParameter("initialAggregation", initialAggregation) + .setParameter("completed", SignupStatusType.COMPLETED) + .setParameter("withdrawn", SignupStatusType.WITHDRAWN) .getResultList(); Map payload = new HashMap<>(); for (Object[] row : rows) { payload.put(row[0].toString(), ((Number) row[1]).longValue()); } - dailyRepo.upsertPayload(StatMetric.USER_DEVICE_PLATFORM_TOTAL, date, payload); + dailyRepo.upsertPayload(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, date, payload); } /* Helpers */ diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatBatchScheduler.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatBatchScheduler.java index a9c4ac30..6a09754c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatBatchScheduler.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatBatchScheduler.java @@ -42,9 +42,7 @@ public void runDailyAggregation() { StatMetric.USER_DAU, StatMetric.USER_WAU, StatMetric.USER_MAU, - - StatMetric.USER_SIGNUP_SOURCE_TOTAL, - StatMetric.USER_DEVICE_PLATFORM_TOTAL + StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE )) { var last = dailyRepo.findLastDateOf(metric).orElse(null); LocalDate from = (last == null) ? yesterday : last.plusDays(1); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryService.java index 0b3789d6..943954b9 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryService.java @@ -21,15 +21,20 @@ @RequiredArgsConstructor public class StatQueryService { + private static final String UNKNOWN_SIGNUP_SOURCE = "UNKNOWN"; + private final DailyStatRepository dailyRepo; + @SuppressWarnings("deprecation") public StatSeriesResponse getSeries(List metrics, String period) { if (metrics == null || metrics.isEmpty()) return StatSeriesResponse.empty(); LocalDateRange range = parsePeriod(period); List out = new ArrayList<>(); for (StatMetric m : metrics) { - List rows = dailyRepo.findSeriesByMetric(m, range.startDate(), range.endDate()); + List rows = m == StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE + ? dailyRepo.findSeriesByMetric(m, null, range.endDate()) + : dailyRepo.findSeriesByMetric(m, range.startDate(), range.endDate()); if (m == StatMetric.BIRD_ID_RESOLUTION_STATS_28D) { var minSeries = new StatSeriesResponse.ComponentSeries( @@ -55,12 +60,23 @@ public StatSeriesResponse getSeries(List metrics, String period) { out.add(StatSeriesResponse.multi(m.name(), List.of(minSeries, maxSeries, avgSeries, stdSeries))); + } else if (m == StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE) { + List components = Arrays.stream(DevicePlatform.values()) + .map(platform -> cumulativePlatformSeries(platform, rows, range.startDate())) + .toList(); + out.add(StatSeriesResponse.multi(m.name(), components)); + } else if (m == StatMetric.USER_SIGNUP_SOURCE_TOTAL) { - List components = Arrays.stream(SignupSourceType.values()) + List keys = new ArrayList<>(Arrays.stream(SignupSourceType.values()) + .map(SignupSourceType::name) + .toList()); + keys.add(UNKNOWN_SIGNUP_SOURCE); + + List components = keys.stream() .map(src -> new StatSeriesResponse.ComponentSeries( - src.name(), + src, rows.stream().map(s -> - new StatSeriesResponse.Point(s.getDate(), numberOrNull(s.getPayload().get(src.name())))).toList() + new StatSeriesResponse.Point(s.getDate(), numberOrZero(s.getPayload().get(src)))).toList() )).toList(); out.add(StatSeriesResponse.multi(m.name(), components)); @@ -69,7 +85,7 @@ public StatSeriesResponse getSeries(List metrics, String period) { .map(p -> new StatSeriesResponse.ComponentSeries( p.name(), rows.stream().map(s -> - new StatSeriesResponse.Point(s.getDate(), numberOrNull(s.getPayload().get(p.name())))).toList() + new StatSeriesResponse.Point(s.getDate(), numberOrZero(s.getPayload().get(p.name())))).toList() )).toList(); out.add(StatSeriesResponse.multi(m.name(), components)); @@ -87,6 +103,27 @@ private static Number numberOrNull(Object o) { return (o instanceof Number n) ? n : null; } + private static Number numberOrZero(Object o) { + return (o instanceof Number n) ? n : 0L; + } + + private StatSeriesResponse.ComponentSeries cumulativePlatformSeries( + DevicePlatform platform, + List rows, + LocalDate startDate + ) { + long cumulative = 0L; + List points = new ArrayList<>(); + + for (DailyStat row : rows) { + cumulative += numberOrZero(row.getPayload().get(platform.name())).longValue(); + if (startDate == null || !row.getDate().isBefore(startDate)) { + points.add(new StatSeriesResponse.Point(row.getDate(), cumulative)); + } + } + return new StatSeriesResponse.ComponentSeries(platform.name(), points); + } + private LocalDateRange parsePeriod(String period) { if (period == null || period.isBlank()) { return LocalDateRange.empty(); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/core/entity/StatMetric.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/core/entity/StatMetric.java index f322c23a..c4dbea73 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/core/entity/StatMetric.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/core/entity/StatMetric.java @@ -14,6 +14,12 @@ public enum StatMetric { USER_WAU, // 주간 활성 사용자 수(마지막 7일 rolling) USER_MAU, // 월간 활성 사용자 수(마지막 30일 rolling) - USER_SIGNUP_SOURCE_TOTAL, // 누적 가입 경로별 가입자 수 (스냅샷, 멀티값) — signupCompletedAt 기준 - USER_DEVICE_PLATFORM_TOTAL // 누적 플랫폼별 유니크 유저 수 (스냅샷, 멀티값) — UserDevice.createdAt 기준 + /** @deprecated 현재 현황 API(/admin/stats/current-users)를 사용한다. 기존 일별 데이터 조회 호환용이다. */ + @Deprecated + USER_SIGNUP_SOURCE_TOTAL, + /** @deprecated 현재 현황 API(/admin/stats/current-users)를 사용한다. 기존 일별 데이터 조회 호환용이다. */ + @Deprecated + USER_DEVICE_PLATFORM_TOTAL, + + USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE // 플랫폼별 누적 가입 사용자 수 (일별 증분 저장, 멀티값) } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryServiceTest.java new file mode 100644 index 00000000..76dd7261 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/CurrentUserStatQueryServiceTest.java @@ -0,0 +1,80 @@ +package org.devkor.apu.saerok_server.domain.admin.stat.application; + +import org.devkor.apu.saerok_server.domain.admin.stat.api.dto.response.CurrentUserStatResponse; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupSourceType; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; +import org.devkor.apu.saerok_server.testsupport.builder.UserBuilder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import(CurrentUserStatQueryService.class) +@ActiveProfiles("test") +class CurrentUserStatQueryServiceTest extends AbstractPostgresContainerTest { + + @Autowired TestEntityManager em; + @Autowired CurrentUserStatQueryService sut; + + @Test + @DisplayName("현재 가입 완료 사용자, 가입 경로, 활성 푸시 플랫폼을 함께 집계한다") + void getCurrentUserStats_countsOnlyCurrentCompletedUsersAndActivePushDevices() { + User iosOnly = completedUser(SignupSourceType.INSTAGRAM); + activeDevice(iosOnly, "ios-device-1", DevicePlatform.IOS); + + User bothPlatforms = completedUser(SignupSourceType.FRIEND); + activeDevice(bothPlatforms, "ios-device-2", DevicePlatform.IOS); + activeDevice(bothPlatforms, "android-device-1", DevicePlatform.ANDROID); + + completedUser(null); + + User inactiveDeviceUser = completedUser(SignupSourceType.INSTAGRAM); + UserDevice inactiveDevice = activeDevice(inactiveDeviceUser, "android-device-2", DevicePlatform.ANDROID); + inactiveDevice.deactivateToken(); + + User incompleteUser = new UserBuilder(em).build(); + activeDevice(incompleteUser, "ios-device-3", DevicePlatform.IOS); + + User deletedUser = completedUser(SignupSourceType.COMMUNITY); + activeDevice(deletedUser, "android-device-3", DevicePlatform.ANDROID); + deletedUser.softDelete(); + + em.flush(); + em.clear(); + + CurrentUserStatResponse response = sut.getCurrentUserStats(); + + assertThat(response.completedUserCount()).isEqualTo(4L); + assertThat(response.signupSourceCounts()) + .containsEntry("INSTAGRAM", 2L) + .containsEntry("FRIEND", 1L) + .containsEntry("UNKNOWN", 1L) + .containsEntry("COMMUNITY", 0L); + assertThat(response.activePushUserCountsByPlatform()) + .containsEntry("IOS", 2L) + .containsEntry("ANDROID", 1L); + } + + private User completedUser(SignupSourceType signupSource) { + User user = new UserBuilder(em).build(); + user.setSignupSource(signupSource); + user.setSignupStatus(SignupStatusType.COMPLETED); + return user; + } + + private UserDevice activeDevice(User user, String deviceId, DevicePlatform platform) { + UserDevice device = UserDevice.create(user, deviceId, "token-" + deviceId, platform); + em.persist(device); + return device; + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/PlatformSignupTrendAggregationServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/PlatformSignupTrendAggregationServiceTest.java new file mode 100644 index 00000000..850623a0 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/PlatformSignupTrendAggregationServiceTest.java @@ -0,0 +1,160 @@ +package org.devkor.apu.saerok_server.domain.admin.stat.application; + +import org.devkor.apu.saerok_server.domain.admin.stat.core.entity.StatMetric; +import org.devkor.apu.saerok_server.domain.admin.stat.core.repository.BirdIdRequestHistoryRepository; +import org.devkor.apu.saerok_server.domain.admin.stat.core.repository.DailyStatRepository; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupSourceType; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; +import org.devkor.apu.saerok_server.testsupport.builder.UserBuilder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.EnumSet; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import({ + StatAggregationService.class, + DailyStatRepository.class, + BirdIdRequestHistoryRepository.class +}) +@ActiveProfiles("test") +class PlatformSignupTrendAggregationServiceTest extends AbstractPostgresContainerTest { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + @Autowired TestEntityManager em; + @Autowired StatAggregationService sut; + @Autowired DailyStatRepository dailyRepo; + + @Test + @DisplayName("플랫폼 가입 증분은 첫 기기 등록과 가입 완료가 모두 충족된 날에 사용자당 한 번만 저장한다") + void aggregatePlatformSignupDaily_countsFirstPlatformAdoptionOnly() { + User iosOnly = completedUser(); + device(iosOnly, "ios-device-1", DevicePlatform.IOS); + device(iosOnly, "ios-device-2", DevicePlatform.IOS); + + User bothPlatforms = completedUser(); + device(bothPlatforms, "ios-device-3", DevicePlatform.IOS); + device(bothPlatforms, "android-device-1", DevicePlatform.ANDROID); + + User incomplete = new UserBuilder(em).build(); + device(incomplete, "android-device-2", DevicePlatform.ANDROID); + + User withdrawn = completedUser(); + device(withdrawn, "android-device-3", DevicePlatform.ANDROID); + withdrawn.anonymizeForWithdrawal(); + + em.flush(); + em.clear(); + + LocalDate date = LocalDate.now(KST); + sut.aggregateFor(date, EnumSet.of(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE)); + + Map payload = dailyRepo + .findByMetricAndDate(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, date) + .orElseThrow() + .getPayload(); + + assertThat(((Number) payload.get("IOS")).longValue()).isEqualTo(2L); + assertThat(((Number) payload.get("ANDROID")).longValue()).isEqualTo(2L); + } + + @Test + @DisplayName("기기 등록이 가입 완료보다 빠르면 가입 완료일에 플랫폼 가입 증분을 저장한다") + void aggregatePlatformSignupDaily_usesTheLaterOfDeviceRegistrationAndSignupCompletion() { + LocalDate signupDate = LocalDate.now(KST); + User user = new UserBuilder(em).build(); + UserDevice device = UserDevice.create(user, "ios-device-before-signup", "token-before-signup", DevicePlatform.IOS); + em.persist(device); + em.flush(); + + updateDeviceCreatedAt(device.getId(), signupDate.minusDays(1).atTime(10, 0).atZone(KST).toOffsetDateTime()); + completeOn(user.getId(), signupDate.atTime(10, 0).atZone(KST).toOffsetDateTime()); + em.clear(); + + sut.aggregateFor(signupDate, EnumSet.of(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE)); + + Map payload = dailyRepo + .findByMetricAndDate(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, signupDate) + .orElseThrow() + .getPayload(); + + assertThat(((Number) payload.get("IOS")).longValue()).isEqualTo(1L); + } + + @Test + @DisplayName("첫 집계는 기존 플랫폼 가입자를 기준값으로 저장하고 이후 집계에서 다시 더하지 않는다") + void aggregatePlatformSignupDaily_seedsExistingUsersOnlyOnTheFirstAggregation() { + LocalDate firstAggregationDate = LocalDate.now(KST); + User user = new UserBuilder(em).build(); + UserDevice device = UserDevice.create(user, "existing-ios-device", "existing-token", DevicePlatform.IOS); + em.persist(device); + em.flush(); + + completeOn(user.getId(), firstAggregationDate.minusDays(5).atTime(10, 0).atZone(KST).toOffsetDateTime()); + updateDeviceCreatedAt(device.getId(), firstAggregationDate.minusDays(4).atTime(10, 0).atZone(KST).toOffsetDateTime()); + em.clear(); + + sut.aggregateFor(firstAggregationDate, EnumSet.of(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE)); + sut.aggregateFor(firstAggregationDate.plusDays(1), EnumSet.of(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE)); + + Map firstPayload = dailyRepo + .findByMetricAndDate(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, firstAggregationDate) + .orElseThrow() + .getPayload(); + Map nextPayload = dailyRepo + .findByMetricAndDate(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, firstAggregationDate.plusDays(1)) + .orElseThrow() + .getPayload(); + + assertThat(((Number) firstPayload.get("IOS")).longValue()).isEqualTo(1L); + assertThat(nextPayload).doesNotContainKey("IOS"); + } + + private User completedUser() { + User user = new UserBuilder(em).build(); + user.setSignupSource(SignupSourceType.INSTAGRAM); + user.setSignupStatus(SignupStatusType.COMPLETED); + return user; + } + + private void device(User user, String deviceId, DevicePlatform platform) { + em.persist(UserDevice.create(user, deviceId, "token-" + deviceId, platform)); + } + + private void updateDeviceCreatedAt(Long deviceId, OffsetDateTime createdAt) { + em.getEntityManager() + .createNativeQuery("UPDATE user_device SET created_at = ?1 WHERE id = ?2") + .setParameter(1, createdAt) + .setParameter(2, deviceId) + .executeUpdate(); + } + + private void completeOn(Long userId, OffsetDateTime completedAt) { + em.getEntityManager() + .createNativeQuery(""" + UPDATE users + SET signup_status = 'COMPLETED', + signup_completed_at = ?1 + WHERE id = ?2 + """) + .setParameter(1, completedAt) + .setParameter(2, userId) + .executeUpdate(); + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryServiceTest.java new file mode 100644 index 00000000..fbd4fa54 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryServiceTest.java @@ -0,0 +1,61 @@ +package org.devkor.apu.saerok_server.domain.admin.stat.application; + +import org.devkor.apu.saerok_server.domain.admin.stat.api.dto.response.StatSeriesResponse; +import org.devkor.apu.saerok_server.domain.admin.stat.core.entity.DailyStat; +import org.devkor.apu.saerok_server.domain.admin.stat.core.entity.StatMetric; +import org.devkor.apu.saerok_server.domain.admin.stat.core.repository.DailyStatRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StatQueryServiceTest { + + @Mock DailyStatRepository dailyRepo; + @InjectMocks StatQueryService sut; + + @Test + void getSeries_cumulatesDailyPlatformSignupCountsFromBeforeTheRequestedRange() { + LocalDate firstDate = LocalDate.of(2026, 7, 1); + LocalDate startDate = firstDate.plusDays(1); + LocalDate endDate = firstDate.plusDays(2); + when(dailyRepo.findSeriesByMetric(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, null, endDate)) + .thenReturn(List.of( + DailyStat.ofPayload(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, firstDate, + Map.of("IOS", 2L)), + DailyStat.ofPayload(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, startDate, + Map.of("IOS", 1L, "ANDROID", 3L)), + DailyStat.ofPayload(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE, endDate, + Map.of("ANDROID", 2L)) + )); + + StatSeriesResponse response = sut.getSeries( + List.of(StatMetric.USER_DEVICE_PLATFORM_SIGNUP_CUMULATIVE), + startDate + "," + endDate + ); + + List components = response.series().getFirst().components(); + assertThat(valuesOf(components, "IOS")).containsExactly(3L, 3L); + assertThat(valuesOf(components, "ANDROID")).containsExactly(3L, 5L); + } + + private List valuesOf(List components, String key) { + return components.stream() + .filter(component -> component.key().equals(key)) + .findFirst() + .orElseThrow() + .points() + .stream() + .map(point -> point.value().longValue()) + .toList(); + } +}