Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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')")
Expand All @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Long> signupSourceCounts,
@Schema(description = "플랫폼별 활성 푸시 토큰 보유 사용자 수. 한 사용자가 여러 플랫폼에 중복 포함될 수 있음")
Map<String, Long> activePushUserCountsByPlatform
) {
}
Original file line number Diff line number Diff line change
@@ -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<String, Long> countCompletedUsersBySignupSource() {
Map<String, Long> counts = new LinkedHashMap<>();
for (SignupSourceType source : SignupSourceType.values()) {
counts.put(source.name(), 0L);
}
counts.put(UNKNOWN_SIGNUP_SOURCE, 0L);

List<Object[]> 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<String, Long> countActivePushUsersByPlatform() {
Map<String, Long> counts = new LinkedHashMap<>();
for (DevicePlatform platform : DevicePlatform.values()) {
counts.put(platform.name(), 0L);
}

List<Object[]> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,8 @@ public void aggregateFor(LocalDate date, Set<StatMetric> 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);
}
}
}
Expand Down Expand Up @@ -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<Object[]> 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<String, Object> 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) {
/**
* 플랫폼별 일일 신규 가입 사용자 수를 저장한다.
*
* <p>사용자-플랫폼마다 첫 기기 등록만 사용하며, 가입 완료와 첫 기기 등록 중 더 늦은 날을
* 플랫폼 가입일로 본다. 조회 단계에서 이 일별 증분을 누적해 증가 추이를 만든다.</p>
*/
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<Object[]> 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<String, Object> 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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<StatMetric> metrics, String period) {
if (metrics == null || metrics.isEmpty()) return StatSeriesResponse.empty();

LocalDateRange range = parsePeriod(period);
List<StatSeriesResponse.Series> out = new ArrayList<>();
for (StatMetric m : metrics) {
List<DailyStat> rows = dailyRepo.findSeriesByMetric(m, range.startDate(), range.endDate());
List<DailyStat> 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(
Expand All @@ -55,12 +60,23 @@ public StatSeriesResponse getSeries(List<StatMetric> 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<StatSeriesResponse.ComponentSeries> 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<StatSeriesResponse.ComponentSeries> components = Arrays.stream(SignupSourceType.values())
List<String> keys = new ArrayList<>(Arrays.stream(SignupSourceType.values())
.map(SignupSourceType::name)
.toList());
keys.add(UNKNOWN_SIGNUP_SOURCE);

List<StatSeriesResponse.ComponentSeries> 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));

Expand All @@ -69,7 +85,7 @@ public StatSeriesResponse getSeries(List<StatMetric> 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));

Expand All @@ -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<DailyStat> rows,
LocalDate startDate
) {
long cumulative = 0L;
List<StatSeriesResponse.Point> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 // 플랫폼별 누적 가입 사용자 수 (일별 증분 저장, 멀티값)
}
Loading
Loading