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
57 changes: 57 additions & 0 deletions src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package apu.saerok_admin.infra.stat;

import apu.saerok_admin.infra.SaerokApiProps;
import apu.saerok_admin.infra.stat.dto.StatSeriesResponse;
import java.net.URI;
import java.util.Collection;
import java.util.Objects;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriBuilder;

@Component
public class AdminStatClient {

private static final String[] ADMIN_STATS_SEGMENTS = {"admin", "stats"};
private static final String SERIES_SEGMENT = "series";

private final RestClient saerokRestClient;
private final String[] missingPrefixSegments;

public AdminStatClient(RestClient saerokRestClient, SaerokApiProps saerokApiProps) {
this.saerokRestClient = saerokRestClient;
this.missingPrefixSegments = saerokApiProps.missingPrefixSegments().toArray(new String[0]);
}

public StatSeriesResponse fetchSeries(Collection<StatMetric> metrics) {
if (metrics == null || metrics.isEmpty()) {
throw new IllegalArgumentException("metrics must not be empty");
}

StatSeriesResponse response = saerokRestClient.get()
.uri(uriBuilder -> buildSeriesUri(uriBuilder, metrics))
.retrieve()
.body(StatSeriesResponse.class);

if (response == null) {
throw new IllegalStateException("Empty response from admin stats API");
}

return response;
}

private URI buildSeriesUri(UriBuilder builder, Collection<StatMetric> metrics) {
if (missingPrefixSegments.length > 0) {
builder.pathSegment(missingPrefixSegments);
}
builder.pathSegment(ADMIN_STATS_SEGMENTS);
builder.pathSegment(SERIES_SEGMENT);

metrics.stream()
.filter(Objects::nonNull)
.map(Enum::name)
.forEach(metric -> builder.queryParam("metric", metric));

return builder.build();
}
}
143 changes: 143 additions & 0 deletions src/main/java/apu/saerok_admin/infra/stat/StatMetric.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package apu.saerok_admin.infra.stat;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;

