Skip to content

Commit f76972e

Browse files
Merge pull request #46 from DevKor-github/develop
어드민 운영 배포
2 parents c797daa + 30f5c23 commit f76972e

13 files changed

Lines changed: 1871 additions & 495 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package apu.saerok_admin.infra.stat;
2+
3+
import apu.saerok_admin.infra.SaerokApiProps;
4+
import apu.saerok_admin.infra.stat.dto.StatSeriesResponse;
5+
import java.net.URI;
6+
import java.util.Collection;
7+
import java.util.Objects;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.web.client.RestClient;
10+
import org.springframework.web.util.UriBuilder;
11+
12+
@Component
13+
public class AdminStatClient {
14+
15+
private static final String[] ADMIN_STATS_SEGMENTS = {"admin", "stats"};
16+
private static final String SERIES_SEGMENT = "series";
17+
18+
private final RestClient saerokRestClient;
19+
private final String[] missingPrefixSegments;
20+
21+
public AdminStatClient(RestClient saerokRestClient, SaerokApiProps saerokApiProps) {
22+
this.saerokRestClient = saerokRestClient;
23+
this.missingPrefixSegments = saerokApiProps.missingPrefixSegments().toArray(new String[0]);
24+
}
25+
26+
public StatSeriesResponse fetchSeries(Collection<StatMetric> metrics) {
27+
if (metrics == null || metrics.isEmpty()) {
28+
throw new IllegalArgumentException("metrics must not be empty");
29+
}
30+
31+
StatSeriesResponse response = saerokRestClient.get()
32+
.uri(uriBuilder -> buildSeriesUri(uriBuilder, metrics))
33+
.retrieve()
34+
.body(StatSeriesResponse.class);
35+
36+
if (response == null) {
37+
throw new IllegalStateException("Empty response from admin stats API");
38+
}
39+
40+
return response;
41+
}
42+
43+
private URI buildSeriesUri(UriBuilder builder, Collection<StatMetric> metrics) {
44+
if (missingPrefixSegments.length > 0) {
45+
builder.pathSegment(missingPrefixSegments);
46+
}
47+
builder.pathSegment(ADMIN_STATS_SEGMENTS);
48+
builder.pathSegment(SERIES_SEGMENT);
49+
50+
metrics.stream()
51+
.filter(Objects::nonNull)
52+
.map(Enum::name)
53+
.forEach(metric -> builder.queryParam("metric", metric));
54+
55+
return builder.build();
56+
}
57+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package apu.saerok_admin.infra.stat;
2+
3+
import java.util.Collections;
4+
import java.util.LinkedHashMap;
5+
import java.util.Map;
6+
7+
public enum StatMetric {
8+
COLLECTION_TOTAL_COUNT(
9+
"새록 총 개수",
10+
"등록된 새록의 총 개수",
11+
MetricUnit.COUNT,
12+
false,
13+
Map.of(),
14+
true
15+
),
16+
COLLECTION_PRIVATE_RATIO(
17+
"비공개 새록 비율",
18+
"전체 새록 중 비공개로 설정된 비율",
19+
MetricUnit.RATIO,
20+
false,
21+
Map.of(),
22+
false
23+
),
24+
BIRD_ID_PENDING_COUNT(
25+
"진행 중인 동정 요청 개수",
26+
"이 날 진행 중인 동정 요청의 개수\n(= \"이름 모를 새 새록\"의 개수)",
27+
MetricUnit.COUNT,
28+
false,
29+
Map.of(),
30+
false
31+
),
32+
BIRD_ID_RESOLVED_COUNT(
33+
"동정 의견 채택 횟수",
34+
"이 날 동정 의견이 몇 번 채택됐는지 횟수",
35+
MetricUnit.COUNT,
36+
false,
37+
Map.of(),
38+
true
39+
),
40+
BIRD_ID_RESOLUTION_STATS(
41+
"동정 의견 채택 시간",
42+
"동정 요청 후 채택되기까지 평균적으로 걸린 시간",
43+
MetricUnit.HOURS,
44+
true,
45+
orderedComponentLabels(),
46+
false
47+
),
48+
49+
// ===== 유저 지표 =====
50+
USER_COMPLETED_TOTAL(
51+
"누적 가입자 수",
52+
"현재 가입된 총 사용자 수",
53+
MetricUnit.COUNT,
54+
false,
55+
Map.of(),
56+
true
57+
),
58+
USER_SIGNUP_DAILY(
59+
"일일 가입자 수",
60+
"이 날 신규 가입한 사용자 수",
61+
MetricUnit.COUNT,
62+
false,
63+
Map.of(),
64+
false
65+
),
66+
USER_WITHDRAWAL_DAILY(
67+
"일일 탈퇴자 수",
68+
"이 날 탈퇴한 사용자 수",
69+
MetricUnit.COUNT,
70+
false,
71+
Map.of(),
72+
false
73+
),
74+
USER_DAU(
75+
"DAU",
76+
"일일 활성 사용자 수(그 날 서비스에 몇 명이 접속했는지 기준)",
77+
MetricUnit.COUNT,
78+
false,
79+
Map.of(),
80+
true
81+
),
82+
USER_WAU(
83+
"WAU",
84+
"주간 활성 사용자 수(최근 7일)",
85+
MetricUnit.COUNT,
86+
false,
87+
Map.of(),
88+
false
89+
),
90+
USER_MAU(
91+
"MAU",
92+
"월간 활성 사용자 수(최근 30일)",
93+
MetricUnit.COUNT,
94+
false,
95+
Map.of(),
96+
false
97+
);
98+
99+
private final String label;
100+
private final String description;
101+
private final MetricUnit unit;
102+
private final boolean multiSeries;
103+
private final Map<String, String> componentLabels;
104+
private final boolean defaultActive;
105+
106+
StatMetric(
107+
String label,
108+
String description,
109+
MetricUnit unit,
110+
boolean multiSeries,
111+
Map<String, String> componentLabels,
112+
boolean defaultActive
113+
) {
114+
this.label = label;
115+
this.description = description;
116+
this.unit = unit;
117+
this.multiSeries = multiSeries;
118+
this.componentLabels = componentLabels;
119+
this.defaultActive = defaultActive;
120+
}
121+
122+
public String label() { return label; }
123+
public String description() { return description; }
124+
public MetricUnit unit() { return unit; }
125+
public boolean multiSeries() { return multiSeries; }
126+
public Map<String, String> componentLabels() { return componentLabels; }
127+
public boolean defaultActive() { return defaultActive; }
128+
129+
private static Map<String, String> orderedComponentLabels() {
130+
Map<String, String> labels = new LinkedHashMap<>();
131+
labels.put("min_hours", "최소");
132+
labels.put("max_hours", "최대");
133+
labels.put("avg_hours", "평균");
134+
labels.put("stddev_hours", "표준편차");
135+
return Collections.unmodifiableMap(labels);
136+
}
137+
138+
public enum MetricUnit {
139+
COUNT,
140+
RATIO,
141+
HOURS
142+
}
143+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package apu.saerok_admin.infra.stat.dto;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import java.time.LocalDate;
5+
import java.util.List;
6+
7+
@JsonIgnoreProperties(ignoreUnknown = true)
8+
public record StatSeriesResponse(List<Series> series) {
9+
10+
@JsonIgnoreProperties(ignoreUnknown = true)
11+
public record Series(
12+
String metric,
13+
List<Point> points,
14+
List<ComponentSeries> components
15+
) {
16+
}
17+
18+
@JsonIgnoreProperties(ignoreUnknown = true)
19+
public record ComponentSeries(
20+
String key,
21+
List<Point> points
22+
) {
23+
}
24+
25+
@JsonIgnoreProperties(ignoreUnknown = true)
26+
public record Point(
27+
LocalDate date,
28+
Number value
29+
) {
30+
}
31+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package apu.saerok_admin.web;
2+
3+
import apu.saerok_admin.web.serviceinsight.ServiceInsightService;
4+
import apu.saerok_admin.web.view.Breadcrumb;
5+
import apu.saerok_admin.web.view.ServiceInsightViewModel;
6+
import apu.saerok_admin.web.view.ToastMessage;
7+
import com.fasterxml.jackson.core.JsonProcessingException;
8+
import com.fasterxml.jackson.databind.ObjectMapper;
9+
import java.util.ArrayList;
10+
import java.util.List;
11+
import org.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
13+
import org.springframework.stereotype.Controller;
14+
import org.springframework.ui.Model;
15+
import org.springframework.web.bind.annotation.GetMapping;
16+
import org.springframework.web.client.RestClientException;
17+
import org.springframework.web.client.RestClientResponseException;
18+
19+
@Controller
20+
public class ServiceInsightController {
21+
22+
private static final Logger log = LoggerFactory.getLogger(ServiceInsightController.class);
23+
private static final String ERROR_TOAST_ID = "toastServiceInsightError";
24+
25+
private final ServiceInsightService serviceInsightService;
26+
private final ObjectMapper objectMapper;
27+
28+
public ServiceInsightController(ServiceInsightService serviceInsightService, ObjectMapper objectMapper) {
29+
this.serviceInsightService = serviceInsightService;
30+
this.objectMapper = objectMapper;
31+
}
32+
33+
@GetMapping("/service-insight")
34+
public String serviceInsight(Model model) {
35+
model.addAttribute("pageTitle", "서비스 인사이트");
36+
model.addAttribute("activeMenu", "serviceInsight");
37+
model.addAttribute("breadcrumbs", List.of(
38+
Breadcrumb.of("대시보드", "/"),
39+
Breadcrumb.active("서비스 인사이트")
40+
));
41+
ensureToastMessages(model);
42+
43+
ServiceInsightViewModel viewModel;
44+
try {
45+
viewModel = serviceInsightService.loadViewModel();
46+
} catch (RestClientResponseException exception) {
47+
log.warn(
48+
"Failed to load service insight stats. status={}, body={}",
49+
exception.getStatusCode(),
50+
exception.getResponseBodyAsString(),
51+
exception
52+
);
53+
viewModel = serviceInsightService.defaultViewModel();
54+
attachErrorToast(model);
55+
} catch (RestClientException | IllegalStateException exception) {
56+
log.warn("Failed to load service insight stats.", exception);
57+
viewModel = serviceInsightService.defaultViewModel();
58+
attachErrorToast(model);
59+
}
60+
61+
model.addAttribute("serviceInsight", viewModel);
62+
model.addAttribute("chartDataJson", toJson(viewModel));
63+
return "service-insight/index";
64+
}
65+
66+
private void ensureToastMessages(Model model) {
67+
if (!model.containsAttribute("toastMessages")) {
68+
model.addAttribute("toastMessages", List.of());
69+
}
70+
}
71+
72+
private void attachErrorToast(Model model) {
73+
ToastMessage errorToast = new ToastMessage(
74+
ERROR_TOAST_ID,
75+
"데이터 로드 실패",
76+
"통계 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.",
77+
"danger",
78+
false
79+
);
80+
List<ToastMessage> messages = new ArrayList<>();
81+
Object existing = model.getAttribute("toastMessages");
82+
if (existing instanceof List<?> list) {
83+
for (Object item : list) {
84+
if (item instanceof ToastMessage toastMessage && !ERROR_TOAST_ID.equals(toastMessage.id())) {
85+
messages.add(toastMessage);
86+
}
87+
}
88+
}
89+
messages.add(errorToast);
90+
model.addAttribute("toastMessages", List.copyOf(messages));
91+
}
92+
93+
private String toJson(ServiceInsightViewModel viewModel) {
94+
try {
95+
return objectMapper.writeValueAsString(viewModel);
96+
} catch (JsonProcessingException exception) {
97+
log.warn("Failed to serialize service insight payload.", exception);
98+
return "{\"metricOptions\":[],\"series\":[],\"componentLabels\":{}}";
99+
}
100+
}
101+
}

0 commit comments

Comments
 (0)