Skip to content

Commit 335ca69

Browse files
authored
Merge pull request #137 from DevKor-github/develop
#136까지 반영해 배포
2 parents 7e89a12 + 6a93ea9 commit 335ca69

15 files changed

Lines changed: 1249 additions & 10 deletions

File tree

src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/NotificationEventListener.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@
2323
* EventListener:
2424
* T2: PENDING → SENDING & fcmSent = true (낙관적 설정, TransactionHelper)
2525
* FCM 발송 (트랜잭션 밖)
26-
* T3: SENDING → SENT (fcmSent = true 유지, TransactionHelper)
26+
* └─ T3: Invalid 토큰 정리 (별도 트랜잭션 - REQUIRES_NEW)
27+
* T4: SENDING → SENT (fcmSent = true 유지, TransactionHelper)
2728
* 또는 SENDING → FAILED & fcmSent = false (복구, TransactionHelper)
2829
*
2930
* 중복 발송 방지:
3031
* - fcmSent를 FCM 발송 전에 미리 true로 설정
31-
* - FCM 성공 후 T3 실패 시 → fcmSent = true로 보호
32+
* - FCM 성공 후 T4 실패 시 → fcmSent = true로 보호
3233
* - RecoveryScheduler가 fcmSent = true 확인 → SENT로만 변경
3334
*/
3435
@Component
@@ -58,10 +59,10 @@ public void handleNotificationCreated(NotificationCreatedEvent event) {
5859
Notification notification = notificationRepository.findByIdWithUser(notificationId)
5960
.orElseThrow(() -> new IllegalArgumentException("Notification not found: " + notificationId));
6061

61-
// FCM 발송 (트랜잭션 밖)
62+
// FCM 발송 (트랜잭션 밖, 내부에서 T3: Invalid 토큰 정리)
6263
boolean fcmSuccess = fcmSender.sendInternal(notification);
6364

64-
// T3: FCM 발송 결과에 따른 최종 상태 업데이트 (별도 트랜잭션)
65+
// T4: FCM 발송 결과에 따른 최종 상태 업데이트 (별도 트랜잭션)
6566
boolean updated = transactionHelper.updateFinalStatus(notificationId, fcmSuccess);
6667

6768
// UPDATE 실패 시 처리

src/main/java/com/devkor/ifive/nadab/domain/notification/infra/InvalidTokenCleaner.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
import lombok.RequiredArgsConstructor;
99
import lombok.extern.slf4j.Slf4j;
1010
import org.springframework.stereotype.Component;
11+
import org.springframework.transaction.annotation.Propagation;
12+
import org.springframework.transaction.annotation.Transactional;
1113

1214
import java.util.ArrayList;
1315
import java.util.List;
1416

1517
/**
1618
* Invalid FCM 토큰 정리 Component
17-
* - 호출자의 트랜잭션에 참여
19+
* - 독립적인 트랜잭션으로 실행 (FCM 발송과 분리)
1820
*/
1921
@Component
2022
@RequiredArgsConstructor
@@ -26,8 +28,8 @@ public class InvalidTokenCleaner {
2628
/**
2729
* Invalid Token 자동 정리
2830
* - DB에서 Invalid Token 삭제
29-
* - 호출자의 트랜잭션에 참여 (FCM 발송 처리의 일부)
3031
*/
32+
@Transactional(propagation = Propagation.REQUIRES_NEW)
3133
public int cleanupInvalidTokens(List<String> fcmTokens, BatchResponse response) {
3234
List<String> invalidTokens = collectInvalidTokens(fcmTokens, response);
3335

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.devkor.ifive.nadab.domain.stats.application;
2+
3+
import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto;
4+
import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel;
5+
import com.devkor.ifive.nadab.domain.stats.core.repository.MonthlyStatsRepository;
6+
import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.stereotype.Service;
9+
10+
import java.time.LocalDate;
11+
import java.time.OffsetDateTime;
12+
import java.time.YearMonth;
13+
import java.time.ZoneId;
14+
import java.time.format.DateTimeFormatter;
15+
import java.util.ArrayList;
16+
import java.util.HashMap;
17+
import java.util.List;
18+
import java.util.Map;
19+
20+
@Service
21+
@RequiredArgsConstructor
22+
public class MonthlyStatsService {
23+
24+
private final MonthlyStatsRepository repo;
25+
26+
private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul");
27+
private static final DateTimeFormatter FMT =
28+
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
29+
private static final DateTimeFormatter MONTH_LABEL_FMT =
30+
DateTimeFormatter.ofPattern("yyyy-MM");
31+
private static final int MONTHLY_CHART_SIZE = 5;
32+
33+
public MonthlyStatsViewModel getMonthlyStatsLast5Months() {
34+
LocalDate today = TodayDateTimeProvider.getTodayDate();
35+
YearMonth currentMonth = YearMonth.from(today);
36+
YearMonth startMonth = currentMonth.minusMonths(MONTHLY_CHART_SIZE - 1L);
37+
38+
LocalDate startDate = startMonth.atDay(1);
39+
LocalDate endDateInclusive = currentMonth.atEndOfMonth();
40+
41+
List<YearMonth> months = new ArrayList<>();
42+
for (int i = 0; i < MONTHLY_CHART_SIZE; i++) {
43+
months.add(startMonth.plusMonths(i));
44+
}
45+
46+
List<String> labels = months.stream()
47+
.map(month -> MONTH_LABEL_FMT.format(month.atDay(1)))
48+
.toList();
49+
50+
Map<YearMonth, Long> signupMap = aggregateByMonth(
51+
repo.findSignupCountsByDateBetween(startDate, endDateInclusive)
52+
);
53+
Map<YearMonth, Long> assignedMap = aggregateByMonth(
54+
repo.findAssignedQuestionCountsByDateBetween(startDate, endDateInclusive)
55+
);
56+
Map<YearMonth, Long> completedMap = aggregateByMonth(
57+
repo.findCompletedMonthlyReportCountsByDateBetween(startDate, endDateInclusive)
58+
);
59+
60+
List<Long> signupCounts = months.stream().map(m -> signupMap.getOrDefault(m, 0L)).toList();
61+
List<Long> assignedCounts = months.stream().map(m -> assignedMap.getOrDefault(m, 0L)).toList();
62+
List<Long> completedCounts = months.stream().map(m -> completedMap.getOrDefault(m, 0L)).toList();
63+
64+
long inProgressNow = repo.countInProgressMonthlyReportsNow();
65+
66+
return new MonthlyStatsViewModel(
67+
labels,
68+
signupCounts,
69+
assignedCounts,
70+
completedCounts,
71+
inProgressNow,
72+
OffsetDateTime.now(SEOUL).format(FMT)
73+
);
74+
}
75+
76+
private Map<YearMonth, Long> aggregateByMonth(List<DateCountDto> dailyCounts) {
77+
Map<YearMonth, Long> map = new HashMap<>();
78+
for (DateCountDto dto : dailyCounts) {
79+
YearMonth month = YearMonth.from(dto.date());
80+
map.merge(month, dto.count(), Long::sum);
81+
}
82+
return map;
83+
}
84+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.devkor.ifive.nadab.domain.stats.application;
2+
3+
import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto;
4+
import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel;
5+
import com.devkor.ifive.nadab.domain.stats.core.repository.WeeklyStatsRepository;
6+
import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.stereotype.Service;
9+
10+
import java.time.DayOfWeek;
11+
import java.time.LocalDate;
12+
import java.time.OffsetDateTime;
13+
import java.time.ZoneId;
14+
import java.time.format.DateTimeFormatter;
15+
import java.time.temporal.TemporalAdjusters;
16+
import java.util.ArrayList;
17+
import java.util.HashMap;
18+
import java.util.List;
19+
import java.util.Map;
20+
21+
@Service
22+
@RequiredArgsConstructor
23+
public class WeeklyStatsService {
24+
25+
private final WeeklyStatsRepository repo;
26+
27+
private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul");
28+
private static final DateTimeFormatter FMT =
29+
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
30+
private static final DateTimeFormatter WEEK_LABEL_FMT =
31+
DateTimeFormatter.ofPattern("MM-dd");
32+
33+
public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() {
34+
LocalDate today = TodayDateTimeProvider.getTodayDate();
35+
LocalDate currentWeekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
36+
LocalDate startWeekStart = currentWeekStart.minusWeeks(6);
37+
LocalDate endDateInclusive = currentWeekStart.plusDays(6);
38+
39+
List<LocalDate> weekStarts = new ArrayList<>();
40+
for (int i = 0; i < 7; i++) {
41+
weekStarts.add(startWeekStart.plusWeeks(i));
42+
}
43+
44+
List<String> labels = weekStarts.stream()
45+
.map(weekStart -> WEEK_LABEL_FMT.format(weekStart) + " ~ " + WEEK_LABEL_FMT.format(weekStart.plusDays(6)))
46+
.toList();
47+
48+
Map<LocalDate, Long> signupMap = aggregateByWeekStart(
49+
repo.findSignupCountsByDateBetween(startWeekStart, endDateInclusive)
50+
);
51+
Map<LocalDate, Long> assignedMap = aggregateByWeekStart(
52+
repo.findAssignedQuestionCountsByDateBetween(startWeekStart, endDateInclusive)
53+
);
54+
55+
Map<LocalDate, Long> completedMap = aggregateByWeekStart(
56+
repo.findCompletedWeeklyReportCountsByDateBetween(startWeekStart, endDateInclusive)
57+
);
58+
59+
List<Long> signupCounts = weekStarts.stream().map(d -> signupMap.getOrDefault(d, 0L)).toList();
60+
List<Long> assignedCounts = weekStarts.stream().map(d -> assignedMap.getOrDefault(d, 0L)).toList();
61+
List<Long> completedCounts = weekStarts.stream().map(d -> completedMap.getOrDefault(d, 0L)).toList();
62+
63+
long inProgressNow = repo.countInProgressWeeklyReportsNow();
64+
65+
return new WeeklyStatsViewModel(
66+
labels,
67+
signupCounts,
68+
assignedCounts,
69+
completedCounts,
70+
inProgressNow,
71+
OffsetDateTime.now(SEOUL).format(FMT)
72+
);
73+
}
74+
75+
private Map<LocalDate, Long> aggregateByWeekStart(List<DateCountDto> dailyCounts) {
76+
Map<LocalDate, Long> map = new HashMap<>();
77+
for (DateCountDto dto : dailyCounts) {
78+
LocalDate weekStart = dto.date().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
79+
map.merge(weekStart, dto.count(), Long::sum);
80+
}
81+
return map;
82+
}
83+
}

src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package com.devkor.ifive.nadab.domain.stats.controller;
22

33
import com.devkor.ifive.nadab.domain.stats.application.DailyStatsService;
4+
import com.devkor.ifive.nadab.domain.stats.application.MonthlyStatsService;
45
import com.devkor.ifive.nadab.domain.stats.application.TotalStatsService;
6+
import com.devkor.ifive.nadab.domain.stats.application.WeeklyStatsService;
57
import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel;
8+
import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel;
69
import com.devkor.ifive.nadab.domain.stats.core.dto.total.TotalStatsViewModel;
10+
import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel;
711
import lombok.RequiredArgsConstructor;
812
import org.springframework.stereotype.Controller;
913
import org.springframework.ui.Model;
@@ -14,6 +18,8 @@
1418
public class StatsController {
1519

1620
private final DailyStatsService dailyStatsService;
21+
private final WeeklyStatsService weeklyStatsService;
22+
private final MonthlyStatsService monthlyStatsService;
1723
private final TotalStatsService totalStatsService;
1824

1925

@@ -25,6 +31,22 @@ public String dailyStats(Model model) {
2531
return "stats/daily";
2632
}
2733

34+
@GetMapping("/stats/weekly")
35+
public String weeklyStats(Model model) {
36+
WeeklyStatsViewModel vm = weeklyStatsService.getWeeklyStatsLast7Weeks();
37+
model.addAttribute("vm", vm);
38+
model.addAttribute("activeTab", "weekly");
39+
return "stats/weekly";
40+
}
41+
42+
@GetMapping("/stats/monthly")
43+
public String monthlyStats(Model model) {
44+
MonthlyStatsViewModel vm = monthlyStatsService.getMonthlyStatsLast5Months();
45+
model.addAttribute("vm", vm);
46+
model.addAttribute("activeTab", "monthly");
47+
return "stats/monthly";
48+
}
49+
2850
@GetMapping("/stats/total")
2951
public String totalStats(Model model) {
3052
TotalStatsViewModel vm = totalStatsService.getTotalStats();
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.devkor.ifive.nadab.domain.stats.core.dto.monthly;
2+
3+
import java.util.List;
4+
5+
public record MonthlyStatsViewModel(
6+
List<String> labels,
7+
List<Long> signupCounts,
8+
List<Long> assignedQuestionCounts,
9+
List<Long> completedMonthlyReportCounts,
10+
long inProgressMonthlyReportCount,
11+
String refreshedAt
12+
) {}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.devkor.ifive.nadab.domain.stats.core.dto.weekly;
2+
3+
import java.util.List;
4+
5+
public record WeeklyStatsViewModel(
6+
List<String> labels,
7+
List<Long> signupCounts,
8+
List<Long> assignedQuestionCounts,
9+
List<Long> completedWeeklyReportCounts,
10+
long inProgressWeeklyReportCount,
11+
String refreshedAt
12+
) {}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package com.devkor.ifive.nadab.domain.stats.core.repository;
2+
3+
import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto;
4+
import jakarta.persistence.EntityManager;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.stereotype.Repository;
7+
8+
import java.sql.Date;
9+
import java.time.LocalDate;
10+
import java.time.OffsetDateTime;
11+
import java.time.ZoneId;
12+
import java.util.List;
13+
14+
@Repository
15+
@RequiredArgsConstructor
16+
public class MonthlyStatsRepository {
17+
18+
private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul");
19+
private final EntityManager em;
20+
21+
public List<DateCountDto> findSignupCountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) {
22+
OffsetDateTime start = startDate.atStartOfDay(SEOUL).toOffsetDateTime();
23+
OffsetDateTime endExclusive = endDateInclusive.plusDays(1).atStartOfDay(SEOUL).toOffsetDateTime();
24+
25+
List<Object[]> rows = em.createQuery("""
26+
select function('date', u.registeredAt), count(u.id)
27+
from User u
28+
where u.deletedAt is null
29+
and u.signupStatus = com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType.COMPLETED
30+
and u.registeredAt is not null
31+
and u.registeredAt >= :start
32+
and u.registeredAt < :endExclusive
33+
group by function('date', u.registeredAt)
34+
order by function('date', u.registeredAt)
35+
""", Object[].class)
36+
.setParameter("start", start)
37+
.setParameter("endExclusive", endExclusive)
38+
.getResultList();
39+
40+
return rows.stream()
41+
.map(MonthlyStatsRepository::toDateCountDto)
42+
.toList();
43+
}
44+
45+
public List<DateCountDto> findAssignedQuestionCountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) {
46+
return em.createQuery("""
47+
select new com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto(udq.date, count(udq.id))
48+
from UserDailyQuestion udq
49+
where udq.date between :startDate and :endDate
50+
group by udq.date
51+
order by udq.date
52+
""", DateCountDto.class)
53+
.setParameter("startDate", startDate)
54+
.setParameter("endDate", endDateInclusive)
55+
.getResultList();
56+
}
57+
58+
public List<DateCountDto> findCompletedMonthlyReportCountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) {
59+
return em.createQuery("""
60+
select new com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto(mr.date, count(mr.id))
61+
from MonthlyReport mr
62+
where mr.date between :startDate and :endDate
63+
and mr.status = com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus.COMPLETED
64+
group by mr.date
65+
order by mr.date
66+
""", DateCountDto.class)
67+
.setParameter("startDate", startDate)
68+
.setParameter("endDate", endDateInclusive)
69+
.getResultList();
70+
}
71+
72+
public long countInProgressMonthlyReportsNow() {
73+
return em.createQuery("""
74+
select count(mr.id)
75+
from MonthlyReport mr
76+
where mr.status = com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus.IN_PROGRESS
77+
""", Long.class)
78+
.getSingleResult();
79+
}
80+
81+
private static DateCountDto toDateCountDto(Object[] row) {
82+
LocalDate date = ((Date) row[0]).toLocalDate();
83+
long count = (Long) row[1];
84+
return new DateCountDto(date, count);
85+
}
86+
}

0 commit comments

Comments
 (0)