From 47aeba9eeba629f17268f565f89d23f80d009d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EB=AF=BC=EA=B7=9C?= Date: Sat, 9 May 2026 21:52:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=8F=89=EA=B7=A0=20=ED=9A=8C=EA=B3=A0?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=A3=BC=EA=B8=B0=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=96=B4=EB=93=9C=EB=AF=BC=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /admin/retrospect/creation-cycle 엔드포인트 추가 - 전체 / 팀 회고 / 개인 회고 평균 주기(일) 반환 - 생성 주기별 유저 분포(1-3일, 4-7일, 8-14일, 15-30일, 31일+) 퍼센트 반환 - AdminSpaceRepository에 findAllBySpaceIdIn 쿼리 메서드 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../controller/AdminRetrospectController.java | 10 ++ .../dto/CycleDistributionEntry.java | 7 + .../dto/RetrospectCreationCycleResponse.java | 11 ++ .../enums/RetrospectCycleRange.java | 24 ++++ .../service/AdminRetrospectService.java | 128 ++++++++++++++++++ .../repository/AdminSpaceRepository.java | 3 + 6 files changed, 183 insertions(+) create mode 100644 layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/CycleDistributionEntry.java create mode 100644 layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/RetrospectCreationCycleResponse.java create mode 100644 layer-admin/src/main/java/org/layer/admin/retrospect/enums/RetrospectCycleRange.java diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/AdminRetrospectController.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/AdminRetrospectController.java index 0629a596..71743cf9 100644 --- a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/AdminRetrospectController.java +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/AdminRetrospectController.java @@ -6,6 +6,7 @@ import org.layer.admin.retrospect.controller.dto.CumulativeRetrospectCountResponse; import org.layer.admin.retrospect.controller.dto.MeaningfulRetrospectMemberResponse; import org.layer.admin.retrospect.controller.dto.RetrospectCompletionRateResponse; +import org.layer.admin.retrospect.controller.dto.RetrospectCreationCycleResponse; import org.layer.admin.retrospect.controller.dto.RetrospectOverviewResponse; import org.layer.admin.retrospect.controller.dto.RetrospectRetentionResponse; import org.layer.admin.retrospect.controller.dto.RetrospectStayTimeResponse; @@ -94,4 +95,13 @@ public ResponseEntity getProceedingCTR( ProceedingRetrospectCTRAverageResponse response = adminRetrospectService.getProceedingRetrospectCTR(startDate, endDate); return ResponseEntity.ok().body(response); } + + @GetMapping("/admin/retrospect/creation-cycle") + public ResponseEntity getRetrospectCreationCycle( + @RequestParam(name = "startDate") LocalDateTime startDate, + @RequestParam(name = "endDate") LocalDateTime endDate) { + + RetrospectCreationCycleResponse response = adminRetrospectService.getRetrospectCreationCycle(startDate, endDate); + return ResponseEntity.ok().body(response); + } } diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/CycleDistributionEntry.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/CycleDistributionEntry.java new file mode 100644 index 00000000..d574d27c --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/CycleDistributionEntry.java @@ -0,0 +1,7 @@ +package org.layer.admin.retrospect.controller.dto; + +public record CycleDistributionEntry( + String label, + double percentage +) { +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/RetrospectCreationCycleResponse.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/RetrospectCreationCycleResponse.java new file mode 100644 index 00000000..7e640feb --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/RetrospectCreationCycleResponse.java @@ -0,0 +1,11 @@ +package org.layer.admin.retrospect.controller.dto; + +import java.util.List; + +public record RetrospectCreationCycleResponse( + double overallAverageDays, + double teamAverageDays, + double individualAverageDays, + List distribution +) { +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/enums/RetrospectCycleRange.java b/layer-admin/src/main/java/org/layer/admin/retrospect/enums/RetrospectCycleRange.java new file mode 100644 index 00000000..b6966b5c --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/enums/RetrospectCycleRange.java @@ -0,0 +1,24 @@ +package org.layer.admin.retrospect.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum RetrospectCycleRange { + DAY_1_TO_3("1-3일"), + DAY_4_TO_7("4-7일"), + DAY_8_TO_14("8-14일"), + DAY_15_TO_30("15-30일"), + DAY_31_PLUS("31일+"); + + private final String label; + + public static RetrospectCycleRange from(double days) { + if (days <= 3) return DAY_1_TO_3; + if (days <= 7) return DAY_4_TO_7; + if (days <= 14) return DAY_8_TO_14; + if (days <= 30) return DAY_15_TO_30; + return DAY_31_PLUS; + } +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/service/AdminRetrospectService.java b/layer-admin/src/main/java/org/layer/admin/retrospect/service/AdminRetrospectService.java index f1f6f0f8..abb76026 100644 --- a/layer-admin/src/main/java/org/layer/admin/retrospect/service/AdminRetrospectService.java +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/service/AdminRetrospectService.java @@ -5,10 +5,12 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -17,9 +19,11 @@ import org.layer.admin.member.repository.AdminMemberRepository; import org.layer.admin.retrospect.controller.dto.CumulativeRetrospectCountResponse; +import org.layer.admin.retrospect.controller.dto.CycleDistributionEntry; import org.layer.admin.retrospect.controller.dto.MeaningfulRetrospectMemberResponse; import org.layer.admin.retrospect.controller.dto.ProceedingRetrospectCTRAverageResponse; import org.layer.admin.retrospect.controller.dto.RetrospectCompletionRateResponse; +import org.layer.admin.retrospect.controller.dto.RetrospectCreationCycleResponse; import org.layer.admin.retrospect.controller.dto.RetrospectOverviewResponse; import org.layer.admin.retrospect.controller.dto.RetrospectRetentionResponse; import org.layer.admin.retrospect.controller.dto.RetrospectStayTimeResponse; @@ -30,6 +34,7 @@ import org.layer.admin.retrospect.entity.AdminRetrospectImpression; import org.layer.admin.retrospect.enums.AdminRetrospectStatus; import org.layer.admin.retrospect.enums.AnswerTimeRange; +import org.layer.admin.retrospect.enums.RetrospectCycleRange; import org.layer.admin.retrospect.repository.AdminRetrospectAnswerRepository; import org.layer.admin.retrospect.repository.AdminRetrospectClickRepository; import org.layer.admin.retrospect.repository.AdminRetrospectImpressionRepository; @@ -40,6 +45,8 @@ import org.layer.admin.retrospect.repository.dto.RetrospectAnswerCompletionDto; import org.layer.admin.retrospect.repository.dto.SpaceRetrospectCountDto; import org.layer.admin.space.entity.AdminMemberSpaceRelation; +import org.layer.admin.space.entity.AdminSpaceHistory; +import org.layer.admin.space.enums.AdminSpaceCategory; import org.layer.admin.space.repository.AdminMemberSpaceRelationRepository; import org.layer.admin.space.repository.AdminSpaceRepository; import org.layer.event.retrospect.ClickRetrospectEvent; @@ -343,6 +350,127 @@ public ProceedingRetrospectCTRAverageResponse getProceedingRetrospectCTR(LocalDa return new ProceedingRetrospectCTRAverageResponse(averageCTR); } + public RetrospectCreationCycleResponse getRetrospectCreationCycle(LocalDateTime startDate, LocalDateTime endDate) { + List currentHistories = adminRetrospectHistoryRepository.findAllByEventTimeBetween(startDate, endDate); + + if (currentHistories.isEmpty()) { + return new RetrospectCreationCycleResponse(0.0, 0.0, 0.0, buildEmptyCycleDistribution()); + } + + List prevHistories = adminRetrospectHistoryRepository.findAllByEventTimeBefore(startDate); + + Set allSpaceIds = new HashSet<>(); + currentHistories.forEach(h -> allSpaceIds.add(h.getSpaceId())); + prevHistories.forEach(h -> allSpaceIds.add(h.getSpaceId())); + + Map spaceCategoryMap = adminSpaceRepository.findAllBySpaceIdIn(allSpaceIds) + .stream() + .collect(Collectors.toMap(AdminSpaceHistory::getSpaceId, AdminSpaceHistory::getCategory, (a, b) -> a)); + + Map> allHistoriesByMember = new HashMap<>(); + prevHistories.forEach(h -> allHistoriesByMember.computeIfAbsent(h.getMemberId(), k -> new ArrayList<>()).add(h)); + currentHistories.forEach(h -> allHistoriesByMember.computeIfAbsent(h.getMemberId(), k -> new ArrayList<>()).add(h)); + + Set currentMemberIds = currentHistories.stream() + .map(AdminRetrospectHistory::getMemberId) + .collect(Collectors.toSet()); + + List allGaps = new ArrayList<>(); + List teamGaps = new ArrayList<>(); + List individualGaps = new ArrayList<>(); + Map memberAvgGapMap = new HashMap<>(); + + for (Long memberId : currentMemberIds) { + List memberHistories = allHistoriesByMember.getOrDefault(memberId, Collections.emptyList()); + memberHistories.sort(Comparator.comparing(AdminRetrospectHistory::getEventTime)); + + List memberAllGaps = new ArrayList<>(); + for (int i = 1; i < memberHistories.size(); i++) { + AdminRetrospectHistory curr = memberHistories.get(i); + if (curr.getEventTime().isBefore(startDate)) { + continue; + } + long gapDays = Duration.between(memberHistories.get(i - 1).getEventTime(), curr.getEventTime()).toDays(); + if (gapDays <= 0) { + continue; + } + memberAllGaps.add(gapDays); + allGaps.add(gapDays); + } + + if (!memberAllGaps.isEmpty()) { + memberAvgGapMap.put(memberId, memberAllGaps.stream().mapToLong(Long::longValue).average().orElse(0.0)); + } + + List teamRetros = memberHistories.stream() + .filter(h -> AdminSpaceCategory.TEAM == spaceCategoryMap.get(h.getSpaceId())) + .sorted(Comparator.comparing(AdminRetrospectHistory::getEventTime)) + .toList(); + + for (int i = 1; i < teamRetros.size(); i++) { + AdminRetrospectHistory curr = teamRetros.get(i); + if (curr.getEventTime().isBefore(startDate)) { + continue; + } + long gapDays = Duration.between(teamRetros.get(i - 1).getEventTime(), curr.getEventTime()).toDays(); + if (gapDays > 0) { + teamGaps.add(gapDays); + } + } + + List individualRetros = memberHistories.stream() + .filter(h -> AdminSpaceCategory.INDIVIDUAL == spaceCategoryMap.get(h.getSpaceId())) + .sorted(Comparator.comparing(AdminRetrospectHistory::getEventTime)) + .toList(); + + for (int i = 1; i < individualRetros.size(); i++) { + AdminRetrospectHistory curr = individualRetros.get(i); + if (curr.getEventTime().isBefore(startDate)) { + continue; + } + long gapDays = Duration.between(individualRetros.get(i - 1).getEventTime(), curr.getEventTime()).toDays(); + if (gapDays > 0) { + individualGaps.add(gapDays); + } + } + } + + double overallAvg = allGaps.stream().mapToLong(Long::longValue).average().orElse(0.0); + double teamAvg = teamGaps.stream().mapToLong(Long::longValue).average().orElse(0.0); + double individualAvg = individualGaps.stream().mapToLong(Long::longValue).average().orElse(0.0); + + return new RetrospectCreationCycleResponse(overallAvg, teamAvg, individualAvg, buildCycleDistribution(memberAvgGapMap)); + } + + private List buildCycleDistribution(Map memberAvgGapMap) { + if (memberAvgGapMap.isEmpty()) { + return buildEmptyCycleDistribution(); + } + + Map bucketCounts = new LinkedHashMap<>(); + for (RetrospectCycleRange range : RetrospectCycleRange.values()) { + bucketCounts.put(range, 0L); + } + + memberAvgGapMap.values().forEach(avgGap -> + bucketCounts.merge(RetrospectCycleRange.from(avgGap), 1L, Long::sum) + ); + + double total = memberAvgGapMap.size(); + return Arrays.stream(RetrospectCycleRange.values()) + .map(range -> new CycleDistributionEntry( + range.getLabel(), + Math.round(bucketCounts.get(range) / total * 1000.0) / 10.0 + )) + .collect(Collectors.toList()); + } + + private List buildEmptyCycleDistribution() { + return Arrays.stream(RetrospectCycleRange.values()) + .map(range -> new CycleDistributionEntry(range.getLabel(), 0.0)) + .collect(Collectors.toList()); + } + @Transactional(propagation = REQUIRES_NEW) @Async public void saveRetrospectAnswerHistory(AnswerRetrospectStartEvent event) { diff --git a/layer-admin/src/main/java/org/layer/admin/space/repository/AdminSpaceRepository.java b/layer-admin/src/main/java/org/layer/admin/space/repository/AdminSpaceRepository.java index e577c337..45093dfb 100644 --- a/layer-admin/src/main/java/org/layer/admin/space/repository/AdminSpaceRepository.java +++ b/layer-admin/src/main/java/org/layer/admin/space/repository/AdminSpaceRepository.java @@ -1,6 +1,7 @@ package org.layer.admin.space.repository; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import org.layer.admin.space.controller.dto.SpaceCountResponse; @@ -26,4 +27,6 @@ List findAllByCategory( Long countAllByEventTimeBetween( @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); + + List findAllBySpaceIdIn(Collection spaceIds); }