From 8e49c63c72bde01d3b1d3b0928bdc776971cbfc1 Mon Sep 17 00:00:00 2001 From: flinter <56645802+jhan0121@users.noreply.github.com> Date: Tue, 10 Mar 2026 01:37:42 +0900 Subject: [PATCH] =?UTF-8?q?v1.1.4-be=20=EB=B0=B0=ED=8F=AC=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 뱃지 색상 변경 * 서버 및 DB 시간 처리 정책 UTC로 통일 (#93) * fix: 서버 타임존 처리 UTC로 통일 * fix: 서버 타임존 처리 UTC로 통일 * refactor: UTC 변환로직을 도메인 레이어로 이동 * refactor: Jackson 설정 application.yaml 설정으로 변경 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Dockerfile | 7 ++---- .../common/config/TimeConfig.java | 2 +- .../recyclestudy/email/ReviewEmailSender.java | 2 +- .../response/MemberFindResponse.java | 4 ++-- .../service/output/MemberFindOutput.java | 8 ++++--- .../review/controller/ReviewController.java | 2 +- .../response/NextReviewResponse.java | 4 ++-- .../response/ReviewSaveResponse.java | 10 ++++---- .../service/output/NextReviewOutput.java | 6 +++-- .../service/output/ReviewSaveOutput.java | 7 ++++-- src/main/resources/application.yaml | 10 +++++++- .../V20260310_1__convert_time_data_to_utc.sql | 23 +++++++++++++++++++ .../controller/MemberControllerTest.java | 19 +++++++-------- .../controller/ReviewControllerTest.java | 9 ++++---- .../service/ReviewCycleServiceTest.java | 6 +++-- .../review/service/ReviewServiceTest.java | 2 +- 16 files changed, 80 insertions(+), 41 deletions(-) create mode 100644 src/main/resources/db/migration/V20260310_1__convert_time_data_to_utc.sql diff --git a/Dockerfile b/Dockerfile index feede4b..e97eaad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,10 +2,7 @@ FROM amazoncorretto:25-alpine3.21 WORKDIR /app -RUN apk add --no-cache curl tzdata && \ - cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ - echo "Asia/Seoul" > /etc/timezone && \ - apk del tzdata +RUN apk add --no-cache curl RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -D appuser RUN mkdir -p /app/log && chown -R appuser:appgroup /app @@ -16,4 +13,4 @@ USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] +ENTRYPOINT ["java", "-Duser.timezone=UTC", "-jar", "app.jar"] diff --git a/src/main/java/com/recyclestudy/common/config/TimeConfig.java b/src/main/java/com/recyclestudy/common/config/TimeConfig.java index a7b112f..2a73c70 100644 --- a/src/main/java/com/recyclestudy/common/config/TimeConfig.java +++ b/src/main/java/com/recyclestudy/common/config/TimeConfig.java @@ -9,6 +9,6 @@ public class TimeConfig { @Bean public Clock clock() { - return Clock.systemDefaultZone(); + return Clock.systemUTC(); } } diff --git a/src/main/java/com/recyclestudy/email/ReviewEmailSender.java b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java index f332ead..a85048b 100644 --- a/src/main/java/com/recyclestudy/email/ReviewEmailSender.java +++ b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java @@ -22,7 +22,7 @@ public class ReviewEmailSender { private final ReviewCycleService reviewCycleService; private final Clock clock; - @Scheduled(cron = "${schedule.review-mail.cron}", zone = "Asia/Seoul") + @Scheduled(cron = "${schedule.review-mail.cron}", zone = "UTC") public void sendReviewMail() { final LocalDateTime targetDateTime = LocalDateTime.now(clock).truncatedTo(ChronoUnit.MINUTES); diff --git a/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java b/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java index 4845dce..ec3d1d5 100644 --- a/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java +++ b/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java @@ -1,7 +1,7 @@ package com.recyclestudy.member.controller.response; import com.recyclestudy.member.service.output.MemberFindOutput; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; public record MemberFindResponse(String email, List devices) { @@ -14,6 +14,6 @@ public static MemberFindResponse from(final MemberFindOutput output) { return new MemberFindResponse(output.email().getValue(), memberFindElements); } - private record MemberFindElement(String identifier, LocalDateTime createdAt) { + private record MemberFindElement(String identifier, Instant createdAt) { } } diff --git a/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java b/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java index 391b247..fb0d4a7 100644 --- a/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java +++ b/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java @@ -3,7 +3,8 @@ import com.recyclestudy.member.domain.Device; import com.recyclestudy.member.domain.DeviceIdentifier; import com.recyclestudy.member.domain.Email; -import java.time.LocalDateTime; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.List; public record MemberFindOutput(Email email, List elements) { @@ -13,11 +14,12 @@ public static MemberFindOutput of( final List devices ) { final List memberFindElements = devices.stream() - .map(device -> new MemberFindElement(device.getIdentifier(), device.getCreatedAt())) + .map(device -> new MemberFindElement(device.getIdentifier(), + device.getCreatedAt() != null ? device.getCreatedAt().toInstant(ZoneOffset.UTC) : null)) .toList(); return new MemberFindOutput(email, memberFindElements); } - public record MemberFindElement(DeviceIdentifier identifier, LocalDateTime createdAt) { + public record MemberFindElement(DeviceIdentifier identifier, Instant createdAt) { } } diff --git a/src/main/java/com/recyclestudy/review/controller/ReviewController.java b/src/main/java/com/recyclestudy/review/controller/ReviewController.java index c552570..9233023 100644 --- a/src/main/java/com/recyclestudy/review/controller/ReviewController.java +++ b/src/main/java/com/recyclestudy/review/controller/ReviewController.java @@ -35,7 +35,7 @@ public ResponseEntity saveReview( ) { final ReviewSaveInput input = ReviewSaveInput.of(identifier, request.targetUrl(), request.cycle()); final ReviewSaveOutput output = reviewService.saveReview(input); - ReviewSaveResponse response = ReviewSaveResponse.of(output.url(), output.scheduledAts()); + ReviewSaveResponse response = ReviewSaveResponse.from(output); return ResponseEntity.status(HttpStatus.CREATED).body(response); } diff --git a/src/main/java/com/recyclestudy/review/controller/response/NextReviewResponse.java b/src/main/java/com/recyclestudy/review/controller/response/NextReviewResponse.java index 5aeb6ae..b6b15d4 100644 --- a/src/main/java/com/recyclestudy/review/controller/response/NextReviewResponse.java +++ b/src/main/java/com/recyclestudy/review/controller/response/NextReviewResponse.java @@ -1,9 +1,9 @@ package com.recyclestudy.review.controller.response; import com.recyclestudy.review.service.output.NextReviewOutput; -import java.time.LocalDateTime; +import java.time.Instant; -public record NextReviewResponse(LocalDateTime scheduledAt, int count) { +public record NextReviewResponse(Instant scheduledAt, int count) { public static NextReviewResponse of(final NextReviewOutput output) { return new NextReviewResponse(output.scheduledAt(), output.count()); diff --git a/src/main/java/com/recyclestudy/review/controller/response/ReviewSaveResponse.java b/src/main/java/com/recyclestudy/review/controller/response/ReviewSaveResponse.java index f46f791..88c19dd 100644 --- a/src/main/java/com/recyclestudy/review/controller/response/ReviewSaveResponse.java +++ b/src/main/java/com/recyclestudy/review/controller/response/ReviewSaveResponse.java @@ -1,12 +1,12 @@ package com.recyclestudy.review.controller.response; -import com.recyclestudy.review.domain.ReviewURL; -import java.time.LocalDateTime; +import com.recyclestudy.review.service.output.ReviewSaveOutput; +import java.time.Instant; import java.util.List; -public record ReviewSaveResponse(String url, List scheduledAts) { +public record ReviewSaveResponse(String url, List scheduledAts) { - public static ReviewSaveResponse of(ReviewURL url, List scheduledAts) { - return new ReviewSaveResponse(url.getValue(), scheduledAts); + public static ReviewSaveResponse from(final ReviewSaveOutput output) { + return new ReviewSaveResponse(output.url().getValue(), output.scheduledAts()); } } diff --git a/src/main/java/com/recyclestudy/review/service/output/NextReviewOutput.java b/src/main/java/com/recyclestudy/review/service/output/NextReviewOutput.java index f446c8b..fca7405 100644 --- a/src/main/java/com/recyclestudy/review/service/output/NextReviewOutput.java +++ b/src/main/java/com/recyclestudy/review/service/output/NextReviewOutput.java @@ -1,14 +1,16 @@ package com.recyclestudy.review.service.output; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneOffset; -public record NextReviewOutput(LocalDateTime scheduledAt, int count) { +public record NextReviewOutput(Instant scheduledAt, int count) { public static NextReviewOutput empty() { return new NextReviewOutput(null, 0); } public static NextReviewOutput of(final LocalDateTime scheduledAt, final int count) { - return new NextReviewOutput(scheduledAt, count); + return new NextReviewOutput(scheduledAt.toInstant(ZoneOffset.UTC), count); } } diff --git a/src/main/java/com/recyclestudy/review/service/output/ReviewSaveOutput.java b/src/main/java/com/recyclestudy/review/service/output/ReviewSaveOutput.java index 2beaadc..4000c0f 100644 --- a/src/main/java/com/recyclestudy/review/service/output/ReviewSaveOutput.java +++ b/src/main/java/com/recyclestudy/review/service/output/ReviewSaveOutput.java @@ -1,12 +1,15 @@ package com.recyclestudy.review.service.output; import com.recyclestudy.review.domain.ReviewURL; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; -public record ReviewSaveOutput(ReviewURL url, List scheduledAts) { +public record ReviewSaveOutput(ReviewURL url, List scheduledAts) { public static ReviewSaveOutput of(ReviewURL url, List scheduledAts) { - return new ReviewSaveOutput(url, scheduledAts); + return new ReviewSaveOutput(url, + scheduledAts.stream().map(scheduledAt -> scheduledAt.toInstant(ZoneOffset.UTC)).toList()); } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index fc22016..0362688 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -21,11 +21,19 @@ spring: flyway: enabled: true + jackson: + serialization: + write-dates-as-timestamps: false + time-zone: UTC + jpa: hibernate: ddl-auto: validate - open-in-view: false + properties: + hibernate: + jdbc: + time_zone: UTC server: diff --git a/src/main/resources/db/migration/V20260310_1__convert_time_data_to_utc.sql b/src/main/resources/db/migration/V20260310_1__convert_time_data_to_utc.sql new file mode 100644 index 0000000..71a18b6 --- /dev/null +++ b/src/main/resources/db/migration/V20260310_1__convert_time_data_to_utc.sql @@ -0,0 +1,23 @@ +-- review_cycle.scheduled_at 보정: PENDING 상태 + 미래 주기만 (-9h) +UPDATE review_cycle rc + INNER JOIN notification_history nh ON nh.review_cycle_id = rc.id +SET rc.scheduled_at = DATE_SUB(rc.scheduled_at, INTERVAL 9 HOUR) +WHERE nh.status = 'PENDING' + AND rc.scheduled_at > NOW(); + +-- notification_history.deadline 보정: PENDING 상태만 (-9h) +UPDATE notification_history nh +SET nh.deadline = DATE_SUB(nh.deadline, INTERVAL 9 HOUR) +WHERE nh.status = 'PENDING'; + +-- member.notification_time 보정: Seoul 기준 -> UTC (-9h) +-- LocalTime은 날짜가 없으므로 자정을 넘는 케이스 처리 필요 +-- 예: 01:00 Seoul -> UTC 전날 16:00 → LocalTime으로 16:00 저장 +UPDATE member +SET notification_time = CASE + WHEN notification_time >= '09:00:00' + THEN SUBTIME(notification_time, '09:00:00') + ELSE + ADDTIME(notification_time, '15:00:00') +END +WHERE notification_time IS NOT NULL; diff --git a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java index 651a3c7..f5d7aa6 100644 --- a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java +++ b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java @@ -17,6 +17,7 @@ import com.recyclestudy.member.service.output.MemberNotificationTimeFindOutput; import com.recyclestudy.member.service.output.MemberSaveOutput; import com.recyclestudy.restdocs.APIBaseTest; +import java.time.Instant; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.temporal.ChronoUnit; @@ -114,11 +115,11 @@ void findAllMemberDevices() { final MemberFindOutput.MemberFindElement device1 = new MemberFindOutput.MemberFindElement( DeviceIdentifier.from(headerIdentifier), - LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES).minusDays(1) + Instant.now().minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MINUTES) ); final MemberFindOutput.MemberFindElement device2 = new MemberFindOutput.MemberFindElement( DeviceIdentifier.from("device-id-2"), - LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) + Instant.now().truncatedTo(ChronoUnit.MINUTES) ); final MemberFindOutput output = new MemberFindOutput( @@ -145,7 +146,7 @@ void findAllMemberDevices() { fieldWithPath("devices[].identifier").type(JsonFieldType.STRING) .description("디바이스 식별자 값"), fieldWithPath("devices[].createdAt").type(JsonFieldType.STRING) - .description("디바이스 생성일") + .description("디바이스 생성일 (UTC, ISO 8601)") ) )) .header("X-Device-Id", headerIdentifier) @@ -337,7 +338,7 @@ void findAllMemberDevices_NullEmail() { Email.from("test@test.com"), List.of(new MemberFindOutput.MemberFindElement( DeviceIdentifier.from(headerIdentifier), - LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) + Instant.now().truncatedTo(ChronoUnit.MINUTES) )) ); given(memberService.findAllMemberDevices(any())).willReturn(output); @@ -362,11 +363,11 @@ void findAllMemberDevices_WithHeader() { final MemberFindOutput.MemberFindElement device1 = new MemberFindOutput.MemberFindElement( DeviceIdentifier.from(headerIdentifier), - LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES).minusDays(1) + Instant.now().minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MINUTES) ); final MemberFindOutput.MemberFindElement device2 = new MemberFindOutput.MemberFindElement( DeviceIdentifier.from("device-id-2"), - LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) + Instant.now().truncatedTo(ChronoUnit.MINUTES) ); final MemberFindOutput output = new MemberFindOutput( @@ -396,7 +397,7 @@ void findAllMemberDevices_WithHeader() { fieldWithPath("devices[].identifier").type(JsonFieldType.STRING) .description("디바이스 식별자 값"), fieldWithPath("devices[].createdAt").type(JsonFieldType.STRING) - .description("디바이스 생성일") + .description("디바이스 생성일 (UTC, ISO 8601)") ), queryParameters( parameterWithName("email").description("이메일 (다음 버전에서 제거 예정)") @@ -434,7 +435,7 @@ void findNotificationTime() { ) .responseFields( fieldWithPath("notificationTime").type(JsonFieldType.STRING) - .description("알림 시간 (HH:mm:ss)") + .description("알림 시간 (HH:mm:ss, UTC 기준)") ) )) .header("X-Device-Id", headerIdentifier) @@ -531,7 +532,7 @@ void updateNotificationTime() { ) .requestFields( fieldWithPath("notificationTime").type(JsonFieldType.STRING) - .description("알림 시간 (HH:mm:ss)") + .description("알림 시간 (HH:mm:ss, UTC 기준)") ) )) .contentType(MediaType.APPLICATION_JSON_VALUE) diff --git a/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java b/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java index a008a14..d6fc956 100644 --- a/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java +++ b/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java @@ -96,7 +96,7 @@ void saveReview_withDefaultCycle() { .responseFields( fieldWithPath("url").type(JsonFieldType.STRING).description("리뷰할 URL"), fieldWithPath("scheduledAts").type(JsonFieldType.ARRAY) - .description("복습 예정 일시 목록") + .description("복습 예정 일시 목록 (UTC, ISO 8601)") ) )) .contentType(MediaType.APPLICATION_JSON_VALUE) @@ -146,7 +146,7 @@ void saveReview_withCustomCycle() { .responseFields( fieldWithPath("url").type(JsonFieldType.STRING).description("리뷰할 URL"), fieldWithPath("scheduledAts").type(JsonFieldType.ARRAY) - .description("복습 예정 일시 목록") + .description("복습 예정 일시 목록 (UTC, ISO 8601)") ) )) .contentType(MediaType.APPLICATION_JSON_VALUE) @@ -274,7 +274,7 @@ void findNextReview_success() { ) .responseFields( fieldWithPath("scheduledAt").type(JsonFieldType.STRING) - .description("다음 발송 예정 시간 (PENDING 없을 시 null)"), + .description("다음 발송 예정 시간, UTC ISO 8601 형식 (PENDING 없을 시 null)"), fieldWithPath("count").type(JsonFieldType.NUMBER) .description("해당 시간에 발송될 URL 개수") ) @@ -284,6 +284,7 @@ void findNextReview_success() { .get("/api/v1/reviews/next") .then() .statusCode(HttpStatus.OK.value()) + .body("scheduledAt", equalTo("2026-03-06T09:00:00Z")) .body("count", equalTo(3)); } @@ -309,7 +310,7 @@ void findNextReview_empty() { ) .responseFields( fieldWithPath("scheduledAt").type(JsonFieldType.NULL) - .description("다음 발송 예정 시간 (PENDING 없을 시 null)"), + .description("다음 발송 예정 시간, UTC ISO 8601 형식 (PENDING 없을 시 null)"), fieldWithPath("count").type(JsonFieldType.NUMBER) .description("해당 시간에 발송될 URL 개수") ) diff --git a/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java index d43a0b5..57049b0 100644 --- a/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java +++ b/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java @@ -17,7 +17,9 @@ import com.recyclestudy.review.service.output.NextReviewOutput; import com.recyclestudy.review.service.output.ReviewSendOutput; import com.recyclestudy.review.service.output.ReviewSendOutput.ReviewSendElement; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -200,7 +202,7 @@ void findNextReview_returnsEarliestGroup() { // then assertSoftly(softly -> { - softly.assertThat(result.scheduledAt()).isEqualTo(t1); + softly.assertThat(result.scheduledAt()).isEqualTo(t1.toInstant(ZoneOffset.UTC)); softly.assertThat(result.count()).isEqualTo(1); }); } @@ -231,7 +233,7 @@ void findNextReview_countsSameScheduledAt() { // then assertSoftly(softly -> { - softly.assertThat(result.scheduledAt()).isEqualTo(t1); + softly.assertThat(result.scheduledAt()).isEqualTo(t1.toInstant(ZoneOffset.UTC)); softly.assertThat(result.count()).isEqualTo(3); }); } diff --git a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java index 80fab28..a9d18bd 100644 --- a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java +++ b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java @@ -154,7 +154,7 @@ void saveReview_withPreferredNotificationTime() { final Email email = Email.from("test@test.com"); final Member member = Member.withoutId(email); - final LocalTime preferredTime = LocalTime.of(9, 0); + final LocalTime preferredTime = LocalTime.of(0, 0); member.updateNotificationTime(preferredTime); final Review review = Review.withoutId(member, ReviewURL.from(urlValue));