Skip to content

Commit d46d190

Browse files
v1.1.3-be 배포 (#91)
* init: 프로젝트 초기 설정 추가 * 이메일 기반 멀티 디바이스 인증 및 관리 기능 구현 (#3) * build: JPA 의존성 추가 * feat: BaseEntity 추가 * feat: NullValidator 추가 * feat: docker compose 파일 추가 * feat: Email 추가 * feat: DeviceIdentifier 추가 * test: BaseEntity equals 검증 테스트 추가 * feat: Member 추가 * feat: Device 추가 * style: 불필요한 개행 제거 * feat: DeviceIdentifier 추가 * feat: email에 toString 추가 * feat: Device 정적 팩터리 메서드 구조 수정 - isActive 추가 * feat: RecyclestudyApplication에 비동기 설정 추가 * feat: 이메일 전송 기능을 위한 의존성 추가 * feat: 전역 예외 처리용 ControllerAdvice 추가 * feat: EmailService 추가 * feat: IdentifierCreator 추가 * feat: 멤버 저장 기능 추가 - 멤버 저장 - 디바이스 id 발급 * feat: 멤버의 디바이스 전체 조회 기능 추가 * feat: 디바이스 이메일 인증 메일 발송 기능 추가 * feat: 이메일 인증 기능 추가 * test: MemberServiceTest 불필요한 검증 로직 제거 * feat: GlobalControllerAdvice 예외 처리 로직 추가 * test: MemberControllerTest 추가 * test: DeviceControllerTest 추가 * chore: DeviceControllerTest 패키지 위치 수정 * refactor: Member 이메일 유니크 제약 조건 설정 * refactor: Device 내 Member에 JoinColumn 추가 * refactor: Device identifier 유니크 제약 조건 설정 * refactor: DeviceController 패키지 위치 수정 및 파라미터명 변경 * feat: ActivationExpiredDateTime 추가 * refactor: EmailService 구조 개선 - 로그 추가 - 메서드 분리 * feat: Member 이메일 검증 기능 추가 * feat: Device 소유 검증 기능 추가 * feat: GlobalControllerAdvice 내 DeviceActivationExpiredException 처리 추가 * refactor: 이메일 인증 제한 시간 로직 추가 * jacoco 기반 테스트 커버리지 CI 구축 (#6) * feat: jacoco 기반 테스트 커버리지 CI 스크립트 추자 * test: 테스트 환경 DB H2 사용하도록 변경 * 디바이스 삭제 기능 추가 (#7) * feat: 디바이스 삭제 기능 추가 * chore: final 키워드 누락 수정 * fix: 대상 디바이스를 제거하도록 기능 수정 * 등록한 디바이스 조회 기능 응답 형식 수정 (#9) * fix: 등록한 디바이스 조회 기능 응답 형식 수정 * chore: 실행 sql 로그 출력 기능 활성화 * 복습할 URL 저장 기능 추가 (#10) * feat: 리뷰 대상 url 저장 기능 추가 * fix: ReviewService 트랜잭션 누락 수정 * swagger 기반 API 문서 작성 (#12) * feat: swagger 기반 api 문서 기능 추가 * refactor: 불필요한 로그 출력 제거 * refactor: 누락된 타입 명시 로직 추가 * CI 대상 branch 설정 추가 (#13) * 복습 대상 URL 이메일 전송 스케줄러 구현 (#19) * feat: Review 엔티티에 Member 연관 관계 추가 * feat: 주기적 복습 이메일 전송 기능 추가 - 공통 이메일 전송 기능 별도 분리 리팩터링 진행 * test: ReviewCycleServiceTest 추가 * refactor: ReviewSendOutput collect 내 불변 리스트를 사용하도록 수정 * refactor: html 태그에 lang 추가 * feat: 이메일 전송 이력 관리 기능 추가 * style: 코드 구조 정리 * refactor: ReviewEmailSender 타임존 설정 추가 * test: 메일 발송 실패 처리 검증 추가 * 로그 기능 추가 (#21) * feat: 로그 기능 추가 * chore: 신규 유저 이메일 등록 시작 로그 태그명 수정 * feat: 이메일 마스킹 기능 적용 * refactor: 복습 주기 저장 로그 포맷 수정 * refactor: 이메일 전송 기능 도메인 객체 파라미터로 변경 * test: MemberServiceTest#authenticateDevice 테스트 커버리지 보완 (#22) * flyway 기반 db 마이그레이션 의존성 추가 (#24) * feat: flyway 기반 db 마이그레이션 의존성 추가 - 환경별 jpa sql 출력 여부 분리 * fix: ReviewCycle#scheduledAt not null 누락 수정 * test: 테스트 환경에서 flyway 비활성화 * 로그 기능 추가 (#21) * feat: 로그 기능 추가 * chore: 신규 유저 이메일 등록 시작 로그 태그명 수정 * feat: 이메일 마스킹 기능 적용 * refactor: 복습 주기 저장 로그 포맷 수정 * refactor: 이메일 전송 기능 도메인 객체 파라미터로 변경 * test: MemberServiceTest#authenticateDevice 테스트 커버리지 보완 (#22) * 배포 스크립트 추가 (#31) * feat: 배포 스크립트 추가 * refactor: docker-compose.yaml env 설정 수정 * chore: 태그 검증 로그 메시지 수정 * feat: 모니터링을 위한 alloy 설정 추가 (#34) * 배포 최적화 적용 (#36) * 배포 스크립트 오류 수정 (#38) * fix: 배포 스크립트 오류 수정 * fix: trace 연결 문제 수정 * 모니터링 설정 불일치 수정 (#40) * feat: 모니터링 설정 추가 * fix: 로그 경로 불일치 수정 * 모니터링 연결 오류 수정 (#43) * fix: loki, tempo 연결 오류 수정 * refactor: 모니터링용 컨테이너 설정 코드 병합 * 디바이스 인증 방식 헤더 마이그레이션 (Phase 1) (#46) * feat: 디바이스 인증 기능 ArgumentResolver 추가 * refactor: 디바이스 id를 헤더를 활용하도록 마이그레이션 과정 추가 * 디바이스 인증 방식 헤더 마이그레이션 (Phase 3) (#49) * hotfix: prod - dev 불일치 수정 (#51) * 사용자 커스텀 복습 주기 관리 및 커스텀 주기 기반 리뷰 저장 기능 구현 (#53) * chore: 불필요한 메서드 제거 * feat: 복습 주기 엔티티 추가 * feat: 커스텀 복습 주기 조회 기능 추가 * feat: 커스텀 복습 주기 저장 기능 추가 * feat: 커스텀 복습 주기 수정/삭제 기능 추가 * feat: 기본 복습 주기 처리 로직 수정 * style: 코드 컨벤션 정리 * style: 코드 컨벤션 정리 * feat: 주기별 리뷰 저장 기능 주기 옵션 설정 로직 추가 * refactor: 주기별 리뷰 저장 기능 주기 하위 호환성 분기 처리 추가 * refactor: ReviewService#calculateScheduledAts 초 단위 절삭 적용 * style: 불필요한 개행 제거 * LocalDateTime 초 단위 절삭 적용 (#54) * 이메일 전송 실패 재시도 로직 구현 (#56) * refactor: 리뷰 이메일 전송 기능 비동기 처리 * feat: 메일 전송 실패 재시도 로직 추가 * refactor: 리뷰 메일 재전송 로직 보완 - PENDING 데이터 고려 - 테스트 코드 추가 보완 * test: 불필요한 테스트 코드 제거 * 멤버 알림 시간 설정 변경 기능 추가 (#59) * feat: 사용자 선호 알림 시간 설정 및 적용 기능 구현 - 리뷰 생성 시 1일 이상의 주기는 사용자 설정 시간에 맞춰 스케줄링되도록 로직 수정 * style: 테스트 코드 포맷팅 수정 * fix: 멤버 알림 시간 변경 로그의 이전 시간 표기 오류 수정 - 알림 시간 업데이트 후 로깅 시 변경 전 시간이 아닌 변경 후 시간이 기록되는 문제 수정 * 복습 주기 하위 호환성 로직 제거 (#61) * style: 코드 컨벤션 정리 - 불필요한 import 제거 - 코드 포맷팅 수정 * refactor: 복습 주기 하위 호환성 로직 제거 - 프런트엔드 마이그레이션 완료에 따라 null 입력 시 기본 주기로 변환하는 로직 제거 * test: 불필요한 테스트 시나리오 제거 * test: 불필요한 테스트 시나리오 제거 * feat: 멤버 알림 시간 조회 API 추가 (#63) - 멤버 조회 API에서 알림 시간 조회 기능 분리 * 로그 패턴에 스레드 정보 추가 (#65) * chore: 콘솔 로그 패턴에 스레드 정보 추가 * chore: 파일 로그 패턴에 스레드 정보 추가 * 복습 주기 조회 쿼리 성능 개선 (#66) * refactor: 복습 주기 조회 쿼리 성능 개선 - review_cycle 테이블 scheduled_at 컬럼 인덱스 추가 - findAllByScheduledAt 조회 시 fetch join 적용 * chore: 파일 개행 누락 수정 * 본인 소유 검증 누락으로 인한 멤버/디바이스 권한 문제 수정 (#69) * refactor: 디바이스 인증 기능 마이그레이션 - 멤버 디바이스 조회 기능 수정 - RequestParam email 제거 - 인증된 디바이스 식별자로 멤버 조회하도록 변경 - 디바이스 삭제 기능 수정 - 인증된 디바이스 식별자로 요청자 식별 - 삭제 대상 디바이스 소유권 검증 로직 추가 - Service Input DTO 이메일 필드 제거 - MemberFindInput, DeviceDeleteInput * refactor: 리뷰 저장 시 커스텀 주기 소유권 검증 로직 수정 - MemberServiceTest 예외 메시지 검증 구체화 - MemberControllerTest 불필요한 테스트 및 파라미터 제거 * test: 멤버 디바이스 조회 테스트 설명 수정 - 이메일 파라미터 누락 시 200 응답 반환에 맞춰 테스트 설명 수정 * feat: 리뷰 저장 시 커스텀 복습 주기 소유권 검증 로직 추가 (#71) * 코드 리뷰 actions 스크립트 추가 (#72) * Virtual Thread 적용을 통한 이메일 발송 처리량 개선 (#75) * chore: virtual thread 설정 추가 - application.yaml VT 설정 추가 - Dockerfile 런타임 JDK 25버전으로 상향 조정 * chore: Docker 이미지 태그 버전 고정 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * 이메일 발송 로직 notification_history outbox 패턴 전환 (#77) * refactor: 이메일 전송 로직 재구성 - notification_history를 outbox 패턴 전환: append-only INSERT → 단건 UPDATE 방식 - fail_count, last_attempted_at 컬럼 추가 - 스케줄러 조회를 정확 일치에서 범위 조회로 변경해 서버 다운 시 PENDING 누락 방지 - findAllRetryableCycles에 cutoffDateTime으로 단기 주기 재시도 제외 * refactor: 불필요한 트랜잭션 annotation 제거 * refactor: clearAutomatically 적용 * refactor: 벌크 update 결과 검증 로직 추가 * style: 단기 주기 판단 기준 주석화 * refactor: NotificationHistoryService#updateStatus 로직 최적화 * 다음 리뷰 전송 예정 정보 조회 API 구현 (#79) * feat: 다음 리뷰 주기 조회 기능 추가 * refactor: NotificationHistoryRepository#findAllByMemberAndStatus로 메서드명 수정 * Gmail SMTP에서 AWS SES SDK v2로 이메일 발송 인프라 교체 (#81) * chore: Gmail SMTP에서 AWS SES SDK v2로 이메일 발송 인프라 교체 * refactor: 이메일 전송 예외 처리 및 테스트 보완 * 이메일 재전송 포기 기준을 maxRetry 횟수 -> deadline 기반으로 전환 (#84) * refactor: 전송 실패 이메일 재전송 기능 정리 * test: 테스트 회귀 문제 수정 * notification_history.review_cycle_id unique constraint 추가 (#86) * chore: NotificationHistory review_cycle_id에 유니크 제약 조건 추가 * refactor: NotificationHistory 유니크 제약 조건명 명시 * style: NotificationHistory 테이블 설정 개행 정리 * deadline을 PENDING 생성 시점에 결정하도록 NotificationHistory 설계 개선 (#89) * refactor: NotificationHistory deadline 계산 로직 변경 - 새로운 복습 주기가 추가될 때, 계산하도록 수정 * README.md 설명 추가 (#90) * docs: README.md 설명 추가 - 프로젝트 소개 추가 - 서비스 기능 설명 추가 - 기술 스택 추가 - 아키텍처 다이어그램 추가 * docs: README 문서 이미지 및 뱃지 수정 - 이미지 너비 100% 설정 - 기술 스택 뱃지 줄바꿈 추가 * docs: README 뱃지 색상 수정 - Loki, Tempo 뱃지 색상 변경 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 969e83f commit d46d190

18 files changed

Lines changed: 218 additions & 93 deletions

README.md

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,75 @@
1-
# recycle-study
2-
주기적 복습 도우미
1+
<div align="center">
2+
<img alt="recycle-study-logo" src="https://github.com/user-attachments/assets/f0f510c6-6d8d-4d40-97af-31dc1616c664" />
3+
</div>
4+
5+
## 서비스 소개
6+
7+
> 웹에서 발견한 지식, 클릭 한 번으로 저장하고 이메일로 복습받으세요.
8+
9+
RecycleStudy는 에빙하우스 망각 곡선 기반의 복습 알림 서비스입니다.
10+
Chrome Extension으로 현재 보고 있는 웹 페이지를 1클릭으로 저장하면,
11+
설정한 복습 주기에 맞춰 이메일로 링크를 다시 전달해드립니다.
12+
13+
👉 [Chrome Web Store에서 설치하기](https://chromewebstore.google.com/detail/recycle-study/hlbbjgnpalplnmpncbjlfpedbgakbgjk)
14+
15+
---
16+
17+
## 기능 소개
18+
19+
### 1. 이메일 인증 기반 로그인
20+
21+
별도 회원가입 없이 이메일 입력과 인증 링크 클릭만으로 시작할 수 있어요.
22+
23+
| | |
24+
|---|---|
25+
| <img width="100%" alt="로그인" src="https://github.com/user-attachments/assets/40259474-84eb-4f34-b3e0-b35b6a67c244" /> | <img width="100%" alt="이메일 인증" src="https://github.com/user-attachments/assets/157c8066-6734-4573-aa6b-b16b72fe7e12" /> |
26+
27+
### 2. 커스텀 복습 주기 등록
28+
29+
기본 에빙하우스 주기 외에도 나만의 주기를 최대 5개까지 설정할 수 있어요. (최소 10분 ~ 최대 1년)
30+
31+
| | |
32+
|---|---|
33+
| <img width="100%" alt="복습 주기 추가" src="https://github.com/user-attachments/assets/ea80014c-207c-4083-a2d5-227e94860ca4" /> | <img width="100%" alt="복습 주기 조회" src="https://github.com/user-attachments/assets/1611b765-898b-4df7-a428-7421e2b3885f" /> |
34+
35+
### 3. URL 저장 & 주기별 알림
36+
37+
익스텐션 클릭 한 번으로 현재 페이지 URL을 저장하고, 선택한 주기에 맞춰 이메일 알림을 받아요.
38+
39+
| | |
40+
|---|---|
41+
| <img width="100%" alt="URL 저장" src="https://github.com/user-attachments/assets/508da208-ddcc-4f24-afb3-683cd9c4f647" /> | <img width="100%" alt="저장 완료 화면" src="https://github.com/user-attachments/assets/ebad2044-2263-4623-8a77-7a4cdb19a7e6" /> |
42+
43+
### 4. 알림 시간 설정
44+
45+
이메일을 받고 싶은 시간대를 직접 설정할 수 있어요.
46+
47+
| | |
48+
|---|---|
49+
| <img width="100%" alt="알림 시간 설정 조회" src="https://github.com/user-attachments/assets/f4d1b023-fda5-4f9a-aa5c-8116102fd756" /> | <img width="100%" alt="알림 시간 설정" src="https://github.com/user-attachments/assets/3d26cab8-e1f4-424a-938d-7f1f8f414eda" /> |
50+
51+
---
52+
53+
## 기술 스택
54+
55+
### Frontend (Chrome Extension)
56+
57+
<img src="https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black"><img src="https://img.shields.io/badge/Vite-646CFF?style=for-the-badge&logo=vite&logoColor=white"><img src="https://img.shields.io/badge/Chrome_Extension-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white">
58+
59+
### Backend
60+
61+
<img src="https://img.shields.io/badge/Java-007396?style=for-the-badge&logo=openjdk&logoColor=white"><img src="https://img.shields.io/badge/Spring_Boot-6DB33F?style=for-the-badge&logo=springboot&logoColor=white"><img src="https://img.shields.io/badge/JPA-6DB33F?style=for-the-badge&logo=hibernate&logoColor=white"><img src="https://img.shields.io/badge/MySQL-4479A1?style=for-the-badge&logo=mysql&logoColor=white">
62+
<br>
63+
<img src="https://img.shields.io/badge/Flyway-CC0200?style=for-the-badge&logo=flyway&logoColor=white"><img src="https://img.shields.io/badge/AWS_SES-232F3E?style=for-the-badge&logo=amazonaws&logoColor=white"><img src="https://img.shields.io/badge/Thymeleaf-005F0F?style=for-the-badge&logo=thymeleaf&logoColor=white">
64+
65+
### Infra
66+
67+
<img src="https://img.shields.io/badge/AWS_EC2-FF9900?style=for-the-badge&logo=amazonaws&logoColor=white"><img src="https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white"><img src="https://img.shields.io/badge/Nginx-009639?style=for-the-badge&logo=nginx&logoColor=white"><img src="https://img.shields.io/badge/GitHub_Actions-2088FF?style=for-the-badge&logo=githubactions&logoColor=white">
68+
<br>
69+
<img src="https://img.shields.io/badge/Prometheus-E6522C?style=for-the-badge&logo=prometheus&logoColor=white"><img src="https://img.shields.io/badge/Grafana-F46800?style=for-the-badge&logo=grafana&logoColor=white"><img src="https://img.shields.io/badge/Loki-F5A800?style=for-the-badge&logo=grafana&logoColor=white"><img src="https://img.shields.io/badge/Tempo-7B5EA7?style=for-the-badge&logo=grafana&logoColor=white">
70+
71+
---
72+
73+
## 아키텍처 설계
74+
75+
<img width="100%" alt="recycle-study-aws-diagram" src="https://github.com/user-attachments/assets/94d8847a-71c3-473e-8b42-0de74a56128b" />

src/main/java/com/recyclestudy/email/EmailRetryScheduler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ public class EmailRetryScheduler {
1010

1111
private final EmailRetryService emailRetryService;
1212

13-
@Scheduled(fixedDelay = 60_000)
13+
@Scheduled(fixedDelay = 300_000)
1414
public void runRetry() {
1515
emailRetryService.retryFailedEmails();
1616
}

src/main/java/com/recyclestudy/email/EmailRetryService.java

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,15 @@
2121
@RequiredArgsConstructor
2222
public class EmailRetryService {
2323

24-
// 단기 주기(1일 미만) 재시도 제외: review_cycle에 주기 컬럼이 없으므로 scheduledAt 경과 시간으로 단기/장기 여부를 역산
25-
private static final long SHORT_CYCLE_THRESHOLD_DAYS = 1;
26-
private static final int MAX_RETRY_COUNT = 3;
27-
2824
private final ReviewCycleRepository reviewCycleRepository;
2925
private final SingleReviewEmailSender singleReviewEmailSender;
3026
private final Clock clock;
3127

3228
@Transactional(readOnly = true)
3329
public void retryFailedEmails() {
34-
final LocalDateTime cutoffDateTime = LocalDateTime.now(clock).minusDays(SHORT_CYCLE_THRESHOLD_DAYS);
30+
final LocalDateTime now = LocalDateTime.now(clock);
3531
final List<ReviewCycle> failedCycles = reviewCycleRepository.findAllRetryableCycles(
36-
NotificationStatus.FAILED, MAX_RETRY_COUNT, cutoffDateTime);
32+
NotificationStatus.FAILED, now);
3733

3834
if (failedCycles.isEmpty()) {
3935
return;

src/main/java/com/recyclestudy/review/domain/NotificationHistory.java

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import jakarta.persistence.JoinColumn;
1111
import jakarta.persistence.ManyToOne;
1212
import jakarta.persistence.Table;
13+
import jakarta.persistence.UniqueConstraint;
1314
import java.time.LocalDateTime;
1415
import lombok.AccessLevel;
1516
import lombok.AllArgsConstructor;
@@ -18,7 +19,13 @@
1819
import lombok.experimental.FieldNameConstants;
1920

2021
@Entity
21-
@Table(name = "notification_history")
22+
@Table(
23+
name = "notification_history",
24+
uniqueConstraints = @UniqueConstraint(
25+
name = "uk_notification_history_review_cycle_id",
26+
columnNames = "review_cycle_id"
27+
)
28+
)
2229
@NoArgsConstructor(access = AccessLevel.PROTECTED)
2330
@AllArgsConstructor(access = AccessLevel.PRIVATE)
2431
@FieldNameConstants(level = AccessLevel.PRIVATE)
@@ -39,21 +46,27 @@ public class NotificationHistory extends BaseEntity {
3946
@Column(name = "last_attempted_at")
4047
private LocalDateTime lastAttemptedAt;
4148

49+
@Column(name = "deadline", nullable = false)
50+
private LocalDateTime deadline;
51+
4252
public static NotificationHistory withoutId(
4353
final ReviewCycle reviewCycle,
44-
final NotificationStatus status
54+
final NotificationStatus status,
55+
final LocalDateTime deadline
4556
) {
46-
validateNotNull(reviewCycle, status);
47-
return new NotificationHistory(reviewCycle, status, 0, null);
57+
validateNotNull(reviewCycle, status, deadline);
58+
return new NotificationHistory(reviewCycle, status, 0, null, deadline);
4859
}
4960

5061
private static void validateNotNull(
5162
final ReviewCycle reviewCycle,
52-
final NotificationStatus status
63+
final NotificationStatus status,
64+
final LocalDateTime deadline
5365
) {
5466
NullValidator.builder()
5567
.add(Fields.reviewCycle, reviewCycle)
5668
.add(Fields.status, status)
69+
.add(Fields.deadline, deadline)
5770
.validate();
5871
}
5972
}

src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ int updateStatus(
2929
SET nh.status = :status, nh.failCount = nh.failCount + 1, nh.lastAttemptedAt = :now
3030
WHERE nh.reviewCycle.id IN :reviewCycleIds
3131
""")
32-
int updateStatusWithIncrementFailCount(
32+
int updateStatusAndIncrementFailCount(
3333
@Param("reviewCycleIds") List<Long> reviewCycleIds,
3434
@Param("status") NotificationStatus status,
3535
@Param("now") LocalDateTime now

src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.springframework.data.jpa.repository.Query;
99
import org.springframework.data.repository.query.Param;
1010

11+
1112
public interface ReviewCycleRepository extends JpaRepository<ReviewCycle, Long> {
1213

1314
@Query("""
@@ -29,12 +30,11 @@ List<ReviewCycle> findAllByScheduledAt(
2930
JOIN FETCH r.member
3031
JOIN NotificationHistory nh ON rc.id = nh.reviewCycle.id
3132
WHERE nh.status = :status
32-
AND nh.failCount < :maxRetryCount
33-
AND rc.scheduledAt <= :cutoffDateTime
33+
AND nh.deadline > :now
3434
""")
3535
List<ReviewCycle> findAllRetryableCycles(
3636
@Param("status") NotificationStatus status,
37-
@Param("maxRetryCount") int maxRetryCount,
38-
@Param("cutoffDateTime") LocalDateTime cutoffDateTime
37+
@Param("now") LocalDateTime now
3938
);
39+
4040
}

src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,15 @@ public void updateStatus(final List<Long> reviewCycleIds, final NotificationStat
2424
return;
2525
}
2626
final LocalDateTime now = LocalDateTime.now(clock);
27-
int updated;
27+
final int updated;
2828
if (status == NotificationStatus.FAILED) {
29-
updated = notificationHistoryRepository.updateStatusWithIncrementFailCount(reviewCycleIds, status, now);
29+
updated = notificationHistoryRepository.updateStatusAndIncrementFailCount(reviewCycleIds, status, now);
3030
} else {
3131
updated = notificationHistoryRepository.updateStatus(reviewCycleIds, status, now);
3232
}
3333
if (updated != reviewCycleIds.size()) {
3434
log.warn("[NOTIFY_HIST_MISMATCH] 기대={}, 실제={}", reviewCycleIds.size(), updated);
3535
}
36-
3736
log.info("[NOTIFY_HIST_UPDATED] 알림 이력 상태 변경: status={}, count={}", status, updated);
3837
}
3938
}

src/main/java/com/recyclestudy/review/service/ReviewService.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.time.LocalDateTime;
2525
import java.time.temporal.ChronoUnit;
2626
import java.util.List;
27+
import java.util.stream.IntStream;
2728
import lombok.RequiredArgsConstructor;
2829
import lombok.extern.slf4j.Slf4j;
2930
import org.springframework.stereotype.Service;
@@ -34,6 +35,8 @@
3435
@Slf4j
3536
public class ReviewService {
3637

38+
private static final long LAST_CYCLE_DEADLINE_HOURS = 24;
39+
3740
private final ReviewRepository reviewRepository;
3841
private final ReviewCycleRepository reviewCycleRepository;
3942
private final MemberRepository memberRepository;
@@ -96,8 +99,14 @@ private LocalDateTime calculateScheduledAt(
9699
}
97100

98101
private void savePendingNotificationHistory(final List<ReviewCycle> savedReviewCycles) {
99-
final List<NotificationHistory> notificationHistories = savedReviewCycles.stream()
100-
.map(reviewCycle -> NotificationHistory.withoutId(reviewCycle, NotificationStatus.PENDING))
102+
final List<NotificationHistory> notificationHistories = IntStream.range(0, savedReviewCycles.size())
103+
.mapToObj(i -> {
104+
final ReviewCycle reviewCycle = savedReviewCycles.get(i);
105+
final LocalDateTime deadline = (i < savedReviewCycles.size() - 1)
106+
? savedReviewCycles.get(i + 1).getScheduledAt()
107+
: reviewCycle.getScheduledAt().plusHours(LAST_CYCLE_DEADLINE_HOURS);
108+
return NotificationHistory.withoutId(reviewCycle, NotificationStatus.PENDING, deadline);
109+
})
101110
.toList();
102111
final List<NotificationHistory> savedNotificationHistories
103112
= notificationHistoryRepository.saveAll(notificationHistories);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE notification_history
2+
ADD COLUMN deadline DATETIME NULL;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE notification_history
2+
ADD CONSTRAINT uk_notification_history_review_cycle_id UNIQUE (review_cycle_id);

0 commit comments

Comments
 (0)