diff --git a/src/main/java/com/mr/Application.java b/src/main/java/com/mr/Application.java index 87e38f6f..e3c636d1 100644 --- a/src/main/java/com/mr/Application.java +++ b/src/main/java/com/mr/Application.java @@ -1,17 +1,25 @@ package com.mr; +import jakarta.annotation.PostConstruct; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableScheduling; +import java.util.TimeZone; + @EnableRetry @EnableScheduling @EnableJpaAuditing @SpringBootApplication public class Application { + @PostConstruct + public void init() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + } + public static void main(String[] args) { SpringApplication.run(Application.class, args); } diff --git a/src/main/java/com/mr/domain/analysis/dto/res/AnalysisCreateResponseDTO.java b/src/main/java/com/mr/domain/analysis/dto/res/AnalysisCreateResponseDTO.java index 921c3a97..14e6e74d 100644 --- a/src/main/java/com/mr/domain/analysis/dto/res/AnalysisCreateResponseDTO.java +++ b/src/main/java/com/mr/domain/analysis/dto/res/AnalysisCreateResponseDTO.java @@ -1,8 +1,11 @@ package com.mr.domain.analysis.dto.res; +import com.fasterxml.jackson.annotation.JsonFormat; import com.mr.domain.analysis.entity.Analysis; import com.mr.domain.analysis.entity.enums.AnalysisStatus; -import java.time.LocalDateTime; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; public record AnalysisCreateResponseDTO( Long analysisId, @@ -10,7 +13,9 @@ public record AnalysisCreateResponseDTO( AnalysisStatus status, Integer startBar, Integer endBar, - LocalDateTime createdAt + @Schema(description = "분석 생성 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant createdAt ) { public static AnalysisCreateResponseDTO from(Analysis analysis) { return new AnalysisCreateResponseDTO(analysis.getId(), analysis.getPlaying().getId(), analysis.getStatus(), diff --git a/src/main/java/com/mr/domain/analysis/dto/res/AnalysisResultResponseDTO.java b/src/main/java/com/mr/domain/analysis/dto/res/AnalysisResultResponseDTO.java index 3a41abde..9e72e833 100644 --- a/src/main/java/com/mr/domain/analysis/dto/res/AnalysisResultResponseDTO.java +++ b/src/main/java/com/mr/domain/analysis/dto/res/AnalysisResultResponseDTO.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonFormat; import com.mr.domain.analysis.entity.enums.AnalysisStatus; import com.mr.domain.backingtrack.entity.BackingTrack; import com.mr.domain.playing.entity.Playing; @@ -11,8 +12,10 @@ import com.mr.domain.analysis.entity.enums.ContentFormat; import com.mr.domain.analysis.entity.enums.LlmStatus; import com.mr.domain.analysis.entity.enums.ReportGenerationType; +import io.swagger.v3.oas.annotations.media.Schema; + import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.Locale; public record AnalysisResultResponseDTO( @@ -22,7 +25,11 @@ public record AnalysisResultResponseDTO( String genre, String key, Integer bpm, - LocalDateTime playedAt, + + @Schema(description = "연주 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant playedAt, + String recordingFileUrl, String backingTrackAudioFileUrl, AnalysisStatus status, @@ -34,8 +41,14 @@ public record AnalysisResultResponseDTO( DomainScores domainScores, Report report, @JsonProperty("result") JsonNode rawResult, - LocalDateTime createdAt, - LocalDateTime completedAt + + @Schema(description = "생성 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant createdAt, + + @Schema(description = "완료 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant completedAt ) { public static AnalysisResultResponseDTO from( @@ -107,8 +120,14 @@ public record Report( String content, String modelName, String promptVersion, - LocalDateTime createdAt, - LocalDateTime updatedAt + + @Schema(description = "리포트 생성 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant createdAt, + + @Schema(description = "리포트 수정 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant updatedAt ) { private static Report fromNullable(AnalysisReport analysisReport) { @@ -129,4 +148,4 @@ private static Report fromNullable(AnalysisReport analysisReport) { ); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/analysis/dto/res/AnalysisStatusResponseDTO.java b/src/main/java/com/mr/domain/analysis/dto/res/AnalysisStatusResponseDTO.java index b32b36be..943c2019 100644 --- a/src/main/java/com/mr/domain/analysis/dto/res/AnalysisStatusResponseDTO.java +++ b/src/main/java/com/mr/domain/analysis/dto/res/AnalysisStatusResponseDTO.java @@ -1,16 +1,25 @@ package com.mr.domain.analysis.dto.res; +import com.fasterxml.jackson.annotation.JsonFormat; import com.mr.domain.analysis.entity.Analysis; import com.mr.domain.analysis.entity.enums.AnalysisStatus; -import java.time.LocalDateTime; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; public record AnalysisStatusResponseDTO( Long analysisId, AnalysisStatus status, Integer progressRate, String message, - LocalDateTime createdAt, - LocalDateTime completedAt + + @Schema(description = "생성 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant createdAt, + + @Schema(description = "완료 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant completedAt ) { public static AnalysisStatusResponseDTO from( diff --git a/src/main/java/com/mr/domain/analysis/entity/Analysis.java b/src/main/java/com/mr/domain/analysis/entity/Analysis.java index aa72b192..9e6019dd 100644 --- a/src/main/java/com/mr/domain/analysis/entity/Analysis.java +++ b/src/main/java/com/mr/domain/analysis/entity/Analysis.java @@ -10,7 +10,7 @@ import jakarta.persistence.*; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Objects; import lombok.AccessLevel; @@ -92,17 +92,17 @@ public class Analysis extends BaseCreatedEntity { private String failedReason; @Column(name = "processing_started_at") - private LocalDateTime processingStartedAt; + private Instant processingStartedAt; @Column(name = "completed_at") - private LocalDateTime completedAt; + private Instant completedAt; @Builder(access = AccessLevel.PRIVATE) private Analysis(User user, Playing playing, String analysisVersion, Integer startBar, Integer endBar, AnalysisStatus status, Integer totalScore, AnalysisGrade grade, String summary, String analysisRequestJson, BigDecimal scaleScore, BigDecimal tensionScore, BigDecimal progressionScore, BigDecimal voiceLeadingScore, String rawResultJson, - String failedReason, LocalDateTime completedAt) { + String failedReason, Instant completedAt) { validateUser(user); validatePlaying(playing); validateOwnerConsistency(user, playing); @@ -172,7 +172,7 @@ private static void validateBarRange(Integer startBar, Integer endBar) { } } - public LocalDateTime startProcessing(LocalDateTime now) { + public Instant startProcessing(Instant now) { if (this.status != AnalysisStatus.PENDING) { throw new IllegalStateException("PENDING 상태의 분석만 PROCESSING으로 변경할 수 있습니다."); } @@ -181,7 +181,7 @@ public LocalDateTime startProcessing(LocalDateTime now) { return this.processingStartedAt; } - public LocalDateTime restartProcessing(LocalDateTime now) { + public Instant restartProcessing(Instant now) { if (this.status != AnalysisStatus.PROCESSING) { throw new IllegalStateException("PROCESSING 상태의 분석만 다시 시작할 수 있습니다."); } @@ -189,13 +189,13 @@ public LocalDateTime restartProcessing(LocalDateTime now) { return this.processingStartedAt; } - public boolean isCurrentProcessing(LocalDateTime expectedProcessingStartedAt) { + public boolean isCurrentProcessing(Instant expectedProcessingStartedAt) { return this.status == AnalysisStatus.PROCESSING && Objects.equals(this.processingStartedAt, expectedProcessingStartedAt); } - private LocalDateTime nextProcessingStartedAt(LocalDateTime requestedAt) { - LocalDateTime next = Objects.requireNonNull(requestedAt).truncatedTo(ChronoUnit.MILLIS); + private Instant nextProcessingStartedAt(Instant requestedAt) { + Instant next = Objects.requireNonNull(requestedAt).truncatedTo(ChronoUnit.MILLIS); if (this.processingStartedAt != null && !next.isAfter(this.processingStartedAt)) { return this.processingStartedAt.plus(1, ChronoUnit.MILLIS); } @@ -204,7 +204,7 @@ private LocalDateTime nextProcessingStartedAt(LocalDateTime requestedAt) { public void complete(Integer totalScore, AnalysisGrade grade, String summary, BigDecimal scaleScore, BigDecimal tensionScore, BigDecimal progressionScore, BigDecimal voiceLeadingScore, - String rawResultJson, LocalDateTime completedAt) { + String rawResultJson, Instant completedAt) { if (this.status != AnalysisStatus.PROCESSING) { throw new IllegalStateException("PROCESSING 상태의 분석만 완료 처리할 수 있습니다."); } @@ -220,7 +220,7 @@ public void complete(Integer totalScore, AnalysisGrade grade, String summary, Bi this.completedAt = Objects.requireNonNull(completedAt); } - public void fail(String failedReason, LocalDateTime completedAt) { + public void fail(String failedReason, Instant completedAt) { if (this.status == AnalysisStatus.COMPLETED || this.status == AnalysisStatus.FAILED) { throw new IllegalStateException("이미 완료된 분석은 실패 처리할 수 없습니다."); } diff --git a/src/main/java/com/mr/domain/analysis/model/AnalysisProcessingClaim.java b/src/main/java/com/mr/domain/analysis/model/AnalysisProcessingClaim.java index c66fff6f..df25f563 100644 --- a/src/main/java/com/mr/domain/analysis/model/AnalysisProcessingClaim.java +++ b/src/main/java/com/mr/domain/analysis/model/AnalysisProcessingClaim.java @@ -1,9 +1,9 @@ package com.mr.domain.analysis.model; -import java.time.LocalDateTime; +import java.time.Instant; public record AnalysisProcessingClaim( String requestJson, - LocalDateTime processingStartedAt + Instant processingStartedAt ) { } diff --git a/src/main/java/com/mr/domain/analysis/repository/AnalysisRepository.java b/src/main/java/com/mr/domain/analysis/repository/AnalysisRepository.java index 9cbbd888..9d219ea2 100644 --- a/src/main/java/com/mr/domain/analysis/repository/AnalysisRepository.java +++ b/src/main/java/com/mr/domain/analysis/repository/AnalysisRepository.java @@ -3,7 +3,8 @@ import com.mr.domain.analysis.entity.Analysis; import com.mr.domain.analysis.entity.enums.AnalysisStatus; import jakarta.persistence.LockModeType; -import java.time.LocalDateTime; + +import java.time.Instant; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Pageable; @@ -28,7 +29,7 @@ public interface AnalysisRepository extends JpaRepository { """) List findIdsByStatusAndCreatedAtBefore( @Param("status") AnalysisStatus status, - @Param("cutoff") LocalDateTime cutoff, + @Param("cutoff") Instant cutoff, Pageable pageable ); @@ -39,7 +40,7 @@ List findIdsByStatusAndCreatedAtBefore( """) List findIdsByStatusAndProcessingStartedAtBefore( @Param("status") AnalysisStatus status, - @Param("cutoff") LocalDateTime cutoff, + @Param("cutoff") Instant cutoff, Pageable pageable ); @@ -72,7 +73,7 @@ List findByPlayingIdAndUserIdOrderByStartBarAscIdAsc( List findByUserAndStatusSince( @Param("userId") Long userId, @Param("status") AnalysisStatus status, - @Param("since") LocalDateTime since + @Param("since") Instant since ); @Query(""" diff --git a/src/main/java/com/mr/domain/analysis/scheduler/AnalysisRecoveryScheduler.java b/src/main/java/com/mr/domain/analysis/scheduler/AnalysisRecoveryScheduler.java index 7bbd0e27..5d484c88 100644 --- a/src/main/java/com/mr/domain/analysis/scheduler/AnalysisRecoveryScheduler.java +++ b/src/main/java/com/mr/domain/analysis/scheduler/AnalysisRecoveryScheduler.java @@ -4,7 +4,7 @@ import com.mr.domain.analysis.repository.AnalysisRepository; import java.time.Clock; import java.time.Duration; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import com.mr.domain.analysis.service.AnalysisProcessingService; @@ -51,7 +51,7 @@ public AnalysisRecoveryScheduler( fixedDelayString = "${analysis.recovery.fixed-delay-ms:30000}" ) public void recoverPendingAnalyses() { - LocalDateTime now = LocalDateTime.now(clock); + Instant now = Instant.now(clock); List pendingIds = analysisRepository.findIdsByStatusAndCreatedAtBefore( AnalysisStatus.PENDING, now.minus(pendingThreshold), @@ -59,7 +59,7 @@ public void recoverPendingAnalyses() { ); submitPending(pendingIds); - LocalDateTime processingCutoff = now.minus(processingThreshold); + Instant processingCutoff = now.minus(processingThreshold); List processingIds = analysisRepository.findIdsByStatusAndProcessingStartedAtBefore( AnalysisStatus.PROCESSING, processingCutoff, @@ -79,7 +79,7 @@ private void submitPending(List analysisIds) { } } - private void submitStaleProcessing(List analysisIds, LocalDateTime cutoff) { + private void submitStaleProcessing(List analysisIds, Instant cutoff) { for (Long analysisId : analysisIds) { try { taskExecutor.execute(() -> analysisProcessingService.recoverStaleProcessing(analysisId, cutoff)); diff --git a/src/main/java/com/mr/domain/analysis/service/AnalysisProcessingService.java b/src/main/java/com/mr/domain/analysis/service/AnalysisProcessingService.java index 4733f46f..6818891d 100644 --- a/src/main/java/com/mr/domain/analysis/service/AnalysisProcessingService.java +++ b/src/main/java/com/mr/domain/analysis/service/AnalysisProcessingService.java @@ -7,7 +7,8 @@ import com.mr.domain.analysis.model.GeneratedAnalysisReport; import com.mr.global.client.ai.AiAnalysisRequest; import com.mr.global.client.ai.AiServerClient; -import java.time.LocalDateTime; + +import java.time.Instant; import java.util.Optional; import java.util.function.Supplier; import lombok.RequiredArgsConstructor; @@ -29,7 +30,7 @@ public void process(Long analysisId) { processClaimed(analysisId, () -> analysisStateService.startProcessing(analysisId)); } - public void recoverStaleProcessing(Long analysisId, LocalDateTime cutoff) { + public void recoverStaleProcessing(Long analysisId, Instant cutoff) { processClaimed(analysisId, () -> analysisStateService.restartStaleProcessing(analysisId, cutoff)); } diff --git a/src/main/java/com/mr/domain/analysis/service/AnalysisStateService.java b/src/main/java/com/mr/domain/analysis/service/AnalysisStateService.java index e4b268d0..b0426af1 100644 --- a/src/main/java/com/mr/domain/analysis/service/AnalysisStateService.java +++ b/src/main/java/com/mr/domain/analysis/service/AnalysisStateService.java @@ -21,7 +21,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Clock; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.Optional; import com.mr.global.event.NotificationEvent; @@ -58,12 +58,12 @@ public Optional startProcessing(Long analysisId) { if (requestJson.isEmpty()) { return Optional.empty(); } - LocalDateTime processingStartedAt = analysis.startProcessing(now()); + Instant processingStartedAt = analysis.startProcessing(now()); return Optional.of(new AnalysisProcessingClaim(requestJson.get(), processingStartedAt)); } @Transactional - public Optional restartStaleProcessing(Long analysisId, LocalDateTime cutoff) { + public Optional restartStaleProcessing(Long analysisId, Instant cutoff) { Analysis analysis = analysisRepository.findByIdForUpdate(analysisId) .orElseThrow(() -> new GeneralException(AnalysisErrorStatus.ANALYSIS_NOT_FOUND)); if (analysis.getStatus() != AnalysisStatus.PROCESSING @@ -75,14 +75,14 @@ public Optional restartStaleProcessing(Long analysisId, if (requestJson.isEmpty()) { return Optional.empty(); } - LocalDateTime processingStartedAt = analysis.restartProcessing(now()); + Instant processingStartedAt = analysis.restartProcessing(now()); return Optional.of(new AnalysisProcessingClaim(requestJson.get(), processingStartedAt)); } @Transactional public boolean complete( Long analysisId, - LocalDateTime expectedProcessingStartedAt, + Instant expectedProcessingStartedAt, JsonNode result, String rawResultJson, GeneratedAnalysisReport generatedReport @@ -190,7 +190,7 @@ private void saveLlmCallLog( } @Transactional - public boolean fail(Long analysisId, LocalDateTime expectedProcessingStartedAt, String reason) { + public boolean fail(Long analysisId, Instant expectedProcessingStartedAt, String reason) { Analysis analysis = getAnalysisForUpdate(analysisId); if (!analysis.isCurrentProcessing(expectedProcessingStartedAt)) { return false; @@ -199,8 +199,8 @@ public boolean fail(Long analysisId, LocalDateTime expectedProcessingStartedAt, return true; } - private LocalDateTime now() { - return LocalDateTime.now(clock); + private Instant now() { + return Instant.now(clock); } private Analysis getAnalysisForUpdate(Long analysisId) { diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index 32876389..66b2eb9c 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -6,12 +6,13 @@ import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.entity.BaseCreatedEntity; import jakarta.persistence.*; -import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Instant; + @Getter @Entity @Table( @@ -43,14 +44,14 @@ public class SocialAuth extends BaseCreatedEntity { private String refreshTokenHash; @Column(name = "expired_at") - private LocalDateTime expiredAt; + private Instant expiredAt; @Column(name = "device_info", length = 255) private String deviceInfo; @Builder(access = AccessLevel.PRIVATE) private SocialAuth(User user, SocialType socialType, String socialId, - String refreshTokenHash, LocalDateTime expiredAt, String deviceInfo) { + String refreshTokenHash, Instant expiredAt, String deviceInfo) { validateUser(user); validateSocialType(socialType); @@ -73,7 +74,7 @@ private static String sanitizeDeviceInfo(String deviceInfo) { } public static SocialAuth create(User user, SocialType socialType, String socialId, - String tokenHash, LocalDateTime expiredAt, String deviceInfo) { + String tokenHash, Instant expiredAt, String deviceInfo) { validateTokenValue(tokenHash); validateExpiryTime(expiredAt); @@ -112,13 +113,13 @@ private static void validateTokenValue(String token) { } } - private static void validateExpiryTime(LocalDateTime expiredAt) { - if (expiredAt == null || !expiredAt.isAfter(LocalDateTime.now())) { + private static void validateExpiryTime(Instant expiredAt) { + if (expiredAt == null || !expiredAt.isAfter(Instant.now())) { throw new GeneralException(AuthErrorStatus.INVALID_TOKEN_EXPIRY); } } - public void updateRefreshToken(String tokenHash, LocalDateTime newExpiredAt, String deviceInfo) { + public void updateRefreshToken(String tokenHash, Instant newExpiredAt, String deviceInfo) { validateTokenValue(tokenHash); validateExpiryTime(newExpiredAt); @@ -130,10 +131,10 @@ public void updateRefreshToken(String tokenHash, LocalDateTime newExpiredAt, Str public void expireToken() { this.refreshTokenHash = null; this.deviceInfo = null; - this.expiredAt = LocalDateTime.now(); + this.expiredAt = Instant.now(); } public boolean isExpired() { - return this.expiredAt == null || !this.expiredAt.isAfter(LocalDateTime.now()); + return this.expiredAt == null || !this.expiredAt.isAfter(Instant.now()); } } \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/service/AuthTransactionService.java b/src/main/java/com/mr/domain/auth/service/AuthTransactionService.java index ef7b98f1..ff57b704 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthTransactionService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthTransactionService.java @@ -17,7 +17,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.Optional; @Service @@ -64,7 +64,7 @@ public AuthResponseDTO.LoginResponse completeTokenExchange(Long userId, SocialTy String newAccessToken = tokenProvider.createAccessToken(user.getUserId()); String newRefreshToken = tokenProvider.createRefreshToken(user.getUserId()); String refreshTokenHash = tokenProvider.hashToken(newRefreshToken); - LocalDateTime expiryTime = tokenProvider.getRefreshTokenExpiryTime(); + Instant expiryTime = tokenProvider.getRefreshTokenExpiryTime(); // PostgreSQL은 제약 위반이 나는 즉시 트랜잭션 전체를 abort 상태로 만들어서, // 같은 트랜잭션 안에서 복구 쿼리를 다시 시도하면 그 쿼리도 실패한다. @@ -114,7 +114,7 @@ public AuthResponseDTO.LoginResponse executeSocialLoginForExistingUser(SocialTyp String newAccessToken = tokenProvider.createAccessToken(user.getUserId()); String newRefreshToken = tokenProvider.createRefreshToken(user.getUserId()); String refreshTokenHash = tokenProvider.hashToken(newRefreshToken); - LocalDateTime expiryTime = tokenProvider.getRefreshTokenExpiryTime(); + Instant expiryTime = tokenProvider.getRefreshTokenExpiryTime(); socialAuth.updateRefreshToken(refreshTokenHash, expiryTime, deviceInfo); @@ -156,7 +156,7 @@ public AuthResponseDTO.TokenInfo executeLinkSocialAccount(Long userId, SocialTyp String newAccessToken = tokenProvider.createAccessToken(userId); String newRefreshToken = tokenProvider.createRefreshToken(userId); String refreshTokenHash = tokenProvider.hashToken(newRefreshToken); - LocalDateTime expiryTime = tokenProvider.getRefreshTokenExpiryTime(); + Instant expiryTime = tokenProvider.getRefreshTokenExpiryTime(); try { if (existingSocialAuth.isPresent()) { diff --git a/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackCreateResponseDTO.java b/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackCreateResponseDTO.java index b441baeb..10d142de 100644 --- a/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackCreateResponseDTO.java +++ b/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackCreateResponseDTO.java @@ -1,8 +1,9 @@ package com.mr.domain.backingtrack.dto.res; import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; +import java.time.Instant; public class BackingTrackCreateResponseDTO { @@ -10,12 +11,12 @@ public record CreateResultDTO( Long backingTrackId, String title, - // 날짜 포맷 - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") - LocalDateTime createdAt + @Schema(description = "생성 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant createdAt ) { - public static CreateResultDTO of(Long backingTrackId, String title, LocalDateTime createdAt) { + public static CreateResultDTO of(Long backingTrackId, String title, Instant createdAt) { return new CreateResultDTO(backingTrackId, title, createdAt); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackUpdateResponseDTO.java b/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackUpdateResponseDTO.java index 7c8e0dc2..9b42157a 100644 --- a/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackUpdateResponseDTO.java +++ b/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackUpdateResponseDTO.java @@ -1,8 +1,9 @@ package com.mr.domain.backingtrack.dto.res; import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; +import java.time.Instant; public class BackingTrackUpdateResponseDTO { @@ -10,12 +11,12 @@ public record UpdateResultDTO( Long backingTrackId, String title, - // 날짜 포맷 - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") - LocalDateTime updatedAt + @Schema(description = "수정 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant updatedAt ) { - public static BackingTrackUpdateResponseDTO.UpdateResultDTO of(Long backingTrackId, String title, LocalDateTime updatedAt) { + public static BackingTrackUpdateResponseDTO.UpdateResultDTO of(Long backingTrackId, String title, Instant updatedAt) { return new BackingTrackUpdateResponseDTO.UpdateResultDTO(backingTrackId, title, updatedAt); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackUploadUrlResponse.java b/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackUploadUrlResponse.java index 35fa70cb..33e678db 100644 --- a/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackUploadUrlResponse.java +++ b/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackUploadUrlResponse.java @@ -1,6 +1,8 @@ package com.mr.domain.backingtrack.dto.res; +import com.fasterxml.jackson.annotation.JsonFormat; import com.mr.global.file.s3.dto.PresignedUrlUpload; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.Map; @@ -8,7 +10,11 @@ public record BackingTrackUploadUrlResponse( String objectKey, String uploadUrl, + + @Schema(description = "URL 만료 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") Instant expiresAt, + Map requiredHeaders ) { public static BackingTrackUploadUrlResponse from( @@ -21,4 +27,4 @@ public static BackingTrackUploadUrlResponse from( presignedUpload.requiredHeaders() ); } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/backingtrack/repository/BackingTrackRepository.java b/src/main/java/com/mr/domain/backingtrack/repository/BackingTrackRepository.java index 1803fce9..54fa6dea 100644 --- a/src/main/java/com/mr/domain/backingtrack/repository/BackingTrackRepository.java +++ b/src/main/java/com/mr/domain/backingtrack/repository/BackingTrackRepository.java @@ -8,7 +8,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -26,12 +26,12 @@ public interface BackingTrackRepository extends JpaRepository findTopByAccessLevelAndAnalysisCompleted( @Param("accessLevel") AccessLevel accessLevel, - @Param("oneWeekAgo") LocalDateTime oneWeekAgo, + @Param("oneWeekAgo") Instant oneWeekAgo, Pageable pageable ); // 서비스 단에서 깔끔하게 호출하기 위한 default 메서드 래핑 - default List findTop3RecommendedTracks(LocalDateTime oneWeekAgo) { + default List findTop3RecommendedTracks(Instant oneWeekAgo) { return findTopByAccessLevelAndAnalysisCompleted( AccessLevel.PUBLIC, oneWeekAgo, diff --git a/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java b/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java index 9e29a38a..a35fc084 100644 --- a/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java +++ b/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java @@ -33,7 +33,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; @@ -329,7 +330,7 @@ public BackingTrackDetailResponseDTO.DetailResponseDTO getBackingTrackDetail( // 추천 백킹트랙 조회 로직 public BackingTrackRecommendedResponseDTO.RecommendedResponseDTO getRecommendedTracks() { - LocalDateTime oneWeekAgo = LocalDateTime.now().minusWeeks(1); // 현재로부터 일주일 전 + Instant oneWeekAgo = Instant.now().minus(7, ChronoUnit.DAYS); // 현재로부터 일주일 전 List topTracks = backingTrackRepository.findTop3RecommendedTracks(oneWeekAgo); diff --git a/src/main/java/com/mr/domain/history/dto/res/HistoryDetailResponseDTO.java b/src/main/java/com/mr/domain/history/dto/res/HistoryDetailResponseDTO.java index 7f88bfc1..38d10f19 100644 --- a/src/main/java/com/mr/domain/history/dto/res/HistoryDetailResponseDTO.java +++ b/src/main/java/com/mr/domain/history/dto/res/HistoryDetailResponseDTO.java @@ -1,5 +1,6 @@ package com.mr.domain.history.dto.res; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.JsonNode; import com.mr.domain.analysis.entity.Analysis; import com.mr.domain.analysis.entity.enums.AnalysisStatus; @@ -8,8 +9,9 @@ import com.mr.domain.playing.entity.MidiEventData; import com.mr.domain.playing.entity.Playing; import com.mr.domain.playing.entity.enums.MidiType; +import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; public record HistoryDetailResponseDTO( @@ -19,7 +21,11 @@ public record HistoryDetailResponseDTO( String key, Integer bpm, String timeSignature, - LocalDateTime playedAt, + + @Schema(description = "연주 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant playedAt, + Integer durationMinutes, Integer durationSec, String recordingFileUrl, @@ -86,7 +92,10 @@ public record AnalysisSummary( String oneLineSummary, AnalysisStatus status, Integer estimatedSeconds, - LocalDateTime createdAt + + @Schema(description = "분석 리포트 생성 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant createdAt ) { public static AnalysisSummary from(Analysis analysis, BarMetrics barMetrics) { @@ -112,4 +121,4 @@ private static Integer toEstimatedSeconds(Analysis analysis, BarMetrics barMetri return (int) Math.round(barCount * barMetrics.barDurationMs() / 1_000D); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/history/dto/res/HistoryListResponseDTO.java b/src/main/java/com/mr/domain/history/dto/res/HistoryListResponseDTO.java index 8fc9f60e..77404f60 100644 --- a/src/main/java/com/mr/domain/history/dto/res/HistoryListResponseDTO.java +++ b/src/main/java/com/mr/domain/history/dto/res/HistoryListResponseDTO.java @@ -1,10 +1,12 @@ package com.mr.domain.history.dto.res; +import com.fasterxml.jackson.annotation.JsonFormat; import com.mr.domain.analysis.entity.Analysis; import com.mr.domain.backingtrack.entity.BackingTrack; import com.mr.domain.playing.entity.Playing; +import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; public record HistoryListResponseDTO( @@ -26,7 +28,11 @@ public record Item( Integer scoreChange, Integer durationMinutes, Integer durationSec, - LocalDateTime playedAt, + + @Schema(description = "연주 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant playedAt, + String relativeDate ) { @@ -50,4 +56,4 @@ private static Integer toDurationMinutes(Integer durationSec) { return durationSec != null ? durationSec / 60 : null; } } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/history/service/HistoryService.java b/src/main/java/com/mr/domain/history/service/HistoryService.java index bbc144ed..a71214a1 100644 --- a/src/main/java/com/mr/domain/history/service/HistoryService.java +++ b/src/main/java/com/mr/domain/history/service/HistoryService.java @@ -18,7 +18,9 @@ import com.mr.global.file.s3.enums.S3FileType; import com.mr.global.file.s3.service.S3FileService; import com.mr.global.util.RelativeDateFormatter; -import java.time.LocalDateTime; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -48,7 +50,7 @@ public class HistoryService { public HistoryListResponseDTO getHistories(Long userId, int page, int size, HistoryPeriod period) { validatePaging(page, size); - LocalDateTime cutoff = resolveCutoff(period); + Instant cutoff = resolveCutoff(period); PageRequest pageRequest = PageRequest.of(page, size); Slice slice = cutoff == null ? playingRepository.findPlayingsByUserAndStatus( @@ -132,7 +134,7 @@ private List buildItems( return items; } - private Long findNextPlayingId(Long userId, LocalDateTime cutoff, Slice slice) { + private Long findNextPlayingId(Long userId, Instant cutoff, Slice slice) { if (!slice.hasNext() || slice.getContent().isEmpty()) { return null; } @@ -206,14 +208,14 @@ private void validatePlayingId(Long playingId) { } } - private LocalDateTime resolveCutoff(HistoryPeriod period) { + private Instant resolveCutoff(HistoryPeriod period) { if (period == null) { return null; } return switch (period) { - case WEEKLY -> LocalDateTime.now().minusDays(WEEKLY_WINDOW_DAYS); - case MONTHLY -> LocalDateTime.now().minusDays(MONTHLY_WINDOW_DAYS); + case WEEKLY -> Instant.now().minus(WEEKLY_WINDOW_DAYS, ChronoUnit.DAYS); + case MONTHLY -> Instant.now().minus(MONTHLY_WINDOW_DAYS, ChronoUnit.DAYS); case RECENT -> null; // 필터 없음과 동일 }; } diff --git a/src/main/java/com/mr/domain/home/dto/res/HomeResponseDTO.java b/src/main/java/com/mr/domain/home/dto/res/HomeResponseDTO.java index 93221ada..72679679 100644 --- a/src/main/java/com/mr/domain/home/dto/res/HomeResponseDTO.java +++ b/src/main/java/com/mr/domain/home/dto/res/HomeResponseDTO.java @@ -1,12 +1,13 @@ package com.mr.domain.home.dto.res; +import com.fasterxml.jackson.annotation.JsonFormat; import com.mr.domain.backingtrack.entity.BackingTrack; import com.mr.domain.learning.dto.res.LearningHomeResponseDTO; import com.mr.domain.playing.entity.Playing; import com.mr.domain.user.entity.enums.TheoryLevel; import io.swagger.v3.oas.annotations.media.Schema; import java.time.DayOfWeek; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; @Schema(description = "홈 화면 요약 조회 응답") @@ -180,8 +181,9 @@ public record RecentPlaying( @Schema(description = "BPM", example = "120") Integer bpm, - @Schema(description = "연습 종료 시각", example = "2026-05-04T14:32:00") - LocalDateTime playedAt, + @Schema(description = "연습 종료 시각 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant playedAt, @Schema(description = "상대 시간 표기", example = "오늘") String relativeTime, @@ -205,4 +207,4 @@ public static RecentPlaying of(Playing playing, String relativeTime) { ); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/home/service/HomeService.java b/src/main/java/com/mr/domain/home/service/HomeService.java index 7595efb2..e6709aab 100644 --- a/src/main/java/com/mr/domain/home/service/HomeService.java +++ b/src/main/java/com/mr/domain/home/service/HomeService.java @@ -26,8 +26,10 @@ import com.mr.global.util.RelativeDateFormatter; import java.sql.Date; import java.time.DayOfWeek; +import java.time.Instant; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -59,7 +61,7 @@ public HomeResponseDTO getHome(Long userId) { Set practiceDates = fetchPracticeDates(userId); - LocalDateTime since = LocalDateTime.now().minusDays(STREAK_LOOKBACK_DAYS); + Instant since = Instant.now().minus(STREAK_LOOKBACK_DAYS, ChronoUnit.DAYS); List recentCompleted = playingRepository.findByUserAndStatusSince(userId, PlayingStatus.COMPLETED, since); @@ -87,7 +89,7 @@ private List buildRecommendedLearnings( // 연속 출석일수는 기간 상한이 없어야 하므로 별도로 전체 기간 날짜만 조회 private Set fetchPracticeDates(Long userId) { return playingRepository.findDistinctEndedDatesByUserAndStatus(userId, PlayingStatus.COMPLETED).stream() - .map(Date::toLocalDate) + .map(instant -> instant.atZone(ZoneId.of("Asia/Seoul")).toLocalDate()) // DB에서 꺼낸 UTC 시간(Instant)을 한국 시간대(Asia/Seoul)로 해서 달력 날짜(LocalDate)만 뽑아냄 .collect(Collectors.toSet()); } @@ -111,7 +113,7 @@ private Streak buildStreak(Set practiceDates) { } private int computeCurrentStreak(Set practiceDates) { - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); // 서버가 UTC여도 사용자는 한국 기준으로 출석을 계산해야 함 LocalDate cursor = practiceDates.contains(today) ? today : today.minusDays(1); int streak = 0; @@ -130,7 +132,7 @@ private String buildStreakMessage(int currentDays) { } private List buildWeeklyAttendance(Set practiceDates) { - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); LocalDate weekStart = today.with(DayOfWeek.MONDAY); List weeklyAttendance = new ArrayList<>(); @@ -170,9 +172,18 @@ private String toKoreanLabel(DayOfWeek dayOfWeek) { } private PracticeSummary buildPracticeSummary(List recentCompleted) { - LocalDate today = LocalDate.now(); - LocalDateTime weekStart = today.with(DayOfWeek.MONDAY).atStartOfDay(); - LocalDateTime monthStart = today.withDayOfMonth(1).atStartOfDay(); + ZoneId kstZone = ZoneId.of("Asia/Seoul"); // 명시적인 한국 타임존 + LocalDate today = LocalDate.now(kstZone); // KST 기준 오늘 날짜 + + // 한국 시간 기준 월요일 자정(00:00)을 구한 뒤 -> UTC Instant로 변환 + Instant weekStart = today.with(DayOfWeek.MONDAY) + .atStartOfDay(kstZone) + .toInstant(); + + // 한국 시간 기준 1일 자정(00:00)을 구한 뒤 -> UTC Instant로 변환 + Instant monthStart = today.withDayOfMonth(1) + .atStartOfDay(kstZone) + .toInstant(); int weeklySeconds = sumDurationSince(recentCompleted, weekStart); int monthlySeconds = sumDurationSince(recentCompleted, monthStart); @@ -184,7 +195,7 @@ private PracticeSummary buildPracticeSummary(List recentCompleted) { ); } - private int sumDurationSince(List playings, LocalDateTime since) { + private int sumDurationSince(List playings, Instant since) { return playings.stream() .filter(p -> p.getEndedAt() != null && !p.getEndedAt().isBefore(since)) .mapToInt(p -> p.getDurationSec() != null ? p.getDurationSec() : 0) diff --git a/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java b/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java index 4272f5a1..b1b30b60 100644 --- a/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java +++ b/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java @@ -1,9 +1,10 @@ package com.mr.domain.learning.dto.res; +import com.fasterxml.jackson.annotation.JsonFormat; import com.mr.domain.learning.entity.UserLearningProgress; import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; +import java.time.Instant; public class LearningResultResponseDTO { @Schema(description = "학습 결과 저장 응답") @@ -23,8 +24,9 @@ public record SaveResultResultDTO( @Schema(description = "저장된 점수", example = "95") Integer score, - @Schema(description = "저장/갱신 시각", type = "string", example = "2026-07-07T15:30:00") - LocalDateTime completedAt + @Schema(description = "저장/갱신 시각 (KST 기준 응답)", type = "string", example = "2026-08-11T18:00:00") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant completedAt ) { public static SaveResultResultDTO from(UserLearningProgress progress) { return new SaveResultResultDTO( @@ -37,4 +39,4 @@ public static SaveResultResultDTO from(UserLearningProgress progress) { ); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java b/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java index b0cdac51..3a5cd354 100644 --- a/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java +++ b/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java @@ -19,7 +19,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.Objects; @Entity @@ -62,16 +62,16 @@ public class UserLearningProgress extends BaseTimeEntity { // 마지막 학습일 @Column(name = "last_studied_at") - private LocalDateTime lastStudiedAt; + private Instant lastStudiedAt; @Builder(access = AccessLevel.PRIVATE) private UserLearningProgress(User user, Learning learning, LearningStep learningStep, - Integer score, LocalDateTime lastStudiedAt) { + Integer score, Instant lastStudiedAt) { this.user = user; this.learning = learning; this.learningStep = learningStep; this.score = score; - this.lastStudiedAt = lastStudiedAt != null ? lastStudiedAt : LocalDateTime.now(); + this.lastStudiedAt = lastStudiedAt != null ? lastStudiedAt : Instant.now(); } public static UserLearningProgress create(User user, Learning learning, LearningStep learningStep) { @@ -81,7 +81,7 @@ public static UserLearningProgress create(User user, Learning learning, Learning .user(user) .learning(learning) .learningStep(learningStep) - .lastStudiedAt(LocalDateTime.now()) + .lastStudiedAt(Instant.now()) .build(); } @@ -96,9 +96,9 @@ private static void validateLearningAndStep(Learning learning, LearningStep lear } // 학습 시간 및 점수 업데이트 - public void updateProgress(Integer score, LocalDateTime lastStudiedAt) { + public void updateProgress(Integer score, Instant lastStudiedAt) { this.score = score; - this.lastStudiedAt = lastStudiedAt != null ? lastStudiedAt : LocalDateTime.now(); + this.lastStudiedAt = lastStudiedAt != null ? lastStudiedAt : Instant.now(); } // 점수 기준 학습 진행 상태 파악 diff --git a/src/main/java/com/mr/domain/learning/service/LearningService.java b/src/main/java/com/mr/domain/learning/service/LearningService.java index 2275cada..9320ee6c 100644 --- a/src/main/java/com/mr/domain/learning/service/LearningService.java +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -32,7 +32,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -91,12 +91,12 @@ public LearningResultResponseDTO.SaveResultResultDTO saveResult( UserLearningProgress progress = userLearningProgressRepository .findByUser_UserIdAndLearningStep_Id(userId, request.learningStepId()) .map(p -> { - p.updateProgress(request.score(), LocalDateTime.now()); + p.updateProgress(request.score(), Instant.now()); return p; }) .orElseGet(() -> { UserLearningProgress newProgress = UserLearningProgress.create(user, learning, learningStep); - newProgress.updateProgress(request.score(), LocalDateTime.now()); + newProgress.updateProgress(request.score(), Instant.now()); return userLearningProgressRepository.save(newProgress); }); diff --git a/src/main/java/com/mr/domain/mentor/dto/res/MentorMessageHistoryResponseDTO.java b/src/main/java/com/mr/domain/mentor/dto/res/MentorMessageHistoryResponseDTO.java index f328f14d..009dec95 100644 --- a/src/main/java/com/mr/domain/mentor/dto/res/MentorMessageHistoryResponseDTO.java +++ b/src/main/java/com/mr/domain/mentor/dto/res/MentorMessageHistoryResponseDTO.java @@ -1,9 +1,12 @@ package com.mr.domain.mentor.dto.res; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.JsonNode; import com.mr.domain.mentor.entity.MentorMessage; import com.mr.domain.mentor.entity.enums.MessageRole; -import java.time.LocalDateTime; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; import java.util.List; public record MentorMessageHistoryResponseDTO( @@ -16,7 +19,10 @@ public record Message( MessageRole role, JsonNode referencesJson, String content, - LocalDateTime createdAt + + @Schema(description = "메시지 생성 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant createdAt ) { public static Message from(MentorMessage message, JsonNode referencesJson) { @@ -29,4 +35,4 @@ public static Message from(MentorMessage message, JsonNode referencesJson) { ); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/mentor/dto/res/MentorStreamEventDTO.java b/src/main/java/com/mr/domain/mentor/dto/res/MentorStreamEventDTO.java index 9eedfc12..46196bc0 100644 --- a/src/main/java/com/mr/domain/mentor/dto/res/MentorStreamEventDTO.java +++ b/src/main/java/com/mr/domain/mentor/dto/res/MentorStreamEventDTO.java @@ -1,9 +1,12 @@ package com.mr.domain.mentor.dto.res; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.JsonNode; import com.mr.domain.mentor.entity.MentorMessage; import com.mr.domain.mentor.entity.enums.MessageRole; -import java.time.LocalDateTime; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; public final class MentorStreamEventDTO { @@ -23,7 +26,11 @@ public record Error(String code, String message) { } public record Message(Long mentorMessageId, MessageRole role, JsonNode referencesJson, - String content, LocalDateTime createdAt) { + String content, + + @Schema(description = "메시지 생성 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant createdAt) { public static Message user(MentorMessage message) { return new Message(message.getId(), message.getRole(), null, message.getContent(), message.getCreatedAt()); @@ -39,4 +46,4 @@ public static Message assistant(MentorMessage message, JsonNode referencesJson) ); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/mentor/entity/MentorChatSession.java b/src/main/java/com/mr/domain/mentor/entity/MentorChatSession.java index 1dc95f45..9d56561f 100644 --- a/src/main/java/com/mr/domain/mentor/entity/MentorChatSession.java +++ b/src/main/java/com/mr/domain/mentor/entity/MentorChatSession.java @@ -9,7 +9,7 @@ import jakarta.persistence.*; import java.time.Duration; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.Objects; import java.util.UUID; import lombok.AccessLevel; @@ -58,19 +58,19 @@ public class MentorChatSession extends BaseTimeEntity { private String disabledReason; @Column(name = "last_message_at") - private LocalDateTime lastMessageAt; + private Instant lastMessageAt; @Column(name = "generation_token", length = 36) private String generationToken; @Column(name = "generation_started_at") - private LocalDateTime generationStartedAt; + private Instant generationStartedAt; @Column(name = "question_count", nullable = false) private Integer questionCount; private MentorChatSession(Analysis analysis, User user, MentorChatStatus status, - String disabledReason, LocalDateTime lastMessageAt) { + String disabledReason, Instant lastMessageAt) { validateAnalysis(analysis); validateUser(user); @@ -114,7 +114,7 @@ public void disable(String disabledReason) { } public void updateLastMessageAt() { - this.lastMessageAt = LocalDateTime.now(); + this.lastMessageAt = Instant.now(); } public void increaseQuestionCount() { @@ -131,7 +131,7 @@ public String startGenerating(Duration staleAfter) { } this.status = MentorChatStatus.GENERATING; this.generationToken = UUID.randomUUID().toString(); - this.generationStartedAt = LocalDateTime.now(); + this.generationStartedAt = Instant.now(); return this.generationToken; } @@ -141,7 +141,7 @@ public void completeGenerating(String expectedToken) { throw new GeneralException(MentorErrorStatus.MENTOR_SESSION_NOT_ACTIVE); } this.questionCount += 1; - this.lastMessageAt = LocalDateTime.now(); + this.lastMessageAt = Instant.now(); this.status = MentorChatStatus.ACTIVE; clearGeneration(); } @@ -156,7 +156,7 @@ public void failGenerating(String expectedToken) { private boolean isStale(Duration staleAfter) { return this.generationStartedAt == null - || !this.generationStartedAt.isAfter(LocalDateTime.now().minus(staleAfter)); + || !this.generationStartedAt.isAfter(Instant.now().minus(staleAfter)); } private void clearGeneration() { diff --git a/src/main/java/com/mr/domain/notification/dto/res/NotificationResponseDTO.java b/src/main/java/com/mr/domain/notification/dto/res/NotificationResponseDTO.java index a177d4e8..c6247928 100644 --- a/src/main/java/com/mr/domain/notification/dto/res/NotificationResponseDTO.java +++ b/src/main/java/com/mr/domain/notification/dto/res/NotificationResponseDTO.java @@ -1,11 +1,13 @@ package com.mr.domain.notification.dto.res; +import com.fasterxml.jackson.annotation.JsonFormat; import com.mr.domain.notification.entity.Notification; import com.mr.domain.notification.exception.NotificationErrorStatus; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.event.NotificationEvent; +import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; +import java.time.Instant; public record NotificationResponseDTO ( @@ -15,7 +17,10 @@ public record NotificationResponseDTO ( String content, Boolean isRead, Long targetId, - LocalDateTime createdAt + + @Schema(description = "알림 생성 일시 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant createdAt ) { public static NotificationResponseDTO from(Notification notification) { String rawContent = notification.getContent(); @@ -55,4 +60,4 @@ public static NotificationResponseDTO from(Notification notification) { notification.getCreatedAt() ); } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/notification/repository/NotificationRepository.java b/src/main/java/com/mr/domain/notification/repository/NotificationRepository.java index 75b13800..799a365b 100644 --- a/src/main/java/com/mr/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/mr/domain/notification/repository/NotificationRepository.java @@ -8,7 +8,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.Optional; public interface NotificationRepository extends JpaRepository { @@ -25,7 +25,7 @@ public interface NotificationRepository extends JpaRepository requiredHeaders ) { public static RecordingUploadUrlResponse from( @@ -20,4 +27,4 @@ public static RecordingUploadUrlResponse from( presignedUpload.requiredHeaders() ); } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index f2793e0c..5fdeaa59 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -29,7 +29,7 @@ import org.hibernate.type.SqlTypes; import java.time.Duration; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; @@ -89,10 +89,10 @@ public class Playing extends BaseCreatedDeletedEntity { // 실제 연주가 시작된 시간 @Column(name = "started_at") - private LocalDateTime startedAt; + private Instant startedAt; @Column(name = "ended_at") - private LocalDateTime endedAt; + private Instant endedAt; @Column(name = "recording_object_key", length = 255) private String recordingObjectKey; @@ -194,7 +194,7 @@ public void completeWithMidiData( validateCompletableStatus(); validateMidiData(requestedMidiData); - LocalDateTime completedAt = LocalDateTime.now(); + Instant completedAt = Instant.now(); long savedDurationMs = calculateSavedDurationMs(completedAt); List normalizedMidiData = normalizeMidiData(requestedMidiData, savedDurationMs); @@ -239,7 +239,7 @@ public void start() { validateStartableStatus(); this.status = PlayingStatus.IN_PROGRESS; - this.startedAt = LocalDateTime.now(); + this.startedAt = Instant.now(); } private void validateCompletableStatus() { @@ -314,7 +314,7 @@ private long calculateBackingTrackDurationMs() { } private long calculateSavedDurationMs( - LocalDateTime completedAt + Instant completedAt ) { long actualDurationMs = Duration.between( diff --git a/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java b/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java index 4452557a..927ef9b4 100644 --- a/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java +++ b/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java @@ -4,7 +4,7 @@ import com.mr.domain.playing.entity.enums.PlayingStatus; import jakarta.persistence.LockModeType; import java.sql.Date; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Pageable; @@ -43,7 +43,7 @@ Slice findPlayingsByUserAndStatus( Slice findPlayingsByUserAndStatusSince( @Param("userId") Long userId, @Param("status") PlayingStatus status, - @Param("cutoff") LocalDateTime cutoff, + @Param("cutoff") Instant cutoff, Pageable pageable ); @@ -58,7 +58,7 @@ Slice findPlayingsByUserAndStatusSince( List findNextPlayingId( @Param("userId") Long userId, @Param("status") PlayingStatus status, - @Param("endedAt") LocalDateTime endedAt, + @Param("endedAt") Instant endedAt, @Param("playingId") Long playingId, Pageable pageable ); @@ -75,8 +75,8 @@ List findNextPlayingId( List findNextPlayingIdSince( @Param("userId") Long userId, @Param("status") PlayingStatus status, - @Param("cutoff") LocalDateTime cutoff, - @Param("endedAt") LocalDateTime endedAt, + @Param("cutoff") Instant cutoff, + @Param("endedAt") Instant endedAt, @Param("playingId") Long playingId, Pageable pageable ); @@ -109,17 +109,17 @@ List findNextPlayingIdSince( List findByUserAndStatusSince( @Param("userId") Long userId, @Param("status") PlayingStatus status, - @Param("since") LocalDateTime since + @Param("since") Instant since ); @Query(""" - select distinct function('date', p.endedAt) from Playing p + select p.endedAt from Playing p where p.user.userId = :userId and p.status = :status and p.deletedAt is null and p.endedAt is not null """) - List findDistinctEndedDatesByUserAndStatus( + List findDistinctEndedDatesByUserAndStatus( @Param("userId") Long userId, @Param("status") PlayingStatus status ); @@ -141,7 +141,7 @@ PracticeTotals aggregateTotalsByUserAndStatus( interface PracticeTotals { Long getSessionCount(); Long getTotalDurationSec(); - LocalDateTime getLastEndedAt(); + Instant getLastEndedAt(); } Optional findByIdAndDeletedAtIsNull(Long playingId); @@ -161,7 +161,7 @@ select coalesce(sum(p.durationSec), 0) from Playing p Long sumDurationSecExcludeCurrent( @Param("userId") Long userId, @Param("status") PlayingStatus status, - @Param("since") LocalDateTime since, + @Param("since") Instant since, @Param("excludePlayingId") Long excludePlayingId ); } diff --git a/src/main/java/com/mr/domain/playing/service/PlayingService.java b/src/main/java/com/mr/domain/playing/service/PlayingService.java index 61ab9ee9..23764655 100644 --- a/src/main/java/com/mr/domain/playing/service/PlayingService.java +++ b/src/main/java/com/mr/domain/playing/service/PlayingService.java @@ -32,8 +32,9 @@ import java.time.Clock; import java.time.DayOfWeek; +import java.time.Instant; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import static com.mr.domain.backingtrack.entity.enums.AccessLevel.PUBLIC; @@ -261,7 +262,12 @@ private void publishPracticeMilestoneNotification( int intervalHours = 10; // 10시간 단위로 알림 int intervalSeconds = intervalHours * 3600; - LocalDateTime weekStart = LocalDate.now(clock).with(DayOfWeek.MONDAY).atStartOfDay(); + // 한국 시간(KST) 기준으로 이번 주 월요일 자정을 구한 뒤 Instant로 변환 + ZoneId kstZone = ZoneId.of("Asia/Seoul"); + Instant weekStart = Instant.now(clock).atZone(kstZone).toLocalDate() + .with(DayOfWeek.MONDAY) + .atStartOfDay(kstZone) + .toInstant(); // 방금 끝낸 연주를 제외한 이전 누적 시간 Long previousWeeklySeconds = playingRepository.sumDurationSecExcludeCurrent( diff --git a/src/main/java/com/mr/domain/statistics/entity/UserStatistics.java b/src/main/java/com/mr/domain/statistics/entity/UserStatistics.java index efae99e2..7207409d 100644 --- a/src/main/java/com/mr/domain/statistics/entity/UserStatistics.java +++ b/src/main/java/com/mr/domain/statistics/entity/UserStatistics.java @@ -14,7 +14,8 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.Instant; + import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -52,11 +53,11 @@ public class UserStatistics extends BaseTimeEntity { private BigDecimal averageAccuracy; @Column(name = "recent_practiced_at") - private LocalDateTime recentPracticedAt; + private Instant recentPracticedAt; private UserStatistics(User user, Integer totalPracticeMinutes, Integer totalPracticeCount, Integer totalAnalysisCount, BigDecimal averageScore, BigDecimal averageAccuracy, - LocalDateTime recentPracticedAt) { + Instant recentPracticedAt) { validateUser(user); this.user = user; @@ -87,7 +88,7 @@ public static UserStatistics createForUser(User user) { } public void updatePracticeSummary(Integer totalPracticeMinutes, Integer totalPracticeCount, - LocalDateTime recentPracticedAt) { + Instant recentPracticedAt) { this.totalPracticeMinutes = totalPracticeMinutes; this.totalPracticeCount = totalPracticeCount; this.recentPracticedAt = recentPracticedAt; diff --git a/src/main/java/com/mr/domain/statistics/service/StatisticsAggregationService.java b/src/main/java/com/mr/domain/statistics/service/StatisticsAggregationService.java index cc007c25..7ef46c3f 100644 --- a/src/main/java/com/mr/domain/statistics/service/StatisticsAggregationService.java +++ b/src/main/java/com/mr/domain/statistics/service/StatisticsAggregationService.java @@ -22,7 +22,9 @@ import java.math.RoundingMode; import java.time.Clock; import java.time.DayOfWeek; +import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.Objects; import lombok.RequiredArgsConstructor; @@ -37,6 +39,7 @@ public class StatisticsAggregationService { private static final int SCORE_SCALE = 1; private static final int SECONDS_PER_MINUTE = 60; + private static final ZoneId KOREA_ZONE_ID = ZoneId.of("Asia/Seoul"); private final UserRepository userRepository; private final UserStatisticsRepository userStatisticsRepository; @@ -87,13 +90,13 @@ private UserStatistics getOrCreateUserStatistics(Long userId) { } private void refreshWeeklyPracticeStatistics(Long userId) { - LocalDate weekStart = LocalDate.now(clock).with(DayOfWeek.MONDAY); + LocalDate weekStart = Instant.now(clock).atZone(KOREA_ZONE_ID).toLocalDate().with(DayOfWeek.MONDAY); LocalDate weekEnd = weekStart.plusDays(6); List playings = playingRepository.findByUserAndStatusSince( - userId, PlayingStatus.COMPLETED, weekStart.atStartOfDay()); + userId, PlayingStatus.COMPLETED, weekStart.atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant()); List analyses = analysisRepository.findByUserAndStatusSince( - userId, AnalysisStatus.COMPLETED, weekStart.atStartOfDay()); + userId, AnalysisStatus.COMPLETED, weekStart.atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant()); int practiceMinutes = minutesFromSeconds(sumDurationSec(playings)); int sessionCount = playings.size(); @@ -108,12 +111,12 @@ private void refreshWeeklyPracticeStatistics(Long userId) { } private void refreshWeeklySkillStatistics(Long userId) { - LocalDate weekStart = LocalDate.now(clock).with(DayOfWeek.MONDAY); + LocalDate weekStart = Instant.now(clock).atZone(KOREA_ZONE_ID).toLocalDate().with(DayOfWeek.MONDAY); LocalDate weekEnd = weekStart.plusDays(6); LocalDate lastWeekStart = weekStart.minusWeeks(1); List analyses = analysisRepository.findByUserAndStatusSince( - userId, AnalysisStatus.COMPLETED, weekStart.atStartOfDay()); + userId, AnalysisStatus.COMPLETED, weekStart.atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant()); for (SkillType skillType : SkillType.values()) { upsertWeeklySkillStatistics(userId, skillType, weekStart, weekEnd, lastWeekStart, analyses); diff --git a/src/main/java/com/mr/domain/statistics/service/StatisticsService.java b/src/main/java/com/mr/domain/statistics/service/StatisticsService.java index 3569e9f8..f9e0fb72 100644 --- a/src/main/java/com/mr/domain/statistics/service/StatisticsService.java +++ b/src/main/java/com/mr/domain/statistics/service/StatisticsService.java @@ -18,7 +18,9 @@ import java.math.RoundingMode; import java.time.Clock; import java.time.DayOfWeek; +import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneId; import java.util.ArrayList; import java.util.EnumMap; import java.util.List; @@ -45,7 +47,11 @@ public class StatisticsService { public StatisticsResponseDTO getStatistics(Long userId) { userRepository.findById(userId) .orElseThrow(() -> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); - LocalDate thisWeek = LocalDate.now(clock).with(DayOfWeek.MONDAY); + + ZoneId kstZone = ZoneId.of("Asia/Seoul"); + LocalDate thisWeek = Instant.now(clock).atZone(kstZone).toLocalDate() + .with(DayOfWeek.MONDAY); + LocalDate lastWeek = thisWeek.minusWeeks(1); LocalDate trendStart = thisWeek.minusWeeks(WEEKLY_TREND_WEEKS - 1L); @@ -53,8 +59,10 @@ public StatisticsResponseDTO getStatistics(Long userId) { .findAllByUser_UserIdAndPeriodTypeAndPeriodStartBetween( userId, PeriodType.WEEKLY, trendStart, thisWeek) .stream().collect(Collectors.toMap(PracticeStatistics::getPeriodStart, Function.identity())); + Map currentSkills = new EnumMap<>(SkillType.class); Map previousSkills = new EnumMap<>(SkillType.class); + skillStatisticsRepository.findAllByUser_UserIdAndPeriodTypeAndPeriodStartBetween( userId, PeriodType.WEEKLY, lastWeek, thisWeek) .forEach(statistics -> { @@ -84,7 +92,7 @@ private WeeklySummary buildWeeklySummary(PracticeStatistics current, PracticeSta } private List buildDomainGrowth(Map current, - Map previous) { + Map previous) { List result = new ArrayList<>(); for (SkillType type : SkillType.values()) { BigDecimal currentScore = scoreOf(current.get(type)); @@ -153,4 +161,4 @@ private String resolveWeekLabel(int weeksAgo) { default -> "이번주"; }; } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/subscriptions/entity/Subscription.java b/src/main/java/com/mr/domain/subscriptions/entity/Subscription.java index 6ccd7064..8ca36281 100644 --- a/src/main/java/com/mr/domain/subscriptions/entity/Subscription.java +++ b/src/main/java/com/mr/domain/subscriptions/entity/Subscription.java @@ -17,7 +17,7 @@ import lombok.NoArgsConstructor; import lombok.Builder; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @Getter @@ -43,14 +43,14 @@ public class Subscription { // 구독 시작일 @Column(name = "start_date", nullable = false) - private LocalDateTime startDate; + private Instant startDate; // 구독 종료일 @Column(name = "end_date", nullable = false) - private LocalDateTime endDate; + private Instant endDate; @Builder(access = AccessLevel.PRIVATE) - private Subscription(User user, String tier, LocalDateTime startDate, LocalDateTime endDate) { + private Subscription(User user, String tier, Instant startDate, Instant endDate) { this.user = user; this.tier = tier; this.startDate = startDate; @@ -58,7 +58,7 @@ private Subscription(User user, String tier, LocalDateTime startDate, LocalDateT } // 정적 팩토리 메서드 (구독 생성) - public static Subscription create(User user, String tier, LocalDateTime startDate, LocalDateTime endDate) { + public static Subscription create(User user, String tier, Instant startDate, Instant endDate) { validateUser(user); validateTier(tier); validateDates(startDate, endDate); @@ -82,7 +82,7 @@ private static void validateTier(String tier) { } } - private static void validateDates(LocalDateTime startDate, LocalDateTime endDate) { + private static void validateDates(Instant startDate, Instant endDate) { if (startDate != null && endDate != null && endDate.isBefore(startDate)) { throw new GeneralException(SubscriptionErrorStatus.INVALID_SUBSCRIPTION_DATE); } @@ -90,12 +90,12 @@ private static void validateDates(LocalDateTime startDate, LocalDateTime endDate // 현재 구독 유효 여부 확인 (현재 시간이 시작일과 종료일 사이인지) public boolean isActive() { - LocalDateTime now = LocalDateTime.now(); + Instant now = Instant.now(); return !now.isBefore(this.startDate) && !now.isAfter(this.endDate); } // 구독 기간 연장 - public void extendSubscription(LocalDateTime newEndDate) { + public void extendSubscription(Instant newEndDate) { if (newEndDate == null || !newEndDate.isAfter(this.endDate)) { throw new GeneralException(SubscriptionErrorStatus.INVALID_SUBSCRIPTION_EXTENSION_DATE); } diff --git a/src/main/java/com/mr/domain/user/dto/res/UserProfileResponseDTO.java b/src/main/java/com/mr/domain/user/dto/res/UserProfileResponseDTO.java index f09040b5..48f52a3a 100644 --- a/src/main/java/com/mr/domain/user/dto/res/UserProfileResponseDTO.java +++ b/src/main/java/com/mr/domain/user/dto/res/UserProfileResponseDTO.java @@ -1,10 +1,12 @@ package com.mr.domain.user.dto.res; +import com.fasterxml.jackson.annotation.JsonFormat; import com.mr.domain.user.entity.enums.TheoryLevel; import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; import lombok.Builder; +import java.time.Instant; + public class UserProfileResponseDTO { @Builder @@ -75,7 +77,8 @@ public record UpdateResponse( @Schema(description = "수정된 화성학 숙련도", example = "ADVANCED") TheoryLevel skillLevel, - @Schema(description = "처리 완료 시각", example = "2026-07-24T01:30:00") - LocalDateTime updatedAt + @Schema(description = "처리 완료 시각 (KST 기준 응답)", example = "2026-08-11T18:00:00", type = "string") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul") + Instant updatedAt ) {} -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index 0b7e5fa2..bcaf1867 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -23,13 +23,15 @@ import com.mr.domain.user.repository.UserRepository; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.security.SecurityUtil; -import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.hibernate.exception.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -110,9 +112,9 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ .orElseThrow(() -> new GeneralException(InstrumentErrorStatus.INSTRUMENT_NOT_SEEDED)); studentInstrumentRepository.save(StudentInstrument.createPrimary(student, piano)); - LocalDateTime now = LocalDateTime.now(); + Instant now = Instant.now(); Subscription subscription = subscriptionRepository.save( - Subscription.create(user, SubscriptionTier.PRO.name(), now, now.plusDays(SUBSCRIPTION_PERIOD_DAYS))); + Subscription.create(user, SubscriptionTier.PRO.name(), now, now.plus(SUBSCRIPTION_PERIOD_DAYS, ChronoUnit.DAYS))); return UserProfileResponseDTO.OnboardingResponse.builder() .userId(user.getUserId()) @@ -153,7 +155,7 @@ public UserProfileResponseDTO.UpdateResponse updateProfile(UserProfileRequestDTO .userId(user.getUserId()) .nickname(user.getNickname()) .skillLevel(student.getTheoryLevel()) - .updatedAt(LocalDateTime.now()) + .updatedAt(Instant.now()) .build(); } diff --git a/src/main/java/com/mr/domain/weakness/entity/WeaknessNote.java b/src/main/java/com/mr/domain/weakness/entity/WeaknessNote.java index 5235f267..4bebb4f8 100644 --- a/src/main/java/com/mr/domain/weakness/entity/WeaknessNote.java +++ b/src/main/java/com/mr/domain/weakness/entity/WeaknessNote.java @@ -18,11 +18,12 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Instant; + @Getter @Entity @Table(name = "weakness_note") @@ -69,11 +70,11 @@ public class WeaknessNote extends BaseTimeEntity { private Learning recommendedLearningContent; @Column(name = "last_detected_at") - private LocalDateTime lastDetectedAt; + private Instant lastDetectedAt; private WeaknessNote(User user, String conceptCode, String conceptName, String weaknessType, Integer occurrenceCount, Severity severity, Analysis lastAnalysis, - Learning recommendedLearningContent, LocalDateTime lastDetectedAt) { + Learning recommendedLearningContent, Instant lastDetectedAt) { validateUser(user); this.user = user; @@ -104,7 +105,7 @@ public static WeaknessNote create(User user, String conceptCode, String conceptN Severity.LOW, lastAnalysis, recommendedLearningContent, - LocalDateTime.now() + Instant.now() ); } @@ -112,6 +113,6 @@ public void increaseOccurrence(Severity severity, Analysis lastAnalysis) { this.occurrenceCount += 1; this.severity = severity == null ? this.severity : severity; this.lastAnalysis = lastAnalysis; - this.lastDetectedAt = LocalDateTime.now(); + this.lastDetectedAt = Instant.now(); } } diff --git a/src/main/java/com/mr/global/entity/BaseCreatedDeletedEntity.java b/src/main/java/com/mr/global/entity/BaseCreatedDeletedEntity.java index 7176ca69..fb0f8635 100644 --- a/src/main/java/com/mr/global/entity/BaseCreatedDeletedEntity.java +++ b/src/main/java/com/mr/global/entity/BaseCreatedDeletedEntity.java @@ -2,7 +2,7 @@ import jakarta.persistence.Column; import jakarta.persistence.MappedSuperclass; -import java.time.LocalDateTime; +import java.time.Instant; import lombok.Getter; @Getter @@ -10,15 +10,15 @@ public abstract class BaseCreatedDeletedEntity extends BaseCreatedEntity { @Column(name = "deleted_at") - protected LocalDateTime deletedAt; + protected Instant deletedAt; public void softDelete() { if (this.deletedAt == null) { - this.deletedAt = LocalDateTime.now(); + this.deletedAt = Instant.now(); } } public boolean isDeleted() { return this.deletedAt != null; } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/entity/BaseCreatedEntity.java b/src/main/java/com/mr/global/entity/BaseCreatedEntity.java index 2d175af9..7b9411e7 100644 --- a/src/main/java/com/mr/global/entity/BaseCreatedEntity.java +++ b/src/main/java/com/mr/global/entity/BaseCreatedEntity.java @@ -7,7 +7,7 @@ import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import java.time.LocalDateTime; +import java.time.Instant; @Getter @MappedSuperclass @@ -16,5 +16,5 @@ public abstract class BaseCreatedEntity { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) - protected LocalDateTime createdAt; -} + protected Instant createdAt; +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/entity/BaseTimeDeletedEntity.java b/src/main/java/com/mr/global/entity/BaseTimeDeletedEntity.java index 1ac6c810..e5743555 100644 --- a/src/main/java/com/mr/global/entity/BaseTimeDeletedEntity.java +++ b/src/main/java/com/mr/global/entity/BaseTimeDeletedEntity.java @@ -2,7 +2,7 @@ import jakarta.persistence.Column; import jakarta.persistence.MappedSuperclass; -import java.time.LocalDateTime; +import java.time.Instant; import lombok.Getter; @Getter @@ -10,15 +10,15 @@ public abstract class BaseTimeDeletedEntity extends BaseTimeEntity { @Column(name = "deleted_at") - protected LocalDateTime deletedAt; + protected Instant deletedAt; public void softDelete() { if (this.deletedAt == null) { - this.deletedAt = LocalDateTime.now(); + this.deletedAt = Instant.now(); } } public boolean isDeleted() { return this.deletedAt != null; } -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/entity/BaseTimeEntity.java b/src/main/java/com/mr/global/entity/BaseTimeEntity.java index 667aaa11..c9a931e6 100644 --- a/src/main/java/com/mr/global/entity/BaseTimeEntity.java +++ b/src/main/java/com/mr/global/entity/BaseTimeEntity.java @@ -2,7 +2,7 @@ import jakarta.persistence.Column; import jakarta.persistence.MappedSuperclass; -import java.time.LocalDateTime; +import java.time.Instant; import lombok.Getter; import org.springframework.data.annotation.LastModifiedDate; @@ -12,5 +12,5 @@ public abstract class BaseTimeEntity extends BaseCreatedEntity { @LastModifiedDate @Column(name = "updated_at") - protected LocalDateTime updatedAt; -} + protected Instant updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/file/s3/service/S3ObjectKeyGenerator.java b/src/main/java/com/mr/global/file/s3/service/S3ObjectKeyGenerator.java index dfb2c4a0..5af66bf6 100644 --- a/src/main/java/com/mr/global/file/s3/service/S3ObjectKeyGenerator.java +++ b/src/main/java/com/mr/global/file/s3/service/S3ObjectKeyGenerator.java @@ -7,7 +7,7 @@ import com.mr.global.file.s3.util.ContentTypeUtils; import org.springframework.stereotype.Component; -import java.time.LocalDateTime; +import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Locale; @@ -18,10 +18,10 @@ public class S3ObjectKeyGenerator { private static final ZoneId KOREA_ZONE_ID = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter DATE_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd"); + DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(KOREA_ZONE_ID); private static final DateTimeFormatter TIME_FORMATTER = - DateTimeFormatter.ofPattern("HHmmss"); + DateTimeFormatter.ofPattern("HHmmss").withZone(KOREA_ZONE_ID); private static final int UUID_LENGTH = 6; @@ -43,13 +43,13 @@ public String generate( String extension = resolveExtension(originalFileName, contentType); - LocalDateTime now = - LocalDateTime.now(KOREA_ZONE_ID); + Instant now = + Instant.now(); String generatedFileName = "%s_%s.%s".formatted( - now.format(TIME_FORMATTER), + TIME_FORMATTER.format(now), createShortUuid(), extension ); @@ -57,7 +57,7 @@ public String generate( return "%s/%d/%s/%s".formatted( fileType.getPrefix(), userId, - now.format(DATE_FORMATTER), + DATE_FORMATTER.format(now), generatedFileName ); } diff --git a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java index 93023ec4..48933502 100644 --- a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java @@ -23,7 +23,7 @@ import java.security.Key; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.Date; import java.util.UUID; @@ -154,8 +154,8 @@ public String hashToken(String token) { } } - public LocalDateTime getRefreshTokenExpiryTime() { - return LocalDateTime.now() + public Instant getRefreshTokenExpiryTime() { + return Instant.now() .plusSeconds(jwtProperties.refreshTokenValidityInSeconds()); } diff --git a/src/main/java/com/mr/global/util/RelativeDateFormatter.java b/src/main/java/com/mr/global/util/RelativeDateFormatter.java index 7a9e787a..abc7e8b2 100644 --- a/src/main/java/com/mr/global/util/RelativeDateFormatter.java +++ b/src/main/java/com/mr/global/util/RelativeDateFormatter.java @@ -1,21 +1,28 @@ package com.mr.global.util; +import java.time.Instant; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; public final class RelativeDateFormatter { + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); // 항상 KST(한국 시간)를 기준으로 계산하도록 고정 + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("M월 d일"); + private RelativeDateFormatter() { } - public static String format(LocalDateTime dateTime) { + public static String format(Instant dateTime) { if (dateTime == null) { return null; } - long daysBetween = ChronoUnit.DAYS.between(dateTime.toLocalDate(), LocalDate.now()); + LocalDate targetDate = dateTime.atZone(KST_ZONE).toLocalDate(); // Instant를 KST 기준의 달력 날짜(LocalDate)로 변환 + LocalDate today = LocalDate.now(KST_ZONE); + + long daysBetween = ChronoUnit.DAYS.between(targetDate, today); if (daysBetween <= 0) { return "오늘"; @@ -23,6 +30,7 @@ public static String format(LocalDateTime dateTime) { if (daysBetween == 1) { return "어제"; } - return dateTime.format(DateTimeFormatter.ofPattern("M월 d일")); + + return targetDate.format(DATE_FORMATTER); } -} +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5e1d8634..f3238ae9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,23 +6,20 @@ spring: application: name: musereview-backend - # 공통 데이터베이스 접속 규칙 (실제 정보는 각자 .env나 application-prod에서 오버라이딩) + flyway: + enabled: false + + # 공통 데이터베이스 접속 규칙 datasource: driver-class-name: org.postgresql.Driver - url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/mr_db} + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5433/mr_db} username: ${SPRING_DATASOURCE_USERNAME:postgres} password: ${SPRING_DATASOURCE_PASSWORD:} - hikari: - maximum-pool-size: 10 - connection-timeout: 30000 - leak-detection-threshold: 20000 - - # 공통 JPA 및 하이버네이트 구동 설정 jpa: hibernate: - ddl-auto: ${SPRING_JPA_DDL_AUTO:validate} + ddl-auto: ${SPRING_JPA_DDL_AUTO:update} show-sql: true properties: hibernate: @@ -35,81 +32,43 @@ spring: max-file-size: 20MB max-request-size: 20MB - data: - redis: - host: ${SPRING_DATA_REDIS_HOST:localhost} - port: ${SPRING_DATA_REDIS_PORT:6379} - connect-timeout: ${SPRING_DATA_REDIS_CONNECT_TIMEOUT:2s} - timeout: ${SPRING_DATA_REDIS_TIMEOUT:2s} - - flyway: - enabled: false - -management: - endpoints: - web: - exposure: - include: health,info,metrics - # Spring Security 및 JWT 인증 설정 jwt: secret: ${JWT_SECRET_KEY} access-token-validity-in-seconds: 1800 # Access Token 만료 시간 (30분) refresh-token-validity-in-seconds: 604800 # Refresh Token 만료 시간 (7일) -# CORS 설정 -cors: - allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,https://www.musereview.site} - # 소셜 로그인 API 연동 oauth: - frontend-redirect-uri: ${OAUTH_FRONTEND_REDIRECT_URI:https://www.musereview.site/oauth/callback} - frontend-redirect-uris: - - http://localhost:5173/oauth/callback - - https://www.musereview.site/oauth/callback - - temp-code-ttl: ${OAUTH_TEMP_CODE_TTL:2m} - cookie: - secure: ${OAUTH_COOKIE_SECURE:false} - kakao: client-id: ${KAKAO_CLIENT_ID:} client-secret: ${KAKAO_CLIENT_SECRET:} + redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:8080/login/oauth2/code/kakao} google: client-id: ${GOOGLE_CLIENT_ID:} client-secret: ${GOOGLE_CLIENT_SECRET:} + redirect-uri: ${GOOGLE_REDIRECT_URI:http://localhost:8080/login/oauth2/code/google} # AI 도입 및 AWS S3 공통 구조 external: ai: - base-url: ${AI_BASE_URL:https://generativelanguage.googleapis.com} - api-key: ${GEMINI_API_KEY:${AI_API_KEY:}} - model: ${AI_MODEL:gemini-3-flash-preview} - connect-timeout: ${AI_CONNECT_TIMEOUT:5s} - read-timeout: ${AI_READ_TIMEOUT:120s} + base-url: ${AI_BASE_URL:} + api-key: ${AI_API_KEY:} + model: ${AI_MODEL:} aws: s3: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} bucket: ${AWS_S3_BUCKET:} region: ${AWS_REGION:ap-northeast-2} - presigned-url-expiration: 10m - max-file-size: 31457280 # 30MB - key-prefix: recordings - allowed-content-types: - - audio/mpeg - - audio/wav - - audio/x-wav - - audio/webm - - audio/ogg - + credentials: + access-key: ${AWS_S3_ACCESS_KEY:dummy-access} + secret-key: ${AWS_S3_SECRET_KEY:dummy-secret} -# 내부 AI 분석 서버 설정 +# 팀원 PR로 추가된 내부 AI 분석 서버 설정 (바인딩 에러 방지용 기본값 포함) ai: internal: + base-url: ${AI_INTERNAL_BASE_URL:http://localhost:8000} connect-timeout: ${AI_INTERNAL_CONNECT_TIMEOUT:5s} read-timeout: ${AI_INTERNAL_READ_TIMEOUT:60s} endpoints: @@ -118,4 +77,4 @@ ai: # 프로필 관련 공통 설정 app: profile: - default-image-url: ${DEFAULT_PROFILE_IMAGE_URL:https://musereview-storage-526426842030-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/profile/default-profile.png} + default-image-url: ${DEFAULT_PROFILE_IMAGE_URL:} \ No newline at end of file diff --git a/src/main/resources/db/migration/V8__alter_analysis_time_columns_to_timestamptz.sql b/src/main/resources/db/migration/V8__alter_analysis_time_columns_to_timestamptz.sql new file mode 100644 index 00000000..0bda13b9 --- /dev/null +++ b/src/main/resources/db/migration/V8__alter_analysis_time_columns_to_timestamptz.sql @@ -0,0 +1,3 @@ +ALTER TABLE analysis +ALTER COLUMN processing_started_at TYPE TIMESTAMP WITH TIME ZONE USING processing_started_at AT TIME ZONE 'Asia/Seoul', +ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE USING created_at AT TIME ZONE 'Asia/Seoul'; diff --git a/src/test/java/com/mr/domain/analysis/controller/AnalysisControllerTest.java b/src/test/java/com/mr/domain/analysis/controller/AnalysisControllerTest.java index 9d054680..30f91886 100644 --- a/src/test/java/com/mr/domain/analysis/controller/AnalysisControllerTest.java +++ b/src/test/java/com/mr/domain/analysis/controller/AnalysisControllerTest.java @@ -22,7 +22,8 @@ import com.mr.global.apipayload.handler.GlobalExceptionHandler; import com.mr.global.security.principal.CustomUserDetails; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.Instant; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -75,7 +76,7 @@ void getAnalysisStatus_success() throws Exception { AnalysisStatus.PROCESSING, null, "연습 결과를 분석 중입니다.", - LocalDateTime.of(2026, 7, 24, 10, 0), + Instant.parse("2026-07-24T10:00:00Z"), null ); given(analysisService.getAnalysisStatus(anyLong(), anyLong())).willReturn(response); @@ -121,7 +122,7 @@ void getAnalysisResult_success() throws Exception { "jazz", "C Major", 120, - LocalDateTime.of(2026, 7, 24, 9, 0), + Instant.parse("2026-07-24T09:00:00Z"), "https://example.com/recording.webm", "https://example.com/backing-track.mp3", AnalysisStatus.COMPLETED, @@ -144,12 +145,12 @@ void getAnalysisResult_success() throws Exception { "리포트 본문", "gpt-4", "v1", - LocalDateTime.of(2026, 7, 24, 10, 0), - LocalDateTime.of(2026, 7, 24, 10, 0) + Instant.parse("2026-07-24T10:00:00Z"), + Instant.parse("2026-07-24T10:00:00Z") ), null, - LocalDateTime.of(2026, 7, 24, 9, 0), - LocalDateTime.of(2026, 7, 24, 10, 0) + Instant.parse("2026-07-24T09:00:00Z"), + Instant.parse("2026-07-24T10:00:00Z") ); given(analysisService.getAnalysisResult(anyLong(), anyLong())).willReturn(response); diff --git a/src/test/java/com/mr/domain/analysis/entity/AnalysisTest.java b/src/test/java/com/mr/domain/analysis/entity/AnalysisTest.java index d76685ff..c80b0bf3 100644 --- a/src/test/java/com/mr/domain/analysis/entity/AnalysisTest.java +++ b/src/test/java/com/mr/domain/analysis/entity/AnalysisTest.java @@ -10,13 +10,14 @@ import com.mr.domain.playing.entity.Playing; import com.mr.domain.user.entity.User; import com.mr.global.apipayload.exception.GeneralException; -import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.time.Instant; + class AnalysisTest { - private static final LocalDateTime NOW = LocalDateTime.of(2026, 7, 31, 12, 0); + private static final Instant NOW = Instant.parse("2026-07-31T12:00:00Z"); @Test @DisplayName("createPending - user가 null이면 예외가 발생한다") @@ -82,9 +83,9 @@ void restartProcessing_renewsProcessingStartedAt() { Playing playing = mock(Playing.class); given(playing.getUser()).willReturn(user); Analysis analysis = Analysis.createPending(user, playing, 1, 8, "{}"); - LocalDateTime firstAttempt = analysis.startProcessing(NOW); + Instant firstAttempt = analysis.startProcessing(NOW); - LocalDateTime secondAttempt = analysis.restartProcessing(NOW); + Instant secondAttempt = analysis.restartProcessing(NOW); assertThat(secondAttempt).isAfter(firstAttempt); assertThat(analysis.isCurrentProcessing(firstAttempt)).isFalse(); diff --git a/src/test/java/com/mr/domain/analysis/service/AnalysisProcessingServiceTest.java b/src/test/java/com/mr/domain/analysis/service/AnalysisProcessingServiceTest.java index 82f1c894..867e3880 100644 --- a/src/test/java/com/mr/domain/analysis/service/AnalysisProcessingServiceTest.java +++ b/src/test/java/com/mr/domain/analysis/service/AnalysisProcessingServiceTest.java @@ -18,7 +18,8 @@ import com.mr.global.client.ai.AiAnalysisRequest; import com.mr.global.client.ai.AiServerClient; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -52,7 +53,7 @@ void process_doesNotCallAiWhenAnotherWorkerAlreadyClaimedAnalysis() { @Test void recoverStaleProcessing_doesNotCallAiWhenWorkIsNoLongerStale() { - LocalDateTime cutoff = LocalDateTime.now().minusMinutes(2); + Instant cutoff = Instant.now().minus(2, ChronoUnit.MINUTES); given(analysisStateService.restartStaleProcessing(1L, cutoff)).willReturn(Optional.empty()); AnalysisProcessingService service = new AnalysisProcessingService( analysisStateService, aiServerClient, reportGenerationService, @@ -68,7 +69,7 @@ void recoverStaleProcessing_doesNotCallAiWhenWorkIsNoLongerStale() { void process_doesNotGenerateReportWhenAiResultIsInvalid() throws Exception { ObjectMapper objectMapper = new ObjectMapper(); JsonNode invalidResult = objectMapper.readTree("{}"); - LocalDateTime processingStartedAt = LocalDateTime.now(); + Instant processingStartedAt = Instant.now(); given(analysisStateService.startProcessing(1L)) .willReturn(Optional.of(new AnalysisProcessingClaim( "{\"meta\":null,\"chords\":[],\"notes\":[]}", @@ -95,7 +96,7 @@ void process_doesNotGenerateReportWhenAiResultIsInvalid() throws Exception { @Test void process_marksMalformedRequestAsFailedWithoutCallingAi() { - LocalDateTime processingStartedAt = LocalDateTime.now(); + Instant processingStartedAt = Instant.now(); given(analysisStateService.startProcessing(1L)) .willReturn(Optional.of(new AnalysisProcessingClaim("{invalid", processingStartedAt))); AnalysisProcessingService service = new AnalysisProcessingService( @@ -116,7 +117,7 @@ void process_marksMalformedRequestAsFailedWithoutCallingAi() { @Test void process_passesGeneratedReportToFencedCompletion() throws Exception { ObjectMapper objectMapper = new ObjectMapper(); - LocalDateTime processingStartedAt = LocalDateTime.now(); + Instant processingStartedAt = Instant.now(); JsonNode validResult = objectMapper.readTree(""" { "scores": { diff --git a/src/test/java/com/mr/domain/analysis/service/AnalysisRecoverySchedulerTest.java b/src/test/java/com/mr/domain/analysis/service/AnalysisRecoverySchedulerTest.java index 39ecd899..aa9d8fa1 100644 --- a/src/test/java/com/mr/domain/analysis/service/AnalysisRecoverySchedulerTest.java +++ b/src/test/java/com/mr/domain/analysis/service/AnalysisRecoverySchedulerTest.java @@ -10,7 +10,6 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; -import java.time.LocalDateTime; import java.time.ZoneId; import java.util.List; @@ -82,7 +81,7 @@ void recoverPendingAnalyses_resubmitsOnlyPersistedStaleProcessingWork() { verify(analysisProcessingService).recoverStaleProcessing( 21L, - LocalDateTime.of(2026, 7, 31, 11, 58) + Instant.parse("2026-07-31T02:58:00Z") ); } } diff --git a/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java index d06f4dfc..a3a4ce0d 100644 --- a/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java +++ b/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java @@ -36,7 +36,7 @@ import com.mr.global.file.s3.service.S3FileService; import com.mr.global.client.ai.AiAnalysisRequest; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Locale; import java.util.Optional; @@ -106,7 +106,7 @@ private Analysis completedAnalysis(Long userId, ScaleType scaleType) { lenient().when(backingTrack.getAudioObjectKey()).thenReturn(BACKING_TRACK_OBJECT_KEY); Analysis analysis = Analysis.createPending(user, playing, 1, 8, "{}"); - LocalDateTime now = LocalDateTime.of(2026, 7, 31, 12, 0); + Instant now = Instant.parse("2026-07-31T12:00:00Z"); analysis.startProcessing(now); analysis.complete( 85, @@ -182,7 +182,7 @@ void getAnalysisResult_withoutBackingTrack_returnsNullAudioUrls() { given(playing.getId()).willReturn(1L); Analysis analysis = Analysis.createPending(user, playing, 1, 8, "{}"); - LocalDateTime now = LocalDateTime.of(2026, 7, 31, 12, 0); + Instant now = Instant.parse("2026-07-31T12:00:00Z"); analysis.startProcessing(now); analysis.complete(85, AnalysisGrade.GOOD, "테스트 요약", null, null, null, null, null, now); given(analysisRepository.findById(1L)).willReturn(Optional.of(analysis)); diff --git a/src/test/java/com/mr/domain/analysis/service/AnalysisStateServiceTest.java b/src/test/java/com/mr/domain/analysis/service/AnalysisStateServiceTest.java index 42087627..be00d8b1 100644 --- a/src/test/java/com/mr/domain/analysis/service/AnalysisStateServiceTest.java +++ b/src/test/java/com/mr/domain/analysis/service/AnalysisStateServiceTest.java @@ -27,8 +27,8 @@ import java.math.BigDecimal; import java.time.Clock; import java.time.Instant; -import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.temporal.ChronoUnit; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -41,7 +41,7 @@ @ExtendWith(MockitoExtension.class) class AnalysisStateServiceTest { - private static final LocalDateTime PROCESSING_STARTED_AT = LocalDateTime.of(2026, 7, 31, 12, 0); + private static final Instant PROCESSING_STARTED_AT = Instant.parse("2026-07-31T03:00:00Z"); private static final Clock FIXED_CLOCK = Clock.fixed( Instant.parse("2026-07-31T03:00:00Z"), ZoneId.of("Asia/Seoul") @@ -100,9 +100,9 @@ void startProcessing_marksBlankRequestAsFailed() { @Test void restartStaleProcessing_marksNullRequestAsFailed() { - LocalDateTime cutoff = LocalDateTime.now(); + Instant cutoff = Instant.now(); given(analysis.getStatus()).willReturn(AnalysisStatus.PROCESSING); - given(analysis.getProcessingStartedAt()).willReturn(cutoff.minusMinutes(1)); + given(analysis.getProcessingStartedAt()).willReturn(cutoff.minus(1, ChronoUnit.MINUTES)); given(analysis.getAnalysisRequestJson()).willReturn(null); Optional claim = service.restartStaleProcessing(1L, cutoff); diff --git a/src/test/java/com/mr/domain/auth/service/AuthTransactionServiceTest.java b/src/test/java/com/mr/domain/auth/service/AuthTransactionServiceTest.java index 86a69695..1149a994 100644 --- a/src/test/java/com/mr/domain/auth/service/AuthTransactionServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/AuthTransactionServiceTest.java @@ -18,7 +18,8 @@ import com.mr.domain.user.repository.UserRepository; import com.mr.global.security.jwt.JwtTokenProvider; import java.sql.SQLException; -import java.time.LocalDateTime; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Optional; import org.hibernate.exception.ConstraintViolationException; import org.junit.jupiter.api.DisplayName; @@ -56,7 +57,7 @@ void completeTokenExchange_socialAuthUniqueViolation_propagatesWithoutRetryingQu given(tokenProvider.createAccessToken(USER_ID)).willReturn("access-token"); given(tokenProvider.createRefreshToken(USER_ID)).willReturn("refresh-token"); given(tokenProvider.hashToken("refresh-token")).willReturn("hashed-refresh-token"); - given(tokenProvider.getRefreshTokenExpiryTime()).willReturn(LocalDateTime.now().plusDays(7)); + given(tokenProvider.getRefreshTokenExpiryTime()).willReturn(Instant.now().plus(7, ChronoUnit.DAYS)); // 최초 조회 시점엔 없다고 판단해 신규 생성 분기로 들어가지만, // 동시에 들어온 다른 요청이 먼저 저장을 마쳐서 실제 저장 시점엔 유니크 제약 위반이 남 @@ -87,7 +88,7 @@ void completeTokenExchange_socialAuthCreatedAfterPrepare_usesExistingUser() { given(tokenProvider.createAccessToken(USER_ID)).willReturn("access-token"); given(tokenProvider.createRefreshToken(USER_ID)).willReturn("refresh-token"); given(tokenProvider.hashToken("refresh-token")).willReturn("hashed-refresh-token"); - given(tokenProvider.getRefreshTokenExpiryTime()).willReturn(LocalDateTime.now().plusDays(7)); + given(tokenProvider.getRefreshTokenExpiryTime()).willReturn(Instant.now().plus(7, ChronoUnit.DAYS)); given(tokenProvider.getAccessTokenExpirationSeconds()).willReturn(1800L); AuthResponseDTO.LoginResponse response = authTransactionService.completeTokenExchange( @@ -103,7 +104,7 @@ void completeTokenExchange_socialAuthCreatedAfterPrepare_usesExistingUser() { verify(userRepository, never()).save(any()); verify(existingSocialAuth).updateRefreshToken( org.mockito.ArgumentMatchers.eq("hashed-refresh-token"), - any(LocalDateTime.class), + any(Instant.class), org.mockito.ArgumentMatchers.eq("device") ); } diff --git a/src/test/java/com/mr/domain/backingtrack/controller/BackingTrackControllerTest.java b/src/test/java/com/mr/domain/backingtrack/controller/BackingTrackControllerTest.java index 0602be78..a5c42349 100644 --- a/src/test/java/com/mr/domain/backingtrack/controller/BackingTrackControllerTest.java +++ b/src/test/java/com/mr/domain/backingtrack/controller/BackingTrackControllerTest.java @@ -17,7 +17,7 @@ import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.security.principal.CustomUserDetails; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import org.junit.jupiter.api.AfterEach; @@ -93,7 +93,7 @@ void createBackingTrack_success() throws Exception { BackingTrackCreateResponseDTO.CreateResultDTO response = BackingTrackCreateResponseDTO.CreateResultDTO.of( - BACKING_TRACK_ID, "테스트 트랙", LocalDateTime.now() + BACKING_TRACK_ID, "테스트 트랙", Instant.now() ); given(backingTrackService.createBackingTrack(anyLong(), any())) @@ -217,7 +217,7 @@ void updateBackingTrack_success() throws Exception { BackingTrackUpdateResponseDTO.UpdateResultDTO response = BackingTrackUpdateResponseDTO.UpdateResultDTO.of( - BACKING_TRACK_ID, "테스트 트랙", LocalDateTime.now() + BACKING_TRACK_ID, "테스트 트랙", Instant.now() ); given(backingTrackService.updateBackingTrack(anyLong(), anyLong(), any())) diff --git a/src/test/java/com/mr/domain/history/controller/HistoryControllerTest.java b/src/test/java/com/mr/domain/history/controller/HistoryControllerTest.java index eec37649..54040b43 100644 --- a/src/test/java/com/mr/domain/history/controller/HistoryControllerTest.java +++ b/src/test/java/com/mr/domain/history/controller/HistoryControllerTest.java @@ -18,7 +18,8 @@ import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.apipayload.handler.GlobalExceptionHandler; import com.mr.global.security.principal.CustomUserDetails; -import java.time.LocalDateTime; + +import java.time.Instant; import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -119,7 +120,7 @@ void getHistoryDetail_invalidPlayingId_returns400() throws Exception { void getHistoryDetail_success() throws Exception { HistoryDetailResponseDTO response = new HistoryDetailResponseDTO( 1L, "제목", "장르", "C Major", 120, "4/4", - LocalDateTime.of(2026, 7, 24, 10, 0), 5, 300, + Instant.parse("2026-07-24T10:00:00Z"), 5, 300, "https://example.com/recording.webm", "https://example.com/backing-track.mp3", List.of(), null, null, List.of() ); diff --git a/src/test/java/com/mr/domain/history/service/HistoryServiceTest.java b/src/test/java/com/mr/domain/history/service/HistoryServiceTest.java index 2b7db8e7..2b357bd3 100644 --- a/src/test/java/com/mr/domain/history/service/HistoryServiceTest.java +++ b/src/test/java/com/mr/domain/history/service/HistoryServiceTest.java @@ -29,7 +29,9 @@ import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.file.s3.enums.S3FileType; import com.mr.global.file.s3.service.S3FileService; -import java.time.LocalDateTime; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -63,7 +65,7 @@ void setUp() { playingRepository, analysisRepository, s3FileService, new AnalysisBarCalculator()); } - private Playing mockPlaying(Long playingId, Long userId, PlayingStatus status, LocalDateTime endedAt) { + private Playing mockPlaying(Long playingId, Long userId, PlayingStatus status, Instant endedAt) { User user = mock(User.class); lenient().when(user.getUserId()).thenReturn(userId); @@ -85,7 +87,7 @@ private Analysis completedAnalysis(Long playingId, Integer totalScore) { lenient().when(playing.getUser()).thenReturn(user); Analysis analysis = Analysis.createPending(user, playing, 1, 8, "{}"); - LocalDateTime now = LocalDateTime.of(2026, 7, 31, 12, 0); + Instant now = Instant.parse("2026-07-31T12:00:00Z"); analysis.startProcessing(now); analysis.complete(totalScore, AnalysisGrade.GOOD, "요약", null, null, null, null, null, now); return analysis; @@ -107,7 +109,7 @@ void getHistories_empty_returnsEmptyList() { @Test @DisplayName("getHistories - 최신 COMPLETED 분석이 없는 Playing은 latestAnalysisId가 null이다") void getHistories_noCompletedAnalysis_latestAnalysisIdIsNull() { - Playing playing = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, LocalDateTime.now()); + Playing playing = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, Instant.now()); given(playingRepository.findPlayingsByUserAndStatus(eq(1L), eq(PlayingStatus.COMPLETED), any())) .willReturn(new SliceImpl<>(List.of(playing), PageRequest.of(0, 10), false)); given(analysisRepository.findByPlayingIdInAndStatusOrderByCreatedAtDescIdDesc( @@ -124,8 +126,8 @@ void getHistories_noCompletedAnalysis_latestAnalysisIdIsNull() { @Test @DisplayName("getHistories - 마지막 페이지에서는 인접 항목과 점수 차이를 계산하고 마지막 항목은 null이다") void getHistories_scoreChange_comparesAdjacentItemsOnLastPage() { - Playing playing1 = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, LocalDateTime.now()); - Playing playing2 = mockPlaying(2L, 1L, PlayingStatus.COMPLETED, LocalDateTime.now().minusDays(1)); + Playing playing1 = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, Instant.now()); + Playing playing2 = mockPlaying(2L, 1L, PlayingStatus.COMPLETED, Instant.now().minus(1, ChronoUnit.DAYS)); given(playingRepository.findPlayingsByUserAndStatus(eq(1L), eq(PlayingStatus.COMPLETED), any())) .willReturn(new SliceImpl<>(List.of(playing1, playing2), PageRequest.of(0, 10), false)); @@ -146,8 +148,8 @@ void getHistories_scoreChange_comparesAdjacentItemsOnLastPage() { @Test @DisplayName("getHistories - 다음 페이지가 있으면 현재 페이지 마지막 항목도 다음 연주와 점수 차이를 계산한다") void getHistories_scoreChange_comparesLastItemWithNextPage() { - LocalDateTime firstEndedAt = LocalDateTime.of(2026, 7, 31, 12, 0); - LocalDateTime secondEndedAt = firstEndedAt.minusDays(1); + Instant firstEndedAt = Instant.parse("2026-07-31T12:00:00Z"); + Instant secondEndedAt = firstEndedAt.minus(1, ChronoUnit.DAYS); Playing playing1 = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, firstEndedAt); Playing playing2 = mockPlaying(2L, 1L, PlayingStatus.COMPLETED, secondEndedAt); given(playingRepository.findPlayingsByUserAndStatus( @@ -177,14 +179,14 @@ void getHistories_scoreChange_comparesLastItemWithNextPage() { @Test @DisplayName("기간 필터가 있으면 cutoff 전용 쿼리로 다음 연주를 조회한다") void getHistories_scoreChange_usesSinceQueryWithPeriod() { - LocalDateTime endedAt = LocalDateTime.of(2026, 7, 31, 12, 0); + Instant endedAt = Instant.parse("2026-07-31T12:00:00Z"); Playing playing = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, endedAt); given(playingRepository.findPlayingsByUserAndStatusSince( - eq(1L), eq(PlayingStatus.COMPLETED), any(LocalDateTime.class), + eq(1L), eq(PlayingStatus.COMPLETED), any(Instant.class), eq(PageRequest.of(0, 1)))) .willReturn(new SliceImpl<>(List.of(playing), PageRequest.of(0, 1), true)); given(playingRepository.findNextPlayingIdSince( - eq(1L), eq(PlayingStatus.COMPLETED), any(LocalDateTime.class), + eq(1L), eq(PlayingStatus.COMPLETED), any(Instant.class), eq(endedAt), eq(1L), eq(PageRequest.of(0, 1)))) .willReturn(List.of()); given(analysisRepository.findByPlayingIdInAndStatusOrderByCreatedAtDescIdDesc( @@ -200,14 +202,14 @@ void getHistories_scoreChange_usesSinceQueryWithPeriod() { @DisplayName("getHistories - 기간 필터가 있으면 cutoff 전용 쿼리를 사용한다") void getHistories_withPeriod_usesSinceQuery() { given(playingRepository.findPlayingsByUserAndStatusSince( - eq(1L), eq(PlayingStatus.COMPLETED), any(LocalDateTime.class), eq(PageRequest.of(0, 10)))) + eq(1L), eq(PlayingStatus.COMPLETED), any(Instant.class), eq(PageRequest.of(0, 10)))) .willReturn(new SliceImpl<>(List.of(), PageRequest.of(0, 10), false)); HistoryListResponseDTO response = historyService.getHistories(1L, 0, 10, HistoryPeriod.WEEKLY); assertThat(response.items()).isEmpty(); verify(playingRepository).findPlayingsByUserAndStatusSince( - eq(1L), eq(PlayingStatus.COMPLETED), any(LocalDateTime.class), eq(PageRequest.of(0, 10))); + eq(1L), eq(PlayingStatus.COMPLETED), any(Instant.class), eq(PageRequest.of(0, 10))); } @Test @@ -252,7 +254,7 @@ void getHistoryDetail_invalidPlayingId_throws400(long playingId) { @Test @DisplayName("getHistoryDetail - 다른 사용자의 기록이면 403이고 Analysis는 조회하지 않는다") void getHistoryDetail_otherUsersPlaying_throws403() { - Playing playing = mockPlaying(1L, 2L, PlayingStatus.COMPLETED, LocalDateTime.now()); + Playing playing = mockPlaying(1L, 2L, PlayingStatus.COMPLETED, Instant.now()); given(playingRepository.findByIdWithBackingTrack(1L)).willReturn(Optional.of(playing)); assertThatThrownBy(() -> historyService.getHistoryDetail(1L, 1L)) @@ -276,7 +278,7 @@ void getHistoryDetail_notCompleted_throws409() { @Test @DisplayName("getHistoryDetail - 정상 조회 시 상태 무관하게 모든 분석을 반환한다") void getHistoryDetail_success_returnsAllAnalysesRegardlessOfStatus() { - Playing playing = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, LocalDateTime.now()); + Playing playing = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, Instant.now()); User backingTrackOwner = mock(User.class); BackingTrack backingTrack = mock(BackingTrack.class); String recordingObjectKey = "recordings/1/2026-08-06/test.webm"; @@ -316,7 +318,7 @@ void getHistoryDetail_success_returnsAllAnalysesRegardlessOfStatus() { @Test @DisplayName("getHistoryDetail - BPM/박자/재생시간이 있으면 totalBars와 구간별 estimatedSeconds를 계산한다") void getHistoryDetail_success_calculatesBarMetrics() { - Playing playing = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, LocalDateTime.now()); + Playing playing = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, Instant.now()); BackingTrack backingTrack = mock(BackingTrack.class); given(playing.getBackingTrack()).willReturn(backingTrack); given(backingTrack.getTimeSignature()).willReturn("4/4"); @@ -337,7 +339,7 @@ void getHistoryDetail_success_calculatesBarMetrics() { @Test @DisplayName("getHistoryDetail - 마디 계산 정보가 없으면 조회는 성공하고 마디 관련 필드만 null이다") void getHistoryDetail_missingBarMetrics_returnsNullBarFields() { - Playing playing = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, LocalDateTime.now()); + Playing playing = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, Instant.now()); BackingTrack backingTrack = mock(BackingTrack.class); given(playing.getBackingTrack()).willReturn(backingTrack); given(backingTrack.getPlaytimeSec()).willReturn(null); diff --git a/src/test/java/com/mr/domain/home/service/HomeServiceTest.java b/src/test/java/com/mr/domain/home/service/HomeServiceTest.java index 3b58cc27..f6cc2a37 100644 --- a/src/test/java/com/mr/domain/home/service/HomeServiceTest.java +++ b/src/test/java/com/mr/domain/home/service/HomeServiceTest.java @@ -30,8 +30,10 @@ import com.mr.global.apipayload.exception.GeneralException; import java.sql.Date; import java.time.DayOfWeek; +import java.time.Instant; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -81,7 +83,7 @@ private void stubBaseline(Long userId) { lenient().when(learningService.getCurrentLearning(anyLong())).thenReturn(null); } - private Playing mockPlaying(LocalDateTime endedAt, Integer durationSec) { + private Playing mockPlaying(Instant endedAt, Integer durationSec) { Playing playing = mock(Playing.class); lenient().when(playing.getId()).thenReturn(1L); lenient().when(playing.getEndedAt()).thenReturn(endedAt); @@ -91,8 +93,8 @@ private Playing mockPlaying(LocalDateTime endedAt, Integer durationSec) { return playing; } - private Date sqlDate(LocalDate date) { - return Date.valueOf(date); + private Instant toInstant(LocalDate date) { + return date.atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant(); } @Test @@ -143,9 +145,9 @@ void getHome_noStudent_skillLevelAndInstrumentAreNull() { void getHome_threeConsecutiveDaysIncludingToday_currentDaysIsThree() { stubBaseline(1L); - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); given(playingRepository.findDistinctEndedDatesByUserAndStatus(1L, PlayingStatus.COMPLETED)).willReturn(List.of( - sqlDate(today), sqlDate(today.minusDays(1)), sqlDate(today.minusDays(2)), sqlDate(today.minusDays(5)) + toInstant(today), toInstant(today.minusDays(1)), toInstant(today.minusDays(2)), toInstant(today.minusDays(5)) )); HomeResponseDTO response = homeService.getHome(1L); @@ -158,10 +160,10 @@ void getHome_threeConsecutiveDaysIncludingToday_currentDaysIsThree() { void getHome_longStreak_notCappedByLookbackWindow() { stubBaseline(1L); - LocalDate today = LocalDate.now(); - List endedDates = java.util.stream.IntStream.range(0, 65) + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + List endedDates = java.util.stream.IntStream.range(0, 65) .mapToObj(today::minusDays) - .map(this::sqlDate) + .map(this::toInstant) .toList(); given(playingRepository.findDistinctEndedDatesByUserAndStatus(1L, PlayingStatus.COMPLETED)).willReturn(endedDates); @@ -177,7 +179,7 @@ void getHome_noPracticeToday_countsFromYesterday() { LocalDate yesterday = LocalDate.now().minusDays(1); given(playingRepository.findDistinctEndedDatesByUserAndStatus(1L, PlayingStatus.COMPLETED)) - .willReturn(List.of(sqlDate(yesterday))); + .willReturn(List.of(toInstant(yesterday))); HomeResponseDTO response = homeService.getHome(1L); @@ -191,7 +193,7 @@ void getHome_duplicateDateFromRepository_countsOnce() { LocalDate today = LocalDate.now(); given(playingRepository.findDistinctEndedDatesByUserAndStatus(1L, PlayingStatus.COMPLETED)) - .willReturn(List.of(sqlDate(today), sqlDate(today), sqlDate(today))); + .willReturn(List.of(toInstant(today), toInstant(today), toInstant(today))); HomeResponseDTO response = homeService.getHome(1L); @@ -225,10 +227,11 @@ void getHome_weeklyAttendance_hasSevenDaysAndFutureDaysAreEmpty() { void getHome_practiceSummary_sumsDurationInHours() { stubBaseline(1L); - LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + ZoneId kstZone = ZoneId.of("Asia/Seoul"); + Instant todayStart = LocalDate.now(kstZone).atStartOfDay(kstZone).toInstant(); List recent = List.of( mockPlaying(todayStart, 3600), - mockPlaying(todayStart.plusHours(2), 3600) + mockPlaying(todayStart.plus(2, ChronoUnit.HOURS), 3600) ); given(playingRepository.findByUserAndStatusSince(anyLong(), any(), any())).willReturn(recent); @@ -243,10 +246,11 @@ void getHome_practiceSummary_sumsDurationInHours() { void getHome_practiceSummary_excludesRecordsBeforeWeekStart() { stubBaseline(1L); - LocalDateTime weekStart = LocalDate.now().with(DayOfWeek.MONDAY).atStartOfDay(); + ZoneId kstZone = ZoneId.of("Asia/Seoul"); + Instant weekStart = LocalDate.now(kstZone).with(DayOfWeek.MONDAY).atStartOfDay(kstZone).toInstant(); List recent = List.of( mockPlaying(weekStart, 3600), - mockPlaying(weekStart.minusSeconds(1), 3600) + mockPlaying(weekStart.minus(1, ChronoUnit.SECONDS), 3600) ); given(playingRepository.findByUserAndStatusSince(anyLong(), any(), any())).willReturn(recent); @@ -341,7 +345,7 @@ void getHome_noRecommendedLearnings_returnsEmptyList() { void getHome_recentPlayings_mapsFromRepository() { stubBaseline(1L); - Playing playing = mockPlaying(LocalDateTime.now(), 600); + Playing playing = mockPlaying(Instant.now(), 600); given(playingRepository.findPlayingsByUserAndStatus(1L, PlayingStatus.COMPLETED, PageRequest.of(0, 5))) .willReturn(new SliceImpl<>(List.of(playing))); diff --git a/src/test/java/com/mr/domain/mentor/controller/MentorControllerTest.java b/src/test/java/com/mr/domain/mentor/controller/MentorControllerTest.java index 06fa082e..e255c14a 100644 --- a/src/test/java/com/mr/domain/mentor/controller/MentorControllerTest.java +++ b/src/test/java/com/mr/domain/mentor/controller/MentorControllerTest.java @@ -22,7 +22,8 @@ import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.apipayload.handler.GlobalExceptionHandler; import com.mr.global.security.principal.CustomUserDetails; -import java.time.LocalDateTime; + +import java.time.Instant; import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -89,7 +90,7 @@ void getMessageHistory_success() throws Exception { MessageRole.ASSISTANT, references, "텐션음을 자주 사용했어요.", - LocalDateTime.of(2026, 7, 1, 14, 35) + Instant.parse("2026-07-01T14:35:00Z") )) ); given(mentorService.getMessageHistory(anyLong(), anyLong())).willReturn(response); diff --git a/src/test/java/com/mr/domain/mentor/service/MentorServiceTest.java b/src/test/java/com/mr/domain/mentor/service/MentorServiceTest.java index f9a78499..fc52e935 100644 --- a/src/test/java/com/mr/domain/mentor/service/MentorServiceTest.java +++ b/src/test/java/com/mr/domain/mentor/service/MentorServiceTest.java @@ -16,7 +16,8 @@ import com.mr.domain.mentor.exception.MentorErrorStatus; import com.mr.domain.mentor.repository.MentorMessageRepository; import com.mr.global.apipayload.exception.GeneralException; -import java.time.LocalDateTime; + +import java.time.Instant; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -63,7 +64,7 @@ void getMessageHistory_noSession_returnsEmptyMessages() { @DisplayName("대화 메시지와 판단 근거 JSON 반환") void getMessageHistory_success() { MentorMessage message = mock(MentorMessage.class); - LocalDateTime createdAt = LocalDateTime.of(2026, 7, 1, 14, 35); + Instant createdAt = Instant.parse("2026-07-01T14:35:00Z"); given(message.getId()).willReturn(30L); given(message.getRole()).willReturn(MessageRole.ASSISTANT); diff --git a/src/test/java/com/mr/domain/playing/entity/PlayingTest.java b/src/test/java/com/mr/domain/playing/entity/PlayingTest.java index e17f6723..1fa06545 100644 --- a/src/test/java/com/mr/domain/playing/entity/PlayingTest.java +++ b/src/test/java/com/mr/domain/playing/entity/PlayingTest.java @@ -12,7 +12,7 @@ import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -594,12 +594,12 @@ void startPlaying_success() { 120 ); - LocalDateTime beforeStart = LocalDateTime.now(); + Instant beforeStart = Instant.now(); // when playing.start(); - LocalDateTime afterStart = LocalDateTime.now(); + Instant afterStart = Instant.now(); // then assertThat(playing.getStatus()) @@ -734,7 +734,7 @@ private Playing createInProgressPlaying() { ReflectionTestUtils.setField( playing, "startedAt", - LocalDateTime.now().minusSeconds(1) + Instant.now().minusSeconds(1) ); return playing; diff --git a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java index 6d2a5788..d1792990 100644 --- a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java +++ b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java @@ -49,7 +49,6 @@ import java.time.DayOfWeek; import java.time.Instant; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.ZoneId; import java.util.List; import java.util.Map; @@ -593,23 +592,25 @@ private void stubCompletedPlayingForMilestoneCalculation() { .atStartOfDay(zoneId) .toInstant(); - when(clock.instant()) + // lenient() 를 붙여서 불필요한 스터빙 에러를 방지합니다. + org.mockito.Mockito.lenient().when(clock.instant()) .thenReturn(fixedInstant); - when(clock.getZone()) + org.mockito.Mockito.lenient().when(clock.getZone()) .thenReturn(zoneId); - when(playing.getStatus()) + org.mockito.Mockito.lenient().when(playing.getStatus()) .thenReturn(PlayingStatus.COMPLETED); - when(playing.getDurationSec()) + org.mockito.Mockito.lenient().when(playing.getDurationSec()) .thenReturn(300); - LocalDateTime weekStart = fixedDate + Instant weekStart = fixedDate .with(DayOfWeek.MONDAY) - .atStartOfDay(); + .atStartOfDay(ZoneId.of("Asia/Seoul")) + .toInstant(); - when(playingRepository.sumDurationSecExcludeCurrent( + org.mockito.Mockito.lenient().when(playingRepository.sumDurationSecExcludeCurrent( userId, PlayingStatus.COMPLETED, weekStart, @@ -1085,8 +1086,8 @@ class DeletePlaying { @DisplayName("본인의 연주 기록을 삭제한다") void deletePlayingSuccess() { // given - LocalDateTime deletedAt = - LocalDateTime.of(2026, 1, 1, 15, 30); + Instant deletedAt = + Instant.parse("2026-01-01T15:30:00Z"); when(playingRepository.findByIdAndDeletedAtIsNull(playingId)) .thenReturn(Optional.of(playing)); diff --git a/src/test/java/com/mr/domain/statistics/service/StatisticsAggregationServiceTest.java b/src/test/java/com/mr/domain/statistics/service/StatisticsAggregationServiceTest.java index 212a1c20..a4b5655c 100644 --- a/src/test/java/com/mr/domain/statistics/service/StatisticsAggregationServiceTest.java +++ b/src/test/java/com/mr/domain/statistics/service/StatisticsAggregationServiceTest.java @@ -32,7 +32,6 @@ import java.time.Clock; import java.time.DayOfWeek; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.Instant; import java.time.ZoneId; import java.util.List; @@ -98,7 +97,7 @@ private void stubBaseline() { } private PlayingRepository.PracticeTotals mockPracticeTotals( - long sessionCount, long totalDurationSec, LocalDateTime lastEndedAt) { + long sessionCount, long totalDurationSec, Instant lastEndedAt) { PlayingRepository.PracticeTotals totals = mock(PlayingRepository.PracticeTotals.class); lenient().when(totals.getSessionCount()).thenReturn(sessionCount); lenient().when(totals.getTotalDurationSec()).thenReturn(totalDurationSec); @@ -146,7 +145,7 @@ private PracticeStatistics captureSavedPracticeStatistics() { @DisplayName("onPlayingCompleted - UserStatistics가 없으면 새로 만들어 전체 기간 집계값을 채운다") void onPlayingCompleted_noExistingUserStatistics_createsWithAggregatedTotals() { stubBaseline(); - LocalDateTime lastEndedAt = LocalDateTime.now(); + Instant lastEndedAt =Instant.now(); PlayingRepository.PracticeTotals totals = mockPracticeTotals(5L, 1200L, lastEndedAt); given(playingRepository.aggregateTotalsByUserAndStatus(userId, PlayingStatus.COMPLETED)) .willReturn(totals); @@ -166,7 +165,7 @@ void onPlayingCompleted_noExistingUserStatistics_createsWithAggregatedTotals() { void onPlayingCompleted_existingUserStatistics_updatesWithoutCreating() { stubBaseline(); UserStatistics existing = UserStatistics.createForUser(mock(User.class)); - PlayingRepository.PracticeTotals totals = mockPracticeTotals(3L, 600L, LocalDateTime.now()); + PlayingRepository.PracticeTotals totals = mockPracticeTotals(3L, 600L, Instant.now()); given(playingRepository.aggregateTotalsByUserAndStatus(userId, PlayingStatus.COMPLETED)) .willReturn(totals); given(userStatisticsRepository.findByUser_UserId(userId)).willReturn(Optional.of(existing)); @@ -206,11 +205,11 @@ void onPlayingCompleted_noWeeklyPracticeStatistics_createsWithWeeklyAggregates() given(userStatisticsRepository.findByUser_UserId(userId)) .willReturn(Optional.of(UserStatistics.createForUser(mock(User.class)))); List weeklyPlayings = List.of(mockPlaying(600), mockPlaying(1200)); - given(playingRepository.findByUserAndStatusSince(userId, PlayingStatus.COMPLETED, weekStart.atStartOfDay())) + given(playingRepository.findByUserAndStatusSince(userId, PlayingStatus.COMPLETED, weekStart.atStartOfDay(SERVICE_ZONE_ID).toInstant())) .willReturn(weeklyPlayings); List weeklyAnalyses = List.of( mockAnalysis(90, null, null, null, null), mockAnalysis(80, null, null, null, null)); - given(analysisRepository.findByUserAndStatusSince(userId, AnalysisStatus.COMPLETED, weekStart.atStartOfDay())) + given(analysisRepository.findByUserAndStatusSince(userId, AnalysisStatus.COMPLETED, weekStart.atStartOfDay(SERVICE_ZONE_ID).toInstant())) .willReturn(weeklyAnalyses); service.onPlayingCompleted(userId); @@ -256,7 +255,7 @@ void onPlayingCompleted_existingWeeklyPracticeStatistics_updatesWithoutCreating( given(practiceStatisticsRepository.findByUser_UserIdAndPeriodTypeAndPeriodStart(userId, PeriodType.WEEKLY, weekStart)) .willReturn(Optional.of(existing)); List weeklyPlayings = List.of(mockPlaying(300)); - given(playingRepository.findByUserAndStatusSince(userId, PlayingStatus.COMPLETED, weekStart.atStartOfDay())) + given(playingRepository.findByUserAndStatusSince(userId, PlayingStatus.COMPLETED, weekStart.atStartOfDay(SERVICE_ZONE_ID).toInstant())) .willReturn(weeklyPlayings); service.onPlayingCompleted(userId); @@ -277,7 +276,7 @@ void onAnalysisCompleted_noCurrentWeekSkillStatistics_createsWithLastWeekAsPrevi .willReturn(Optional.of(UserStatistics.createForUser(mock(User.class)))); List weeklyAnalyses = List.of(mockAnalysis(null, new BigDecimal("90.0"), new BigDecimal("70.0"), new BigDecimal("88.0"), new BigDecimal("60.0"))); - given(analysisRepository.findByUserAndStatusSince(userId, AnalysisStatus.COMPLETED, weekStart.atStartOfDay())) + given(analysisRepository.findByUserAndStatusSince(userId, AnalysisStatus.COMPLETED, weekStart.atStartOfDay(SERVICE_ZONE_ID).toInstant())) .willReturn(weeklyAnalyses); SkillStatistics lastWeekScale = SkillStatistics.create( @@ -326,7 +325,7 @@ void onAnalysisCompleted_existingCurrentWeekSkillStatistics_keepsPreviousScore() .willReturn(Optional.of(UserStatistics.createForUser(mock(User.class)))); List weeklyAnalyses = List.of(mockAnalysis(null, new BigDecimal("90.0"), new BigDecimal("91.0"), new BigDecimal("92.0"), new BigDecimal("93.0"))); - given(analysisRepository.findByUserAndStatusSince(userId, AnalysisStatus.COMPLETED, weekStart.atStartOfDay())) + given(analysisRepository.findByUserAndStatusSince(userId, AnalysisStatus.COMPLETED, weekStart.atStartOfDay(SERVICE_ZONE_ID).toInstant())) .willReturn(weeklyAnalyses); SkillStatistics existingScale = SkillStatistics.createWithPreviousScore( @@ -375,7 +374,7 @@ void onAnalysisCompleted_skillScoreAllNull_skipsThatSkillType() { .willReturn(Optional.of(UserStatistics.createForUser(mock(User.class)))); List weeklyAnalyses = List.of(mockAnalysis(null, null, new BigDecimal("70.0"), new BigDecimal("80.0"), new BigDecimal("90.0"))); - given(analysisRepository.findByUserAndStatusSince(userId, AnalysisStatus.COMPLETED, weekStart.atStartOfDay())) + given(analysisRepository.findByUserAndStatusSince(userId, AnalysisStatus.COMPLETED, weekStart.atStartOfDay(SERVICE_ZONE_ID).toInstant())) .willReturn(weeklyAnalyses); service.onAnalysisCompleted(userId); @@ -393,7 +392,7 @@ void onAnalysisCompleted_skillScoreAllNull_skipsThatSkillType() { @DisplayName("onPlayingCompleted - UserStatistics 저장 중 예외가 발생하면 삼키지 않고 그대로 전파한다") void onPlayingCompleted_saveFails_propagatesException() { stubBaseline(); - PlayingRepository.PracticeTotals totals = mockPracticeTotals(1L, 60L, LocalDateTime.now()); + PlayingRepository.PracticeTotals totals = mockPracticeTotals(1L, 60L, Instant.now()); given(playingRepository.aggregateTotalsByUserAndStatus(userId, PlayingStatus.COMPLETED)) .willReturn(totals); given(userStatisticsRepository.findByUser_UserId(userId)).willReturn(Optional.empty()); diff --git a/src/test/java/com/mr/domain/statistics/service/StatisticsServiceTest.java b/src/test/java/com/mr/domain/statistics/service/StatisticsServiceTest.java index 21e5d751..db2fce62 100644 --- a/src/test/java/com/mr/domain/statistics/service/StatisticsServiceTest.java +++ b/src/test/java/com/mr/domain/statistics/service/StatisticsServiceTest.java @@ -40,6 +40,7 @@ class StatisticsServiceTest { @Mock UserRepository userRepository; @Mock PracticeStatisticsRepository practiceRepository; @Mock SkillStatisticsRepository skillRepository; + private StatisticsService service; @BeforeEach @@ -104,6 +105,7 @@ void aggregatesBuildSameResponseContract() { List skills = List.of( skill(THIS_WEEK.minusWeeks(1), SkillType.SCALE, new BigDecimal("77.00")), skill(THIS_WEEK, SkillType.SCALE, new BigDecimal("85.00"))); + given(practiceRepository.findAllByUser_UserIdAndPeriodTypeAndPeriodStartBetween( 1L, PeriodType.WEEKLY, THIS_WEEK.minusWeeks(3), THIS_WEEK)).willReturn(practices); given(skillRepository.findAllByUser_UserIdAndPeriodTypeAndPeriodStartBetween( @@ -129,8 +131,10 @@ void missingPreviousScoreKeepsDiffAtZero() { skill(THIS_WEEK, SkillType.SCALE, new BigDecimal("85.00"))); given(skillRepository.findAllByUser_UserIdAndPeriodTypeAndPeriodStartBetween( 1L, PeriodType.WEEKLY, THIS_WEEK.minusWeeks(1), THIS_WEEK)).willReturn(skills); + StatisticsResponseDTO response = service.getStatistics(1L); + assertThat(response.domainGrowth().get(0).currentScore()).isEqualByComparingTo("85.0"); assertThat(response.domainGrowth().get(0).diff()).isEqualByComparingTo("0.0"); } -} +} \ No newline at end of file diff --git a/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java b/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java index 3e7203c3..0b1e78ab 100644 --- a/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java +++ b/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java @@ -18,7 +18,6 @@ import com.mr.domain.user.service.UserProfileService; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.apipayload.handler.GlobalExceptionHandler; -import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,6 +29,8 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import java.time.Instant; + @ExtendWith(MockitoExtension.class) class UserProfileControllerTest { @@ -181,7 +182,7 @@ void updateProfile_success() throws Exception { .userId(1L) .nickname("새로운뮤즈") .skillLevel(TheoryLevel.ADVANCED) - .updatedAt(LocalDateTime.of(2026, 7, 24, 1, 30)) + .updatedAt(Instant.parse("2026-07-24T01:30:00Z")) .build(); given(userProfileService.updateProfile(any())).willReturn(response); diff --git a/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java index 060ecbc2..7195f33f 100644 --- a/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java +++ b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java @@ -29,7 +29,8 @@ import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.security.principal.CustomUserDetails; import java.sql.SQLException; -import java.time.LocalDateTime; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Optional; import org.hibernate.exception.ConstraintViolationException; import org.junit.jupiter.api.AfterEach; @@ -202,7 +203,7 @@ void getMyProfile_usesLearningProgressAggregationForCompletedLearningCount() { Instrument piano = Instrument.create("PIANO", "피아노"); StudentInstrument primaryInstrument = StudentInstrument.createPrimary(student, piano); Subscription subscription = Subscription.create( - user, "PRO", LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(29)); + user, "PRO", Instant.now().minus(1, ChronoUnit.DAYS), Instant.now().plus(29, ChronoUnit.DAYS)); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); given(studentRepository.findByUser(user)).willReturn(Optional.of(student)); @@ -227,8 +228,9 @@ void getMyProfile_subscriptionPastOldEndDate_stillSucceeds() { Instrument piano = Instrument.create("PIANO", "피아노"); StudentInstrument primaryInstrument = StudentInstrument.createPrimary(student, piano); // 가입 40일 전 생성된 구독 (end_date = 시작일 + 30일 → 이미 10일 지남) + Instant startDate = Instant.now().minus(40, ChronoUnit.DAYS); Subscription subscription = Subscription.create( - user, "PRO", LocalDateTime.now().minusDays(40), LocalDateTime.now().minusDays(10)); + user, "PRO", startDate, startDate.plus(30, ChronoUnit.DAYS)); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); given(studentRepository.findByUser(user)).willReturn(Optional.of(student));