public enum StatMetric {
COLLECTION_TOTAL_COUNT(
"새록 총 개수",
"등록된 새록의 총 개수",
MetricUnit.COUNT,
false,
Map.of(),
true
),
COLLECTION_PRIVATE_RATIO(
"비공개 새록 비율",
"전체 새록 중 비공개로 설정된 비율",
MetricUnit.RATIO,
false,
Map.of(),
false
),
BIRD_ID_PENDING_COUNT(
"진행 중인 동정 요청 개수",
"이 날 진행 중인 동정 요청의 개수\n(= \"이름 모를 새 새록\"의 개수)",
MetricUnit.COUNT,
false,
Map.of(),
false
),
BIRD_ID_RESOLVED_COUNT(
"동정 의견 채택 횟수",
"이 날 동정 의견이 몇 번 채택됐는지 횟수",
MetricUnit.COUNT,
false,
Map.of(),
true
),
BIRD_ID_RESOLUTION_STATS(
"동정 의견 채택 시간",
"동정 요청 후 채택되기까지 평균적으로 걸린 시간",
MetricUnit.HOURS,
true,
orderedComponentLabels(),
false
),

// ===== 유저 지표 =====
USER_COMPLETED_TOTAL(
"누적 가입자 수",
"현재 가입된 총 사용자 수",
MetricUnit.COUNT,
false,
Map.of(),
true
),
USER_SIGNUP_DAILY(
"일일 가입자 수",
"이 날 신규 가입한 사용자 수",
MetricUnit.COUNT,
false,
Map.of(),
false
),
USER_WITHDRAWAL_DAILY(
"일일 탈퇴자 수",
"이 날 탈퇴한 사용자 수",
MetricUnit.COUNT,
false,
Map.of(),
false
),
USER_DAU(
"DAU",
"일일 활성 사용자 수(그 날 서비스에 몇 명이 접속했는지 기준)",
MetricUnit.COUNT,
false,
Map.of(),
true
),
USER_WAU(
"WAU",
"주간 활성 사용자 수(최근 7일)",
MetricUnit.COUNT,
false,
Map.of(),
false
),
USER_MAU(
"MAU",
"월간 활성 사용자 수(최근 30일)",
MetricUnit.COUNT,
false,
Map.of(),
false
);

private final String label;
private final String description;
private final MetricUnit unit;
private final boolean multiSeries;
private final Map<String, String> componentLabels;
private final boolean defaultActive;

StatMetric(
String label,
String description,
MetricUnit unit,
boolean multiSeries,
Map<String, String> componentLabels,
boolean defaultActive
) {
this.label = label;
this.description = description;
this.unit = unit;
this.multiSeries = multiSeries;
this.componentLabels = componentLabels;
this.defaultActive = defaultActive;
}

public String label() { return label; }
public String description() { return description; }
public MetricUnit unit() { return unit; }
public boolean multiSeries() { return multiSeries; }
public Map<String, String> componentLabels() { return componentLabels; }
public boolean defaultActive() { return defaultActive; }

private static Map<String, String> orderedComponentLabels() {
Map<String, String> labels = new LinkedHashMap<>();
labels.put("min_hours", "최소");
labels.put("max_hours", "최대");
labels.put("avg_hours", "평균");
labels.put("stddev_hours", "표준편차");
return Collections.unmodifiableMap(labels);
}

public enum MetricUnit {
COUNT,
RATIO,
HOURS
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package apu.saerok_admin.infra.stat.dto;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.time.LocalDate;
import java.util.List;

@JsonIgnoreProperties(ignoreUnknown = true)
public record StatSeriesResponse(List<Series> series) {

@JsonIgnoreProperties(ignoreUnknown = true)
public record Series(
String metric,
List<Point> points,
List<ComponentSeries> components
) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
public record ComponentSeries(
String key,
List<Point> points
) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
public record Point(
LocalDate date,
Number value
) {
}
}
101 changes: 101 additions & 0 deletions src/main/java/apu/saerok_admin/web/ServiceInsightController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package apu.saerok_admin.web;

import apu.saerok_admin.web.serviceinsight.ServiceInsightService;
import apu.saerok_admin.web.view.Breadcrumb;
import apu.saerok_admin.web.view.ServiceInsightViewModel;
import apu.saerok_admin.web.view.ToastMessage;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;

@Controller
public class ServiceInsightController {

private static final Logger log = LoggerFactory.getLogger(ServiceInsightController.class);
private static final String ERROR_TOAST_ID = "toastServiceInsightError";

private final ServiceInsightService serviceInsightService;
private final ObjectMapper objectMapper;

public ServiceInsightController(ServiceInsightService serviceInsightService, ObjectMapper objectMapper) {
this.serviceInsightService = serviceInsightService;
this.objectMapper = objectMapper;
}

@GetMapping("/service-insight")
public String serviceInsight(Model model) {
model.addAttribute("pageTitle", "서비스 인사이트");
model.addAttribute("activeMenu", "serviceInsight");
model.addAttribute("breadcrumbs", List.of(
Breadcrumb.of("대시보드", "/"),
Breadcrumb.active("서비스 인사이트")
));
ensureToastMessages(model);

ServiceInsightViewModel viewModel;
try {
viewModel = serviceInsightService.loadViewModel();
} catch (RestClientResponseException exception) {
log.warn(
"Failed to load service insight stats. status={}, body={}",
exception.getStatusCode(),
exception.getResponseBodyAsString(),
exception
);
viewModel = serviceInsightService.defaultViewModel();
attachErrorToast(model);
} catch (RestClientException | IllegalStateException exception) {
log.warn("Failed to load service insight stats.", exception);
viewModel = serviceInsightService.defaultViewModel();
attachErrorToast(model);
}

model.addAttribute("serviceInsight", viewModel);
model.addAttribute("chartDataJson", toJson(viewModel));
return "service-insight/index";
}

private void ensureToastMessages(Model model) {
if (!model.containsAttribute("toastMessages")) {
model.addAttribute("toastMessages", List.of());
}
}

private void attachErrorToast(Model model) {
ToastMessage errorToast = new ToastMessage(
ERROR_TOAST_ID,
"데이터 로드 실패",
"통계 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.",
"danger",
false
);
List<ToastMessage> messages = new ArrayList<>();
Object existing = model.getAttribute("toastMessages");
if (existing instanceof List<?> list) {
for (Object item : list) {
if (item instanceof ToastMessage toastMessage && !ERROR_TOAST_ID.equals(toastMessage.id())) {
messages.add(toastMessage);
}
}
}
messages.add(errorToast);
model.addAttribute("toastMessages", List.copyOf(messages));
}

private String toJson(ServiceInsightViewModel viewModel) {
try {
return objectMapper.writeValueAsString(viewModel);
} catch (JsonProcessingException exception) {
log.warn("Failed to serialize service insight payload.", exception);
return "{\"metricOptions\":[],\"series\":[],\"componentLabels\":{}}";
}
}
}
Loading
Loading