From 70ddd93965f74f20b09cf4ec4e6995de528fbc95 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 19:17:40 +0900 Subject: [PATCH 01/42] =?UTF-8?q?feat:=20ZonedDateAttribute=EC=97=90?= =?UTF-8?q?=EC=84=9C=20UTC=20=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20zoneId=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/datetime/ZonedDateAttribute.java | 63 ++++++++++++++----- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/domain/datetime/ZonedDateAttribute.java b/src/main/java/me/gg/pinit/pinittask/domain/datetime/ZonedDateAttribute.java index de84757..dc804ec 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/datetime/ZonedDateAttribute.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/datetime/ZonedDateAttribute.java @@ -3,14 +3,13 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; -import lombok.Getter; import java.time.LocalDate; +import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Objects; -@Getter @Embeddable public class ZonedDateAttribute { @Column(name = "date") @@ -19,37 +18,65 @@ public class ZonedDateAttribute { @Column(name = "offset_id") private String offsetId; + @Column(name = "zone_id") + private String zoneId; + protected ZonedDateAttribute() { } - private ZonedDateAttribute(LocalDate date, String offsetId) { - this.date = date; - this.offsetId = offsetId; + private ZonedDateAttribute(LocalDate date, String offsetId, String zoneId) { + this.date = Objects.requireNonNull(date, "date must not be null"); + this.offsetId = Objects.requireNonNull(offsetId, "offsetId must not be null"); + this.zoneId = Objects.requireNonNull(zoneId, "zoneId must not be null"); } public static ZonedDateAttribute from(ZonedDateTime zonedDateTime) { Objects.requireNonNull(zonedDateTime, "zonedDateTime must not be null"); - return of(zonedDateTime.toLocalDate(), zonedDateTime.getOffset()); + return new ZonedDateAttribute( + zonedDateTime.toLocalDate(), + zonedDateTime.getOffset().getId(), + zonedDateTime.getZone().getId() + ); + } + + public static ZonedDateAttribute of(LocalDate date, ZoneId zoneId) { + Objects.requireNonNull(zoneId, "zoneId must not be null"); + ZoneOffset offset = date.atStartOfDay(zoneId).getOffset(); + return new ZonedDateAttribute(date, offset.getId(), zoneId.getId()); } - public static ZonedDateAttribute of(LocalDate date, ZoneOffset offset) { + public static ZonedDateAttribute of(LocalDate date, ZoneId zoneId, ZoneOffset offset) { Objects.requireNonNull(date, "date must not be null"); + Objects.requireNonNull(zoneId, "zoneId must not be null"); Objects.requireNonNull(offset, "offset must not be null"); - return new ZonedDateAttribute(date, offset.getId()); + ZoneOffset expected = date.atStartOfDay(zoneId).getOffset(); + if (!expected.equals(offset)) { + throw new IllegalArgumentException("제공된 offset이 zoneId의 규칙과 일치하지 않습니다."); + } + return new ZonedDateAttribute(date, offset.getId(), zoneId.getId()); } public ZonedDateTime toZonedDateTime() { - Objects.requireNonNull(date, "dateTime must not be null"); - Objects.requireNonNull(offsetId, "offsetId must not be null"); + ZoneId zone = getZoneId(); + return date.atStartOfDay(zone); + } - ZoneOffset to = getOffset(); + public ZoneOffset getOffset() { + ZoneId zone = getZoneId(); + return date.atStartOfDay(zone).getOffset(); + } - return date.atStartOfDay(to); + public LocalDate getDate() { + return date; } - public ZoneOffset getOffset() { - Objects.requireNonNull(offsetId, "offsetId must not be null"); - return ZoneOffset.of(offsetId); + public String getOffsetId() { + return offsetId; + } + + public ZoneId getZoneId() { + Objects.requireNonNull(zoneId, "zoneId must not be null"); + return ZoneId.of(zoneId); } @Override @@ -57,11 +84,13 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ZonedDateAttribute that = (ZonedDateAttribute) o; - return Objects.equals(date, that.date) && Objects.equals(offsetId, that.offsetId); + return Objects.equals(date, that.date) + && Objects.equals(offsetId, that.offsetId) + && Objects.equals(zoneId, that.zoneId); } @Override public int hashCode() { - return Objects.hash(date, offsetId); + return Objects.hash(date, offsetId, zoneId); } } From 9fb1f167b08949abcff295fb8b594e8019ab7c00 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 19:26:31 +0900 Subject: [PATCH 02/42] =?UTF-8?q?feat:=20TemporalConstraint=EC=97=90=20zon?= =?UTF-8?q?eId=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9E=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/task/vo/TemporalConstraint.java | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/domain/task/vo/TemporalConstraint.java b/src/main/java/me/gg/pinit/pinittask/domain/task/vo/TemporalConstraint.java index bfa99c6..0633e8d 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/task/vo/TemporalConstraint.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/task/vo/TemporalConstraint.java @@ -4,10 +4,7 @@ import me.gg.pinit.pinittask.domain.converter.service.DurationConverter; import me.gg.pinit.pinittask.domain.datetime.ZonedDateAttribute; -import java.time.Duration; -import java.time.LocalDate; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; +import java.time.*; import java.util.Objects; @Embeddable @@ -15,7 +12,8 @@ public class TemporalConstraint { @Embedded @AttributeOverrides({ @AttributeOverride(name = "date", column = @Column(name = "deadline_date")), - @AttributeOverride(name = "offsetId", column = @Column(name = "deadline_offset_id")) + @AttributeOverride(name = "offsetId", column = @Column(name = "deadline_offset_id")), + @AttributeOverride(name = "zoneId", column = @Column(name = "deadline_zone_id")) }) private ZonedDateAttribute deadline; @Convert(converter = DurationConverter.class) @@ -29,8 +27,8 @@ public TemporalConstraint(ZonedDateTime deadline, Duration duration) { this(ZonedDateAttribute.from(deadline), duration); } - public TemporalConstraint(LocalDate deadlineDate, ZoneOffset offset, Duration duration) { - this(ZonedDateAttribute.of(deadlineDate, offset), duration); + public TemporalConstraint(LocalDate deadlineDate, ZoneId zoneId, ZoneOffset offset, Duration duration) { + this(ZonedDateAttribute.of(deadlineDate, zoneId, offset), duration); } public TemporalConstraint(ZonedDateAttribute deadline, Duration duration) { @@ -42,14 +40,6 @@ public TemporalConstraint changeDeadline(ZonedDateTime newDeadline) { return new TemporalConstraint(newDeadline, this.duration); } - public TemporalConstraint changeDeadline(LocalDate newDeadlineDate, ZoneOffset offset) { - return new TemporalConstraint(newDeadlineDate, offset, this.duration); - } - - /** - * Returns deadline at start-of-day using stored zone offset. - * Time component is always 00:00 and region ZoneId information is not preserved. - */ public ZonedDateTime getDeadline() { return deadline.toZonedDateTime(); } @@ -58,6 +48,10 @@ public LocalDate getDeadlineDate() { return deadline.getDate(); } + public ZoneId getDeadlineZoneId() { + return deadline.getZoneId(); + } + public ZoneOffset getDeadlineOffset() { return deadline.getOffset(); } From 81aefba39224bcdef0e99699574880347246f69f Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 19:30:39 +0900 Subject: [PATCH 03/42] =?UTF-8?q?feat:=20Schedule=20=EB=B0=8F=20Task=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=EC=9D=98=20createdAt,=20updatedAt?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=EC=97=90=20UTC=20=EB=B3=80=ED=99=98?= =?UTF-8?q?=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../me/gg/pinit/pinittask/domain/schedule/model/Schedule.java | 2 ++ .../java/me/gg/pinit/pinittask/domain/task/model/Task.java | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/main/java/me/gg/pinit/pinittask/domain/schedule/model/Schedule.java b/src/main/java/me/gg/pinit/pinittask/domain/schedule/model/Schedule.java index b84a9b0..a245905 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/schedule/model/Schedule.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/schedule/model/Schedule.java @@ -65,10 +65,12 @@ public class Schedule { @CreationTimestamp @Column(updatable = false, columnDefinition = "DATETIME(6)") + @Convert(converter = InstantToDatetime6UtcConverter.class) private Instant createdAt; @UpdateTimestamp @Column(columnDefinition = "DATETIME(6)") + @Convert(converter = InstantToDatetime6UtcConverter.class) private Instant updatedAt; protected Schedule() { diff --git a/src/main/java/me/gg/pinit/pinittask/domain/task/model/Task.java b/src/main/java/me/gg/pinit/pinittask/domain/task/model/Task.java index c951cbc..013f8b6 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/task/model/Task.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/task/model/Task.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import lombok.Getter; +import me.gg.pinit.pinittask.domain.converter.service.InstantToDatetime6UtcConverter; import me.gg.pinit.pinittask.domain.events.DomainEvents; import me.gg.pinit.pinittask.domain.task.event.TaskCanceledEvent; import me.gg.pinit.pinittask.domain.task.event.TaskCompletedEvent; @@ -51,9 +52,11 @@ public class Task { @CreationTimestamp @Column(updatable = false, columnDefinition = "DATETIME(6)") + @Convert(converter = InstantToDatetime6UtcConverter.class) private Instant createdAt; @UpdateTimestamp @Column(columnDefinition = "DATETIME(6)") + @Convert(converter = InstantToDatetime6UtcConverter.class) private Instant updatedAt; protected Task() { From 7504559d4221f289d0ac021ce4943b7ffc20187c Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 19:31:08 +0900 Subject: [PATCH 04/42] =?UTF-8?q?feat:=20Statistics=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=EC=97=90=20startOfWeek=20=ED=95=84=EB=93=9C=EC=97=90?= =?UTF-8?q?=20zoneId=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/pinit/pinittask/domain/statistics/model/Statistics.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/me/gg/pinit/pinittask/domain/statistics/model/Statistics.java b/src/main/java/me/gg/pinit/pinittask/domain/statistics/model/Statistics.java index f333678..b9a4c7d 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/statistics/model/Statistics.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/statistics/model/Statistics.java @@ -25,7 +25,8 @@ public class Statistics { @Embedded @AttributeOverrides({ @AttributeOverride(name = "date", column = @Column(name = "start_of_week_date")), - @AttributeOverride(name = "offsetId", column = @Column(name = "start_of_week_offset_id")) + @AttributeOverride(name = "offsetId", column = @Column(name = "start_of_week_offset_id")), + @AttributeOverride(name = "zoneId", column = @Column(name = "start_of_week_zone_id")) }) private ZonedDateAttribute startOfWeek; From 57b950a796e1a5375fc0272af7a8b5899904ed28 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 19:31:35 +0900 Subject: [PATCH 05/42] =?UTF-8?q?test:=20ZonedDateAttribute=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../datetime/ZonedDateAttributeTest.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/test/java/me/gg/pinit/pinittask/domain/datetime/ZonedDateAttributeTest.java diff --git a/src/test/java/me/gg/pinit/pinittask/domain/datetime/ZonedDateAttributeTest.java b/src/test/java/me/gg/pinit/pinittask/domain/datetime/ZonedDateAttributeTest.java new file mode 100644 index 0000000..1f066dd --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/domain/datetime/ZonedDateAttributeTest.java @@ -0,0 +1,38 @@ +package me.gg.pinit.pinittask.domain.datetime; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class ZonedDateAttributeTest { + + @Test + void preservesZoneIdAndComputesOffsetFromRules() { + ZoneId zoneId = ZoneId.of("Asia/Seoul"); + LocalDate date = LocalDate.of(2026, 2, 1); + + ZonedDateAttribute attribute = ZonedDateAttribute.of(date, zoneId); + + ZonedDateTime zoned = attribute.toZonedDateTime(); + assertThat(zoned.getZone()).isEqualTo(zoneId); + assertThat(attribute.getOffset()).isEqualTo(ZoneOffset.of("+09:00")); + } + + @Test + void recalculatesOffsetForDstDates() { + ZoneId zoneId = ZoneId.of("America/New_York"); + + ZonedDateAttribute winter = ZonedDateAttribute.of(LocalDate.of(2026, 3, 8), zoneId); // DST switch day + ZonedDateAttribute summer = ZonedDateAttribute.of(LocalDate.of(2026, 7, 1), zoneId); + + assertThat(winter.getOffset()).isEqualTo(ZoneOffset.of("-05:00")); + assertThat(summer.getOffset()).isEqualTo(ZoneOffset.of("-04:00")); + assertThat(winter.getZoneId()).isEqualTo(zoneId); + assertThat(summer.getZoneId()).isEqualTo(zoneId); + } +} From e7d2e1bd84c1fcb626bc19c06ba5823bb19351e7 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 20:45:36 +0900 Subject: [PATCH 06/42] =?UTF-8?q?feat:=20TaskRepository=EC=97=90=20deadlin?= =?UTF-8?q?eZoneId=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=82=A0=EC=A7=9C=20=EB=B2=94=EC=9C=84=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/task/repository/TaskRepository.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepository.java b/src/main/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepository.java index 3626dd8..460995a 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepository.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepository.java @@ -63,10 +63,21 @@ Page findCurrentByOwnerId(@Param("ownerId") Long ownerId, SELECT t FROM Task t WHERE t.ownerId = :ownerId AND t.temporalConstraint.deadline.date = :deadlineDate + AND t.temporalConstraint.deadline.zoneId = :deadlineZoneId ORDER BY t.id ASC """) List findAllByOwnerIdAndDeadlineDate(@Param("ownerId") Long ownerId, - @Param("deadlineDate") LocalDate deadlineDate); + @Param("deadlineDate") LocalDate deadlineDate, + @Param("deadlineZoneId") String deadlineZoneId); + + @Query(""" + SELECT t FROM Task t + WHERE t.ownerId = :ownerId + AND t.temporalConstraint.deadline.date BETWEEN :fromDate AND :toDate + """) + List findAllByOwnerIdAndDeadlineDateBetween(@Param("ownerId") Long ownerId, + @Param("fromDate") LocalDate fromDate, + @Param("toDate") LocalDate toDate); @Query(""" SELECT t FROM Task t From c0861951d6eb93f1d4f296675be8a976825010d2 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 20:45:50 +0900 Subject: [PATCH 07/42] =?UTF-8?q?feat:=20TaskRepository=EC=9D=98=20?= =?UTF-8?q?=EB=A7=88=EA=B0=90=EC=9D=BC=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EC=97=90=20zoneId=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pinittask/domain/task/repository/TaskRepositoryTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepositoryTest.java b/src/test/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepositoryTest.java index e58b546..e8fe16a 100644 --- a/src/test/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepositoryTest.java +++ b/src/test/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepositoryTest.java @@ -192,7 +192,7 @@ void findAllByOwnerIdAndDeadlineDate_returnsOnlyMatchingDate() { taskRepository.saveAll(List.of(target1, target2, otherDate, otherOwner)); - List result = taskRepository.findAllByOwnerIdAndDeadlineDate(1L, LocalDate.of(2025, 2, 1)); + List result = taskRepository.findAllByOwnerIdAndDeadlineDate(1L, LocalDate.of(2025, 2, 1), "Z"); assertThat(result).extracting(Task::getId) .containsExactly(target1.getId(), target2.getId()); From e2aa582bbc4d8cb66fdf682faac20822e7b66754 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 20:46:06 +0900 Subject: [PATCH 08/42] =?UTF-8?q?feat:=20StatisticsRepository=EC=9D=98=20s?= =?UTF-8?q?tartOfWeek=20=ED=95=84=EB=93=9C=EC=97=90=20zoneId=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/statistics/repository/StatisticsRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/domain/statistics/repository/StatisticsRepository.java b/src/main/java/me/gg/pinit/pinittask/domain/statistics/repository/StatisticsRepository.java index 47f0139..04e6e49 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/statistics/repository/StatisticsRepository.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/statistics/repository/StatisticsRepository.java @@ -9,6 +9,6 @@ import java.util.Optional; public interface StatisticsRepository extends JpaRepository { - @Query("SELECT s FROM Statistics s WHERE s.memberId = :memberId AND s.startOfWeek.date = :startOfWeekDate AND s.startOfWeek.offsetId = :startOfWeekOffsetId") - Optional findByMemberIdAndStartOfWeekDate(@Param("memberId") Long memberId, @Param("startOfWeekDate") LocalDate startOfWeekDate, @Param("startOfWeekOffsetId") String startOfWeekOffsetId); + @Query("SELECT s FROM Statistics s WHERE s.memberId = :memberId AND s.startOfWeek.date = :startOfWeekDate AND s.startOfWeek.zoneId = :startOfWeekZoneId") + Optional findByMemberIdAndStartOfWeekDate(@Param("memberId") Long memberId, @Param("startOfWeekDate") LocalDate startOfWeekDate, @Param("startOfWeekZoneId") String startOfWeekZoneId); } From 0dc710cc847a15f9a2368b78700dc1ce7f8cbbb7 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 21:12:17 +0900 Subject: [PATCH 09/42] =?UTF-8?q?feat:=20DateTimeUtils=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=EC=9D=98=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20ZoneOffset=EC=9D=84=20ZoneId=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/datetime/DateTimeUtils.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/application/datetime/DateTimeUtils.java b/src/main/java/me/gg/pinit/pinittask/application/datetime/DateTimeUtils.java index a334074..4bf4901 100644 --- a/src/main/java/me/gg/pinit/pinittask/application/datetime/DateTimeUtils.java +++ b/src/main/java/me/gg/pinit/pinittask/application/datetime/DateTimeUtils.java @@ -10,11 +10,11 @@ @Service @Slf4j public class DateTimeUtils { - public ZonedDateTime lastMondayStart(ZonedDateTime point, ZoneOffset offset) { - ZonedDateTime converted = point.withZoneSameInstant(offset); + public ZonedDateTime lastMondayStart(ZonedDateTime point, ZoneId zoneId) { + ZonedDateTime converted = point.withZoneSameInstant(zoneId); LocalDate monday = converted.toLocalDate() .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); - return monday.atStartOfDay(offset); + return monday.atStartOfDay(zoneId); } public ZonedDateTime toZonedDateTime(LocalDateTime localDateTime, ZoneId zoneId) { @@ -23,9 +23,9 @@ public ZonedDateTime toZonedDateTime(LocalDateTime localDateTime, ZoneId zoneId) return ZonedDateTime.of(localDateTime, zoneId); } - public ZonedDateTime toStartOfDay(LocalDate date, ZoneOffset offset) { + public ZonedDateTime toStartOfDay(LocalDate date, ZoneId zoneId) { Objects.requireNonNull(date, "date must not be null"); - Objects.requireNonNull(offset, "offset must not be null"); - return date.atStartOfDay(offset); + Objects.requireNonNull(zoneId, "zoneId must not be null"); + return date.atStartOfDay(zoneId); } } From bdef7501928b6e8833d0d20bd549d1c6d5fd1ee4 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 21:12:44 +0900 Subject: [PATCH 10/42] =?UTF-8?q?feat:=20DateTimeUtilsTest=EC=97=90?= =?UTF-8?q?=EC=84=9C=20ZoneOffset=EC=9D=84=20ZoneId=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/datetime/DateTimeUtilsTest.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/test/java/me/gg/pinit/pinittask/application/datetime/DateTimeUtilsTest.java b/src/test/java/me/gg/pinit/pinittask/application/datetime/DateTimeUtilsTest.java index a9cd64a..fd28cb7 100644 --- a/src/test/java/me/gg/pinit/pinittask/application/datetime/DateTimeUtilsTest.java +++ b/src/test/java/me/gg/pinit/pinittask/application/datetime/DateTimeUtilsTest.java @@ -5,7 +5,6 @@ import java.time.LocalDateTime; import java.time.ZoneId; -import java.time.ZoneOffset; import java.time.ZonedDateTime; class DateTimeUtilsTest { @@ -13,7 +12,10 @@ class DateTimeUtilsTest { @Test void lastMondayStart() { DateTimeUtils dateTimeUtils = new DateTimeUtils(); - ZonedDateTime zonedDateTime = dateTimeUtils.lastMondayStart(ZonedDateTime.of(LocalDateTime.of(2025, 11, 19, 10, 30, 0), ZoneId.of("Asia/Seoul")), ZoneOffset.of("+09:00")); + ZoneId zoneId = ZoneId.of("Asia/Seoul"); + ZonedDateTime zonedDateTime = dateTimeUtils.lastMondayStart( + ZonedDateTime.of(LocalDateTime.of(2025, 11, 19, 10, 30, 0), zoneId), + zoneId); Assertions.assertThat(zonedDateTime).isNotNull(); Assertions.assertThat(zonedDateTime.getYear()).isEqualTo(2025); @@ -22,6 +24,6 @@ void lastMondayStart() { Assertions.assertThat(zonedDateTime.getHour()).isEqualTo(0); Assertions.assertThat(zonedDateTime.getMinute()).isEqualTo(0); Assertions.assertThat(zonedDateTime.getSecond()).isEqualTo(0); - Assertions.assertThat(zonedDateTime.getZone()).isEqualTo(ZoneId.of("+09:00")); + Assertions.assertThat(zonedDateTime.getZone()).isEqualTo(zoneId); } -} \ No newline at end of file +} From ee5251ecf1f77c5bb7f6c5472a4560e255aa64a2 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 21:33:03 +0900 Subject: [PATCH 11/42] =?UTF-8?q?feat:=20MemberService=EC=97=90=EC=84=9C?= =?UTF-8?q?=20ZoneId=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/service/MemberService.java | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/application/member/service/MemberService.java b/src/main/java/me/gg/pinit/pinittask/application/member/service/MemberService.java index d5f7eee..4c39c14 100644 --- a/src/main/java/me/gg/pinit/pinittask/application/member/service/MemberService.java +++ b/src/main/java/me/gg/pinit/pinittask/application/member/service/MemberService.java @@ -6,16 +6,21 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; +import java.time.Instant; import java.time.ZoneId; import java.time.ZoneOffset; +import java.util.Objects; import java.util.Optional; @Service public class MemberService { private final MemberRepository memberRepository; + private final Clock clock; - public MemberService(MemberRepository memberRepository) { + public MemberService(MemberRepository memberRepository, Clock clock) { this.memberRepository = memberRepository; + this.clock = clock; } @Transactional(readOnly = true) @@ -25,12 +30,17 @@ public ZoneId findZoneIdOfMember(Long memberId) { } @Transactional(readOnly = true) - public ZoneOffset findZoneOffsetOfMember(Long memberId) { + public ZoneOffset findZoneOffsetAt(Long memberId, Instant instant) { return memberRepository.findById(memberId) .orElseThrow(() -> new MemberNotFoundException("Member not found")) .getZoneId() .getRules() - .getOffset(java.time.Instant.now()); + .getOffset(instant); + } + + @Transactional(readOnly = true) + public ZoneOffset findZoneOffsetOfMember(Long memberId) { + return findZoneOffsetAt(memberId, Instant.now(clock)); } public Long getNowInProgressScheduleId(Long memberId) { @@ -54,11 +64,13 @@ public void clearNowRunningSchedule(Long memberId) { } @Transactional - public void enrollMember(Long memberId, String nickname) { + public void enrollMember(Long memberId, String nickname, ZoneId zoneId) { + Objects.requireNonNull(zoneId, "zoneId must not be null"); Optional byId = memberRepository.findById(memberId); if (byId.isPresent()) { return; } - memberRepository.save(new Member(memberId, nickname, ZoneId.of("Asia/Seoul"))); + memberRepository.save(new Member(memberId, nickname, zoneId)); } + } From 8d2f8db35d06afd69311ee5d004f2d66d1d7f5c6 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 21:33:29 +0900 Subject: [PATCH 12/42] =?UTF-8?q?test:=20MemberServiceTest=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Clock=20=EB=AA=A8=EC=9D=98=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/member/service/MemberServiceTest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/test/java/me/gg/pinit/pinittask/application/member/service/MemberServiceTest.java b/src/test/java/me/gg/pinit/pinittask/application/member/service/MemberServiceTest.java index c84245a..39c7b6c 100644 --- a/src/test/java/me/gg/pinit/pinittask/application/member/service/MemberServiceTest.java +++ b/src/test/java/me/gg/pinit/pinittask/application/member/service/MemberServiceTest.java @@ -12,7 +12,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.time.Duration; +import java.time.Clock; import java.time.ZoneId; import java.util.Optional; @@ -24,6 +24,8 @@ class MemberServiceTest { @Mock MemberRepository memberRepository; + @Mock + Clock clock; @InjectMocks MemberService memberService; @@ -130,4 +132,5 @@ void clearNowRunningSchedule_memberNotFound() { assertThrows(MemberNotFoundException.class, () -> memberService.clearNowRunningSchedule(memberId)); verify(memberRepository).findById(memberId); } -} \ No newline at end of file + +} From a6f53a4bf42083c2a11e64703e92afa8f0d6ec3c Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 21:50:03 +0900 Subject: [PATCH 13/42] =?UTF-8?q?feat:=20ScheduleService=EC=97=90=EC=84=9C?= =?UTF-8?q?=20ZoneId=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/service/ScheduleService.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleService.java b/src/main/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleService.java index 77cc23e..eb33e61 100644 --- a/src/main/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleService.java +++ b/src/main/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleService.java @@ -3,7 +3,6 @@ import lombok.RequiredArgsConstructor; import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; import me.gg.pinit.pinittask.application.events.DomainEventPublisher; -import me.gg.pinit.pinittask.application.member.service.MemberService; import me.gg.pinit.pinittask.domain.dependency.exception.ScheduleNotFoundException; import me.gg.pinit.pinittask.domain.events.DomainEvent; import me.gg.pinit.pinittask.domain.events.DomainEvents; @@ -16,7 +15,6 @@ import java.time.Instant; import java.time.ZoneId; -import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Deque; import java.util.List; @@ -26,7 +24,6 @@ @RequiredArgsConstructor public class ScheduleService { private final ScheduleRepository scheduleRepository; - private final MemberService memberService; private final DomainEventPublisher domainEventPublisher; private final DateTimeUtils dateTimeUtils; @@ -42,9 +39,9 @@ public Schedule getSchedule(Long memberId, Long scheduleId) { @Transactional(readOnly = true) public List getScheduleList(Long memberId, ZonedDateTime dateTime) { - ZoneId memberZoneById = memberService.findZoneIdOfMember(memberId); - Instant startOfDay = dateTime.toLocalDate().atStartOfDay(memberZoneById).toInstant(); - Instant endExclusive = dateTime.toLocalDate().plusDays(1).atStartOfDay(memberZoneById).toInstant(); + ZoneId zoneId = dateTime.getZone(); + Instant startOfDay = dateTime.toLocalDate().atStartOfDay(zoneId).toInstant(); + Instant endExclusive = dateTime.toLocalDate().plusDays(1).atStartOfDay(zoneId).toInstant(); return scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeBetween( memberId, @@ -55,9 +52,10 @@ public List getScheduleList(Long memberId, ZonedDateTime dateTime) { @Transactional(readOnly = true) public List getScheduleListForWeek(Long memberId, ZonedDateTime now) { - ZoneOffset zoneOffsetOfMember = memberService.findZoneOffsetOfMember(memberId); - Instant start = dateTimeUtils.lastMondayStart(now, zoneOffsetOfMember).toInstant(); - Instant end = dateTimeUtils.lastMondayStart(now, zoneOffsetOfMember).plusDays(7).toInstant(); + ZoneId zoneId = now.getZone(); + ZonedDateTime startOfWeek = dateTimeUtils.lastMondayStart(now, zoneId); + Instant start = startOfWeek.toInstant(); + Instant end = startOfWeek.plusDays(7).toInstant(); return scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeBetween( memberId, start, From 555981291b6a58f4295da881352629e0644a580a Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 21:50:11 +0900 Subject: [PATCH 14/42] =?UTF-8?q?test:=20ScheduleServiceTest=EC=97=90?= =?UTF-8?q?=EC=84=9C=20memberService=EC=9D=98=20ZoneId=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EA=B2=80=EC=A6=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/schedule/service/ScheduleServiceTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/test/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleServiceTest.java b/src/test/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleServiceTest.java index 5b877ae..a1c37ff 100644 --- a/src/test/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleServiceTest.java +++ b/src/test/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleServiceTest.java @@ -59,7 +59,6 @@ void getSchedule() { @Test void getScheduleList() { //given - when(memberService.findZoneIdOfMember(memberId)).thenReturn(ZoneId.of("Asia/Seoul")); when(scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeBetween(eq(memberId), any(), any())).thenReturn(List.of(scheduleSample)); //when @@ -68,7 +67,6 @@ void getScheduleList() { //then assertNotNull(scheduleList); assertEquals(1, scheduleList.size()); - verify(memberService).findZoneIdOfMember(memberId); verify(scheduleRepository).findAllByOwnerIdAndDesignatedStartTimeBetween(eq(memberId), any(), any()); } From bad6637595a1298bc29a5253196db25d0d35d7ba Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 21:51:49 +0900 Subject: [PATCH 15/42] =?UTF-8?q?feat:=20StatisticsService=EC=97=90?= =?UTF-8?q?=EC=84=9C=20ZoneOffset=EC=9D=84=20ZoneId=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=98=EC=97=AC=20=EC=8B=9C=EA=B0=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../statistics/service/StatisticsService.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/application/statistics/service/StatisticsService.java b/src/main/java/me/gg/pinit/pinittask/application/statistics/service/StatisticsService.java index 7fe276f..39a6d1a 100644 --- a/src/main/java/me/gg/pinit/pinittask/application/statistics/service/StatisticsService.java +++ b/src/main/java/me/gg/pinit/pinittask/application/statistics/service/StatisticsService.java @@ -10,7 +10,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Duration; -import java.time.ZoneOffset; +import java.time.ZoneId; import java.time.ZonedDateTime; @Slf4j @@ -28,17 +28,16 @@ public StatisticsService(StatisticsRepository statisticsRepository, DateTimeUtil @Transactional(readOnly = true) public Statistics getStatistics(Long memberId, ZonedDateTime now) { - ZoneOffset zoneOffsetOfMember = memberService.findZoneOffsetOfMember(memberId); - ZonedDateTime startTime = dateTimeUtils.lastMondayStart(now, zoneOffsetOfMember); - log.warn("startTime = {}", startTime); + ZoneId zoneIdOfMember = memberService.findZoneIdOfMember(memberId); + ZonedDateTime startTime = dateTimeUtils.lastMondayStart(now, zoneIdOfMember); return statisticsRepository.findByMemberIdAndStartOfWeekDate(memberId, startTime.toLocalDate(), startTime.getZone().getId()) .orElseGet(() -> new Statistics(memberId, startTime)); } @Transactional public void removeElapsedTime(Long ownerId, ScheduleType scheduleType, Duration duration, ZonedDateTime startTime) { - ZoneOffset zoneOffsetOfMember = memberService.findZoneOffsetOfMember(ownerId); - ZonedDateTime dateTime = dateTimeUtils.lastMondayStart(startTime, zoneOffsetOfMember); + ZoneId zoneIdOfMember = memberService.findZoneIdOfMember(ownerId); + ZonedDateTime dateTime = dateTimeUtils.lastMondayStart(startTime, zoneIdOfMember); Statistics statistics = statisticsRepository.findByMemberIdAndStartOfWeekDate(ownerId, dateTime.toLocalDate(), dateTime.getZone().getId()) .orElseGet(() -> new Statistics(ownerId, dateTime)); scheduleType.rollback(statistics, duration); @@ -47,8 +46,8 @@ public void removeElapsedTime(Long ownerId, ScheduleType scheduleType, Duration @Transactional public void addElapsedTime(Long ownerId, ScheduleType scheduleType, Duration duration, ZonedDateTime startTime) { - ZoneOffset zoneOffsetOfMember = memberService.findZoneOffsetOfMember(ownerId); - ZonedDateTime dateTime = dateTimeUtils.lastMondayStart(startTime, zoneOffsetOfMember); + ZoneId zoneIdOfMember = memberService.findZoneIdOfMember(ownerId); + ZonedDateTime dateTime = dateTimeUtils.lastMondayStart(startTime, zoneIdOfMember); Statistics statistics = statisticsRepository.findByMemberIdAndStartOfWeekDate(ownerId, dateTime.toLocalDate(), dateTime.getZone().getId()) .orElseGet(() -> new Statistics(ownerId, dateTime)); scheduleType.record(statistics, duration); From 2f50d7a5a80f7a72e34a3365dd757e7b4ad830aa Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 21:52:12 +0900 Subject: [PATCH 16/42] =?UTF-8?q?test:=20StatisticsServiceTest=EC=97=90?= =?UTF-8?q?=EC=84=9C=20ZoneOffset=EC=9D=84=20ZoneId=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=98=EC=97=AC=20=EC=8B=9C=EA=B0=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../statistics/service/StatisticsServiceTest.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/test/java/me/gg/pinit/pinittask/application/statistics/service/StatisticsServiceTest.java b/src/test/java/me/gg/pinit/pinittask/application/statistics/service/StatisticsServiceTest.java index 8180b56..c675e7b 100644 --- a/src/test/java/me/gg/pinit/pinittask/application/statistics/service/StatisticsServiceTest.java +++ b/src/test/java/me/gg/pinit/pinittask/application/statistics/service/StatisticsServiceTest.java @@ -13,7 +13,6 @@ import java.time.Duration; import java.time.ZoneId; -import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Optional; @@ -40,8 +39,8 @@ void getStatistics() { ZonedDateTime now = ZonedDateTime.now(zone); ZonedDateTime mondayStart = now.minusDays(2).withHour(0).withMinute(0).withSecond(0).withNano(0); Statistics existing = new Statistics(memberId, mondayStart); - when(memberService.findZoneOffsetOfMember(memberId)).thenReturn(ZoneOffset.UTC); - when(dateTimeUtils.lastMondayStart(now, ZoneOffset.UTC)).thenReturn(mondayStart); + when(memberService.findZoneIdOfMember(memberId)).thenReturn(zone); + when(dateTimeUtils.lastMondayStart(now, zone)).thenReturn(mondayStart); when(statisticsRepository.findByMemberIdAndStartOfWeekDate(memberId, mondayStart.toLocalDate(), mondayStart.getZone().getId())).thenReturn(Optional.of(existing)); //when @@ -60,8 +59,8 @@ void removeElapsedTime() { Statistics stats = new Statistics(memberId, mondayStart); stats.addDeepWorkDuration(Duration.ofMinutes(40)); Duration rollback = Duration.ofMinutes(15); - when(memberService.findZoneOffsetOfMember(memberId)).thenReturn(ZoneOffset.UTC); - when(dateTimeUtils.lastMondayStart(startTime, ZoneOffset.UTC)).thenReturn(mondayStart); + when(memberService.findZoneIdOfMember(memberId)).thenReturn(zone); + when(dateTimeUtils.lastMondayStart(startTime, zone)).thenReturn(mondayStart); when(statisticsRepository.findByMemberIdAndStartOfWeekDate(memberId, mondayStart.toLocalDate(), mondayStart.getZone().getId())).thenReturn(Optional.of(stats)); //when @@ -79,8 +78,8 @@ void addElapsedTime() { ZonedDateTime startTime = ZonedDateTime.now(zone); ZonedDateTime mondayStart = startTime.minusDays(4).withHour(0).withMinute(0).withSecond(0).withNano(0); Duration duration = Duration.ofMinutes(45); - when(memberService.findZoneOffsetOfMember(memberId)).thenReturn(ZoneOffset.UTC); - when(dateTimeUtils.lastMondayStart(startTime, ZoneOffset.UTC)).thenReturn(mondayStart); + when(memberService.findZoneIdOfMember(memberId)).thenReturn(zone); + when(dateTimeUtils.lastMondayStart(startTime, zone)).thenReturn(mondayStart); when(statisticsRepository.findByMemberIdAndStartOfWeekDate(memberId, mondayStart.toLocalDate(), mondayStart.getZone().getId())).thenReturn(Optional.empty()); //when From 10aa09005bf78e694d90b8cf43b49e1fd2d3ba4b Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 21:59:28 +0900 Subject: [PATCH 17/42] =?UTF-8?q?feat:=20TaskService=EC=97=90=EC=84=9C=20Z?= =?UTF-8?q?oneId=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A0=95=EB=B3=B4=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/task/service/TaskService.java | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskService.java b/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskService.java index 09e5015..4577c22 100644 --- a/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskService.java +++ b/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskService.java @@ -141,13 +141,29 @@ private CursorPage loadTasksByCursor(Long ownerId, int size, String cursor, bool } private LocalDate resolveToday(Long ownerId) { - ZoneOffset offset = memberService.findZoneOffsetOfMember(ownerId); - return LocalDate.now(clock.withZone(offset)); + ZoneId zoneId = memberService.findZoneIdOfMember(ownerId); + return LocalDate.now(clock.withZone(zoneId)); } @Transactional(readOnly = true) - public List getTasksByDeadline(Long ownerId, LocalDate deadlineDate) { - return taskRepository.findAllByOwnerIdAndDeadlineDate(ownerId, deadlineDate); + public List getTasksByDeadline(Long ownerId, LocalDate deadlineDate, ZoneId zoneId) { + ZonedDateTime dayStart = deadlineDate.atStartOfDay(zoneId); + Instant startUtc = dayStart.toInstant(); + Instant endUtc = dayStart.plusDays(1).toInstant(); + + // 넉넉한 날짜 범위로 조회 후, 실제 UTC 구간으로 필터 (DST 대응) + List candidates = taskRepository.findAllByOwnerIdAndDeadlineDateBetween( + ownerId, + deadlineDate.minusDays(1), + deadlineDate.plusDays(1) + ); + + return candidates.stream() + .filter(task -> { + Instant deadlineUtc = task.getTemporalConstraint().getDeadline().toInstant(); + return !deadlineUtc.isBefore(startUtc) && deadlineUtc.isBefore(endUtc); + }) + .toList(); } private void validateOwner(Long ownerId, Task task) { From 0defbe4a87289596dc6ea904ca3675ef1b3d09bd Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 21:59:46 +0900 Subject: [PATCH 18/42] =?UTF-8?q?test:=20TaskServiceTest=EC=97=90=EC=84=9C?= =?UTF-8?q?=20ZoneOffset=EC=9D=84=20ZoneId=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../task/service/TaskServiceTest.java | 51 ++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskServiceTest.java b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskServiceTest.java index ca095d1..a2ef7a1 100644 --- a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskServiceTest.java +++ b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskServiceTest.java @@ -196,9 +196,9 @@ void getTasks_returnsAllWhenNotReadyOnly() { Long ownerId = 10L; PageRequest pageable = PageRequest.of(1, 3); LocalDate today = LocalDate.of(2025, 1, 10); - ZoneOffset offset = ZoneOffset.UTC; - when(memberService.findZoneOffsetOfMember(ownerId)).thenReturn(offset); - when(clock.withZone(offset)).thenReturn(Clock.fixed(today.atStartOfDay(offset).toInstant(), offset)); + ZoneId zoneId = ZoneId.of("UTC"); + when(memberService.findZoneIdOfMember(ownerId)).thenReturn(zoneId); + when(clock.withZone(zoneId)).thenReturn(Clock.fixed(today.atStartOfDay(zoneId).toInstant(), zoneId)); when(taskRepository.findCurrentByOwnerId(ownerId, today, pageable)).thenReturn(new PageImpl<>(List.of())); taskService.getTasks(ownerId, pageable, false); @@ -211,13 +211,13 @@ void getTasks_returnsAllWhenNotReadyOnly() { void getTasksByCursor_returnsNextCursorWhenPageFull() { Long ownerId = 50L; LocalDate today = LocalDate.of(2025, 1, 10); - ZoneOffset offset = ZoneOffset.UTC; + ZoneId zoneId = ZoneId.of("UTC"); Task t1 = buildTask(ownerId); ReflectionTestUtils.setField(t1, "id", 1L); Task t2 = buildTask(ownerId); ReflectionTestUtils.setField(t2, "id", 2L); - when(memberService.findZoneOffsetOfMember(ownerId)).thenReturn(offset); - when(clock.withZone(offset)).thenReturn(Clock.fixed(today.atStartOfDay(offset).toInstant(), offset)); + when(memberService.findZoneIdOfMember(ownerId)).thenReturn(zoneId); + when(clock.withZone(zoneId)).thenReturn(Clock.fixed(today.atStartOfDay(zoneId).toInstant(), zoneId)); when(taskRepository.findNextByCursor(eq(ownerId), eq(true), any(LocalDate.class), anyLong(), eq(today), any())) .thenReturn(List.of(t1, t2)); @@ -232,11 +232,11 @@ void getTasksByCursor_returnsNextCursorWhenPageFull() { void getTasksByCursor_noNextWhenSmallerThanSize() { Long ownerId = 51L; LocalDate today = LocalDate.of(2025, 1, 10); - ZoneOffset offset = ZoneOffset.UTC; + ZoneId zoneId = ZoneId.of("UTC"); Task t1 = buildTask(ownerId); ReflectionTestUtils.setField(t1, "id", 5L); - when(memberService.findZoneOffsetOfMember(ownerId)).thenReturn(offset); - when(clock.withZone(offset)).thenReturn(Clock.fixed(today.atStartOfDay(offset).toInstant(), offset)); + when(memberService.findZoneIdOfMember(ownerId)).thenReturn(zoneId); + when(clock.withZone(zoneId)).thenReturn(Clock.fixed(today.atStartOfDay(zoneId).toInstant(), zoneId)); when(taskRepository.findNextByCursor(eq(ownerId), eq(false), any(LocalDate.class), anyLong(), eq(today), any())) .thenReturn(List.of(t1)); @@ -270,13 +270,40 @@ void markIncomplete_setsFlagAndPublishesEvent() { void getTasksByDeadline_delegatesToRepository() { Long ownerId = 15L; LocalDate date = LocalDate.of(2025, 2, 1); + ZoneId zoneId = ZoneId.of("UTC"); Task t1 = buildTask(ownerId); - when(taskRepository.findAllByOwnerIdAndDeadlineDate(ownerId, date)).thenReturn(List.of(t1)); + ReflectionTestUtils.setField(t1, "temporalConstraint", + new TemporalConstraint(date.atStartOfDay(zoneId), Duration.ZERO)); + when(taskRepository.findAllByOwnerIdAndDeadlineDateBetween(ownerId, date.minusDays(1), date.plusDays(1))).thenReturn(List.of(t1)); - List result = taskService.getTasksByDeadline(ownerId, date); + List result = taskService.getTasksByDeadline(ownerId, date, zoneId); Assertions.assertThat(result).containsExactly(t1); - verify(taskRepository).findAllByOwnerIdAndDeadlineDate(ownerId, date); + verify(taskRepository).findAllByOwnerIdAndDeadlineDateBetween(ownerId, date.minusDays(1), date.plusDays(1)); + } + + @Test + void getTasksByDeadline_filtersByUtcRangeWithDst() { + Long ownerId = 21L; + ZoneId requestZone = ZoneId.of("Asia/Tokyo"); // UTC+9 + LocalDate requestDate = LocalDate.of(2025, 3, 9); // DST start day in NY + + Task matching = buildTask(ownerId); + ZonedDateTime nyStart = requestDate.atStartOfDay(ZoneId.of("America/New_York")); + ReflectionTestUtils.setField(matching, "temporalConstraint", + new TemporalConstraint(nyStart, Duration.ZERO)); + + Task nonMatching = buildTask(ownerId); + ZonedDateTime nextDayNy = requestDate.plusDays(1).atStartOfDay(ZoneId.of("America/New_York")); + ReflectionTestUtils.setField(nonMatching, "temporalConstraint", + new TemporalConstraint(nextDayNy, Duration.ZERO)); + + when(taskRepository.findAllByOwnerIdAndDeadlineDateBetween(ownerId, requestDate.minusDays(1), requestDate.plusDays(1))) + .thenReturn(List.of(matching, nonMatching)); + + List result = taskService.getTasksByDeadline(ownerId, requestDate, requestZone); + + Assertions.assertThat(result).containsExactly(matching); } private Task buildTask(Long ownerId) { From 09121c5677470fa3edec854448b2d1c680ad10ec Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:01:53 +0900 Subject: [PATCH 19/42] =?UTF-8?q?feat:=20TaskArchiveService=EC=97=90?= =?UTF-8?q?=EC=84=9C=20ZoneId=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=8B=9C=EA=B0=84=20=EC=A0=95=EB=B3=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../task/service/TaskArchiveService.java | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveService.java b/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveService.java index 25774ff..09e27a8 100644 --- a/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveService.java +++ b/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveService.java @@ -11,6 +11,7 @@ import java.time.Clock; import java.time.LocalDate; +import java.time.ZoneId; import java.time.ZoneOffset; import java.util.List; import java.util.regex.Matcher; @@ -30,10 +31,11 @@ public class TaskArchiveService { private final Clock clock; @Transactional(readOnly = true) - public CursorPage getCompletedArchive(Long ownerId, Integer sizeParam, String cursor, ZoneOffset requestOffset) { + public CursorPage getCompletedArchive(Long ownerId, Integer sizeParam, String cursor, ZoneOffset requestOffset, ZoneId requestZoneId) { int size = resolveSize(sizeParam); - ZoneOffset effectiveOffset = resolveOffset(ownerId, requestOffset); - LocalDate cutoffDate = LocalDate.now(clock.withZone(effectiveOffset)).minusDays(1); + ZoneId effectiveZoneId = resolveZoneId(ownerId, requestZoneId, requestOffset); + LocalDate cutoffDate = LocalDate.now(clock.withZone(effectiveZoneId)).minusDays(1); + ZoneOffset effectiveOffset = cutoffDate.atStartOfDay(effectiveZoneId).getOffset(); Cursor decoded = decodeCursor(cursor, cutoffDate); Pageable pageable = PageRequest.of(0, size + 1); @@ -48,7 +50,7 @@ public CursorPage getCompletedArchive(Long ownerId, Integer sizeParam, String cu boolean hasNext = tasks.size() > size; List content = hasNext ? tasks.subList(0, size) : tasks; String nextCursor = hasNext ? encodeCursor(content.getLast()) : null; - return new CursorPage(content, nextCursor, hasNext, cutoffDate, effectiveOffset); + return new CursorPage(content, nextCursor, hasNext, cutoffDate, effectiveZoneId, effectiveOffset); } private int resolveSize(Integer sizeParam) { @@ -59,11 +61,15 @@ private int resolveSize(Integer sizeParam) { return size; } - private ZoneOffset resolveOffset(Long ownerId, ZoneOffset requestOffset) { + private ZoneId resolveZoneId(Long ownerId, ZoneId requestZoneId, ZoneOffset requestOffset) { + if (requestZoneId != null) { + return requestZoneId; + } if (requestOffset != null) { - return requestOffset; + // 과거 호환용: 오프셋만 받은 경우에는 Offset 시간대를 생성해 사용한다. + return ZoneId.ofOffset("UTC", requestOffset); } - return memberService.findZoneOffsetOfMember(ownerId); + return memberService.findZoneIdOfMember(ownerId); } private String encodeCursor(Task task) { @@ -100,7 +106,7 @@ private long parseId(String raw) { } public record CursorPage(List tasks, String nextCursor, boolean hasNext, LocalDate cutoffDate, - ZoneOffset offset) { + ZoneId zoneId, ZoneOffset offset) { } private record Cursor(LocalDate deadline, long id) { From cff4cf605ee253d707f4d3dd3bd91310be074957 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:03:27 +0900 Subject: [PATCH 20/42] =?UTF-8?q?test:=20TaskArchiveServiceTest=EC=97=90?= =?UTF-8?q?=EC=84=9C=20ZoneId=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=8B=9C=EA=B0=84=20=EC=A0=95=EB=B3=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../task/service/TaskArchiveServiceTest.java | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveServiceTest.java b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveServiceTest.java index 6f4da82..79ddec7 100644 --- a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveServiceTest.java +++ b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveServiceTest.java @@ -42,11 +42,12 @@ void setUp() { @Test void getCompletedArchive_buildsCutoffAndNextCursor() { ZoneOffset offset = ZoneOffset.of("+09:00"); - when(memberService.findZoneOffsetOfMember(1L)).thenReturn(offset); + ZoneId zoneId = ZoneId.of("Asia/Seoul"); + when(memberService.findZoneIdOfMember(1L)).thenReturn(zoneId); - Task t1 = new Task(1L, "a", "a", new TemporalConstraint(ZonedDateTime.of(2025, 1, 9, 0, 0, 0, 0, offset), Duration.ZERO), new ImportanceConstraint(1, 1)); - Task t2 = new Task(1L, "b", "b", new TemporalConstraint(ZonedDateTime.of(2025, 1, 8, 0, 0, 0, 0, offset), Duration.ZERO), new ImportanceConstraint(1, 1)); - Task t3 = new Task(1L, "c", "c", new TemporalConstraint(ZonedDateTime.of(2025, 1, 7, 0, 0, 0, 0, offset), Duration.ZERO), new ImportanceConstraint(1, 1)); + Task t1 = new Task(1L, "a", "a", new TemporalConstraint(ZonedDateTime.of(2025, 1, 9, 0, 0, 0, 0, zoneId), Duration.ZERO), new ImportanceConstraint(1, 1)); + Task t2 = new Task(1L, "b", "b", new TemporalConstraint(ZonedDateTime.of(2025, 1, 8, 0, 0, 0, 0, zoneId), Duration.ZERO), new ImportanceConstraint(1, 1)); + Task t3 = new Task(1L, "c", "c", new TemporalConstraint(ZonedDateTime.of(2025, 1, 7, 0, 0, 0, 0, zoneId), Duration.ZERO), new ImportanceConstraint(1, 1)); ReflectionTestUtils.setField(t1, "id", 10L); ReflectionTestUtils.setField(t2, "id", 9L); ReflectionTestUtils.setField(t3, "id", 8L); @@ -54,11 +55,13 @@ void getCompletedArchive_buildsCutoffAndNextCursor() { when(taskRepository.findCompletedArchiveByCursor(anyLong(), any(), any(), anyLong(), any(Pageable.class))) .thenReturn(List.of(t1, t2, t3)); - TaskArchiveService.CursorPage page = service.getCompletedArchive(1L, 2, null, null); + TaskArchiveService.CursorPage page = service.getCompletedArchive(1L, 2, null, null, null); assertThat(page.hasNext()).isTrue(); assertThat(page.nextCursor()).isEqualTo("2025-01-08|9"); assertThat(page.cutoffDate()).isEqualTo(LocalDate.of(2025, 1, 9)); + assertThat(page.zoneId()).isEqualTo(zoneId); + assertThat(page.offset()).isEqualTo(offset); ArgumentCaptor cutoffCaptor = ArgumentCaptor.forClass(LocalDate.class); ArgumentCaptor cursorDateCaptor = ArgumentCaptor.forClass(LocalDate.class); @@ -74,7 +77,7 @@ void getCompletedArchive_prefersRequestOffset() { ZoneOffset requestOffset = ZoneOffset.of("+02:00"); when(taskRepository.findCompletedArchiveByCursor(anyLong(), any(), any(), anyLong(), any(Pageable.class))) .thenReturn(List.of()); - TaskArchiveService.CursorPage page = service.getCompletedArchive(2L, 1, null, requestOffset); + TaskArchiveService.CursorPage page = service.getCompletedArchive(2L, 1, null, requestOffset, null); assertThat(page.cutoffDate()).isEqualTo(LocalDate.of(2025, 1, 9)); verifyNoInteractions(memberService); @@ -82,15 +85,15 @@ void getCompletedArchive_prefersRequestOffset() { @Test void getCompletedArchive_rejectsInvalidSize() { - assertThatThrownBy(() -> service.getCompletedArchive(1L, 0, null, null)) + assertThatThrownBy(() -> service.getCompletedArchive(1L, 0, null, null, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("size는 1 이상 100 이하이어야 합니다."); } @Test void getCompletedArchive_rejectsBadCursor() { - when(memberService.findZoneOffsetOfMember(1L)).thenReturn(ZoneOffset.UTC); - assertThatThrownBy(() -> service.getCompletedArchive(1L, 20, "bad-cursor", null)) + when(memberService.findZoneIdOfMember(1L)).thenReturn(ZoneId.of("UTC")); + assertThatThrownBy(() -> service.getCompletedArchive(1L, 20, "bad-cursor", null, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("커서는 'yyyy-MM-dd|id' 형식이어야 합니다."); } From 9db292759d6939949299f681498be94c13804ec5 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:08:34 +0900 Subject: [PATCH 21/42] =?UTF-8?q?feat:=20DateWithOffset=EC=97=90=EC=84=9C?= =?UTF-8?q?=20ZoneId=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A0=95=EB=B3=B4=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/dto/DateWithOffset.java | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java index e883fd4..46ddeda 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotNull; import java.time.LocalDate; +import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Objects; @@ -14,10 +15,33 @@ public record DateWithOffset( LocalDate date, @NotNull @Schema(description = "UTC 기준 오프셋(+HH:mm)", example = "+09:00") - ZoneOffset offset + ZoneOffset offset, + @NotNull + @Schema(description = "IANA 시간대 ID", example = "Asia/Seoul") + ZoneId zoneId ) { + public DateWithOffset { + Objects.requireNonNull(date, "date must not be null"); + Objects.requireNonNull(offset, "offset must not be null"); + Objects.requireNonNull(zoneId, "zoneId must not be null"); + } + + public DateWithOffset(LocalDate date, ZoneOffset offset) { + this(date, offset, null); + } + public static DateWithOffset from(ZonedDateTime zonedDateTime) { Objects.requireNonNull(zonedDateTime, "zonedDateTime must not be null"); - return new DateWithOffset(zonedDateTime.toLocalDate(), zonedDateTime.getOffset()); + return new DateWithOffset(zonedDateTime.toLocalDate(), zonedDateTime.getOffset(), zonedDateTime.getZone()); + } + + public ZoneId resolveZoneId(ZoneId fallbackZoneId) { + Objects.requireNonNull(fallbackZoneId, "fallbackZoneId must not be null"); + return zoneId == null ? fallbackZoneId : zoneId; + } + + public ZoneOffset resolveOffset(ZoneId fallbackZoneId) { + ZoneId effectiveZone = resolveZoneId(fallbackZoneId); + return date.atStartOfDay(effectiveZone).getOffset(); } } From 7ddd7bfba4ab0b43d8a53e89f52ef292c0dcac30 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:10:10 +0900 Subject: [PATCH 22/42] =?UTF-8?q?feat:=20DateWithOffset=EC=97=90=EC=84=9C?= =?UTF-8?q?=20ZoneOffset=20=EC=83=9D=EC=84=B1=EC=9E=90=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20ZoneId=20=EC=82=AC=EC=9A=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java index 46ddeda..d52279a 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java @@ -26,10 +26,6 @@ public record DateWithOffset( Objects.requireNonNull(zoneId, "zoneId must not be null"); } - public DateWithOffset(LocalDate date, ZoneOffset offset) { - this(date, offset, null); - } - public static DateWithOffset from(ZonedDateTime zonedDateTime) { Objects.requireNonNull(zonedDateTime, "zonedDateTime must not be null"); return new DateWithOffset(zonedDateTime.toLocalDate(), zonedDateTime.getOffset(), zonedDateTime.getZone()); From 9871eba3e7121912a84591b6ed0adf3cb6b063de Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:11:33 +0900 Subject: [PATCH 23/42] =?UTF-8?q?feat:=20DateWithOffset=EC=97=90=EC=84=9C?= =?UTF-8?q?=20ZoneOffset=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20ZoneId=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/pinit/pinittask/interfaces/dto/DateWithOffset.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java index d52279a..94de1b4 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java @@ -13,7 +13,6 @@ public record DateWithOffset( @NotNull @Schema(description = "날짜", example = "2024-03-01") LocalDate date, - @NotNull @Schema(description = "UTC 기준 오프셋(+HH:mm)", example = "+09:00") ZoneOffset offset, @NotNull @@ -22,10 +21,13 @@ public record DateWithOffset( ) { public DateWithOffset { Objects.requireNonNull(date, "date must not be null"); - Objects.requireNonNull(offset, "offset must not be null"); Objects.requireNonNull(zoneId, "zoneId must not be null"); } + public DateWithOffset(LocalDate date, ZoneId zoneId) { + this(date, null, zoneId); + } + public static DateWithOffset from(ZonedDateTime zonedDateTime) { Objects.requireNonNull(zonedDateTime, "zonedDateTime must not be null"); return new DateWithOffset(zonedDateTime.toLocalDate(), zonedDateTime.getOffset(), zonedDateTime.getZone()); From 4a20d11a980cd5a82ba61d0c81c7bf507d05fe67 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:11:56 +0900 Subject: [PATCH 24/42] =?UTF-8?q?feat:=20DateTimeWithZone=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Instant=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=8B=9C=EA=B0=84=20=EC=A0=95=EB=B3=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pinittask/interfaces/dto/DateTimeWithZone.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateTimeWithZone.java b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateTimeWithZone.java index 5a8ca29..8e57ce3 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateTimeWithZone.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateTimeWithZone.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -10,14 +11,20 @@ public record DateTimeWithZone( @NotNull - @Schema(description = "지역 시각", example = "2024-03-01T18:00:00") + @Schema(description = "사용자 로컬 시각(오프셋 없이). `toISOString()`이 아닌 로컬 기준 문자열을 사용", example = "2026-02-01T10:00:00") LocalDateTime dateTime, @NotNull - @Schema(description = "시간대 ID", example = "Asia/Seoul") + @Schema(description = "IANA 시간대 ID", example = "Asia/Seoul") ZoneId zoneId ) { public static DateTimeWithZone from(ZonedDateTime zonedDateTime) { Objects.requireNonNull(zonedDateTime, "zonedDateTime must not be null"); return new DateTimeWithZone(zonedDateTime.toLocalDateTime(), zonedDateTime.getZone()); } + + public static DateTimeWithZone from(Instant instant, ZoneId zoneId) { + Objects.requireNonNull(instant, "instant must not be null"); + Objects.requireNonNull(zoneId, "zoneId must not be null"); + return from(ZonedDateTime.ofInstant(instant, zoneId)); + } } From 0892be7d2e4f9224226651a1d370b271bb9b6182 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:12:40 +0900 Subject: [PATCH 25/42] =?UTF-8?q?feat:=20ScheduleResponse=20=EB=B0=8F=20Sc?= =?UTF-8?q?heduleSimpleResponse=EC=97=90=EC=84=9C=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20UTC=EB=A1=9C=20=EB=B3=80=ED=99=98?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/schedule/dto/ScheduleResponse.java | 9 ++++++++- .../schedule/dto/ScheduleSimpleResponse.java | 12 +++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleResponse.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleResponse.java index c4cbda0..c013312 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleResponse.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleResponse.java @@ -9,6 +9,8 @@ import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; import java.time.Duration; +import java.time.ZoneId; +import java.util.Objects; public record ScheduleResponse( @Schema(description = "일정 ID", example = "10") @@ -37,6 +39,11 @@ public record ScheduleResponse( String state ) { public static ScheduleResponse from(Schedule schedule, Task task) { + return from(schedule, task, schedule.getDesignatedStartTime().getZone()); + } + + public static ScheduleResponse from(Schedule schedule, Task task, ZoneId viewZoneId) { + Objects.requireNonNull(viewZoneId, "viewZoneId must not be null"); TemporalConstraint temporal = task == null ? null : task.getTemporalConstraint(); ImportanceConstraint importanceConstraint = task == null ? null : task.getImportanceConstraint(); ScheduleHistory history = schedule.getHistory(); @@ -46,7 +53,7 @@ public static ScheduleResponse from(Schedule schedule, Task task) { task == null ? null : task.getId(), schedule.getTitle(), schedule.getDescription(), - DateTimeWithZone.from(schedule.getDesignatedStartTime()), + DateTimeWithZone.from(schedule.getDesignatedStartTime().withZoneSameInstant(viewZoneId)), schedule.getScheduleType().name(), temporal == null ? null : DateTimeWithZone.from(temporal.getDeadline()), importanceConstraint == null ? null : importanceConstraint.getImportance(), diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimpleResponse.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimpleResponse.java index 68524d2..47df843 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimpleResponse.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimpleResponse.java @@ -8,6 +8,8 @@ import java.time.Duration; import java.time.Instant; +import java.time.ZoneId; +import java.util.Objects; public record ScheduleSimpleResponse( @Schema(description = "일정 ID", example = "10") @@ -36,10 +38,15 @@ public record ScheduleSimpleResponse( Instant updatedAt ) { public static ScheduleSimpleResponse from(Schedule schedule) { - return from(schedule, null); + return from(schedule, null, schedule.getDesignatedStartTime().getZone()); } public static ScheduleSimpleResponse from(Schedule schedule, Task task) { + return from(schedule, task, schedule.getDesignatedStartTime().getZone()); + } + + public static ScheduleSimpleResponse from(Schedule schedule, Task task, ZoneId viewZoneId) { + Objects.requireNonNull(viewZoneId, "viewZoneId must not be null"); ScheduleHistory history = schedule.getHistory(); return new ScheduleSimpleResponse( schedule.getId(), @@ -48,7 +55,7 @@ public static ScheduleSimpleResponse from(Schedule schedule, Task task) { task == null ? null : task.isCompleted(), schedule.getTitle(), schedule.getDescription(), - DateTimeWithZone.from(schedule.getDesignatedStartTime()), + DateTimeWithZone.from(schedule.getDesignatedStartTime().withZoneSameInstant(viewZoneId)), schedule.getScheduleType().name(), history.getElapsedTime(), schedule.getState(), @@ -57,4 +64,3 @@ public static ScheduleSimpleResponse from(Schedule schedule, Task task) { ); } } - From 4a803e7d3f03e4878273226296398799419c0e73 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:14:50 +0900 Subject: [PATCH 26/42] =?UTF-8?q?feat:=20ScheduleControllerV2=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=20=EB=B0=8F=20=EC=8B=9C=EA=B0=84=EB=8C=80=20?= =?UTF-8?q?ID=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/ScheduleControllerV2.java | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV2.java index eeb3c62..3983ac1 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV2.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV2.java @@ -11,6 +11,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; +import me.gg.pinit.pinittask.application.member.service.MemberService; import me.gg.pinit.pinittask.application.schedule.service.ScheduleService; import me.gg.pinit.pinittask.application.schedule.service.ScheduleStateChangeService; import me.gg.pinit.pinittask.application.task.service.TaskService; @@ -48,6 +49,7 @@ public class ScheduleControllerV2 { private final ScheduleService scheduleService; private final ScheduleStateChangeService scheduleStateChangeService; private final TaskService taskService; + private final MemberService memberService; @PostMapping @Operation(summary = "일정 생성 (작업 없이)", description = "작업과 연결하지 않는 단순 일정을 등록합니다.") @@ -58,7 +60,8 @@ public class ScheduleControllerV2 { public ResponseEntity createSchedule(@Parameter(hidden = true) @MemberId Long memberId, @Valid @RequestBody ScheduleSimpleRequest request) { Schedule saved = scheduleService.addSchedule(request.toSchedule(memberId, dateTimeUtils)); - return ResponseEntity.status(HttpStatus.CREATED).body(ScheduleSimpleResponse.from(saved)); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ScheduleSimpleResponse.from(saved, null, request.date().zoneId())); } @GetMapping @@ -68,7 +71,9 @@ public ResponseEntity createSchedule(@Parameter(hidden = @ApiResponse(responseCode = "400", description = "날짜 형식이 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) public List getSchedules(@Parameter(hidden = true) @MemberId Long memberId, + @Parameter(description = "사용자 로컬 시각. 반드시 TZ 없이 직렬화한 LocalDateTime 사용 금지 — 아래 zoneId와 함께 보낸다.", example = "2026-02-01T09:00:00") @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, + @Parameter(description = "IANA 시간대 ID", example = "Asia/Seoul") @RequestParam ZoneId zoneId) { List schedules = scheduleService.getScheduleList(memberId, dateTimeUtils.toZonedDateTime(time, zoneId)); Map taskMap = taskService.findTasksByIds(memberId, schedules.stream() @@ -78,7 +83,7 @@ public List getSchedules(@Parameter(hidden = true) @Memb .stream() .collect(Collectors.toMap(Task::getId, Function.identity())); return schedules.stream() - .map(schedule -> ScheduleSimpleResponse.from(schedule, taskMap.get(schedule.getTaskId()))) + .map(schedule -> ScheduleSimpleResponse.from(schedule, taskMap.get(schedule.getTaskId()), zoneId)) .toList(); } @@ -89,7 +94,9 @@ public List getSchedules(@Parameter(hidden = true) @Memb @ApiResponse(responseCode = "400", description = "날짜 형식이 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) public List getWeeklySchedules(@Parameter(hidden = true) @MemberId Long memberId, + @Parameter(description = "사용자 로컬 시각. 주차 계산의 기준 anchor.", example = "2026-02-03T10:00:00") @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, + @Parameter(description = "IANA 시간대 ID", example = "America/Los_Angeles") @RequestParam ZoneId zoneId) { List schedules = scheduleService.getScheduleListForWeek(memberId, dateTimeUtils.toZonedDateTime(time, zoneId)); Map taskMap = taskService.findTasksByIds(memberId, schedules.stream() @@ -99,7 +106,7 @@ public List getWeeklySchedules(@Parameter(hidden = true) .stream() .collect(Collectors.toMap(Task::getId, Function.identity())); return schedules.stream() - .map(schedule -> ScheduleSimpleResponse.from(schedule, taskMap.get(schedule.getTaskId()))) + .map(schedule -> ScheduleSimpleResponse.from(schedule, taskMap.get(schedule.getTaskId()), zoneId)) .toList(); } @@ -115,7 +122,8 @@ public ScheduleSimpleResponse getSchedule(@Parameter(hidden = true) @MemberId Lo if (schedule.getTaskId() != null) { task = taskService.getTask(memberId, schedule.getTaskId()); } - return ScheduleSimpleResponse.from(schedule, task); + ZoneId viewZone = memberService.findZoneIdOfMember(memberId); + return ScheduleSimpleResponse.from(schedule, task, viewZone); } @PatchMapping("/{scheduleId}") @@ -130,7 +138,8 @@ public ResponseEntity updateSchedule(@Parameter(hidden = @PathVariable Long scheduleId, @RequestBody @Valid ScheduleSimplePatchRequest request) { Schedule updated = scheduleService.updateSchedule(memberId, scheduleId, request.toPatch(dateTimeUtils)); - return ResponseEntity.ok(ScheduleSimpleResponse.from(updated)); + ZoneId viewZone = request.date() != null ? request.date().zoneId() : memberService.findZoneIdOfMember(memberId); + return ResponseEntity.ok(ScheduleSimpleResponse.from(updated, null, viewZone)); } @PostMapping("/{scheduleId}/start") @@ -141,7 +150,9 @@ public ResponseEntity updateSchedule(@Parameter(hidden = @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) public ResponseEntity startSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId, + @Parameter(description = "사용자 로컬 시각", example = "2026-02-01T09:00:00") @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, + @Parameter(description = "IANA 시간대 ID", example = "Asia/Seoul") @RequestParam ZoneId zoneId) { scheduleStateChangeService.startSchedule(memberId, scheduleId, dateTimeUtils.toZonedDateTime(time, zoneId)); return ResponseEntity.noContent().build(); @@ -155,7 +166,9 @@ public ResponseEntity startSchedule(@Parameter(hidden = true) @MemberId Lo @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) public ResponseEntity completeSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId, + @Parameter(description = "사용자 로컬 시각", example = "2026-02-01T10:30:00") @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, + @Parameter(description = "IANA 시간대 ID", example = "Asia/Seoul") @RequestParam ZoneId zoneId) { scheduleStateChangeService.completeSchedule(memberId, scheduleId, dateTimeUtils.toZonedDateTime(time, zoneId)); return ResponseEntity.noContent().build(); @@ -169,7 +182,9 @@ public ResponseEntity completeSchedule(@Parameter(hidden = true) @MemberId @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) public ResponseEntity suspendSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId, + @Parameter(description = "사용자 로컬 시각", example = "2026-02-01T11:00:00") @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, + @Parameter(description = "IANA 시간대 ID", example = "Asia/Seoul") @RequestParam ZoneId zoneId) { scheduleStateChangeService.suspendSchedule(memberId, scheduleId, dateTimeUtils.toZonedDateTime(time, zoneId)); return ResponseEntity.noContent().build(); From ea25fbd4e31c8c2cbf8aabb4cffd71809f8194c8 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:15:42 +0900 Subject: [PATCH 27/42] =?UTF-8?q?feat:=20Deprecated=20Controller=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/ScheduleControllerAdvice.java | 2 +- .../schedule/ScheduleControllerV0.java | 203 ------------------ .../schedule/ScheduleControllerV1.java | 203 ------------------ .../statistics/StatisticsControllerV0.java | 48 ----- .../statistics/StatisticsControllerV1.java | 49 ----- .../interfaces/task/TaskControllerAdvice.java | 3 +- .../interfaces/task/TaskControllerV1.java | 195 ----------------- 7 files changed, 2 insertions(+), 701 deletions(-) delete mode 100644 src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV0.java delete mode 100644 src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV1.java delete mode 100644 src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV0.java delete mode 100644 src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV1.java delete mode 100644 src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV1.java diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerAdvice.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerAdvice.java index 7f4499e..0062104 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerAdvice.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerAdvice.java @@ -22,7 +22,7 @@ @Slf4j @RestControllerAdvice(assignableTypes = { - ScheduleControllerV2.class, ScheduleControllerV1.class + ScheduleControllerV2.class }) public class ScheduleControllerAdvice { @ExceptionHandler({ diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV0.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV0.java deleted file mode 100644 index d02b50e..0000000 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV0.java +++ /dev/null @@ -1,203 +0,0 @@ -package me.gg.pinit.pinittask.interfaces.schedule; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; -import me.gg.pinit.pinittask.application.schedule.service.ScheduleAdjustmentService; -import me.gg.pinit.pinittask.application.schedule.service.ScheduleService; -import me.gg.pinit.pinittask.application.schedule.service.ScheduleStateChangeService; -import me.gg.pinit.pinittask.application.task.service.TaskService; -import me.gg.pinit.pinittask.domain.schedule.model.Schedule; -import me.gg.pinit.pinittask.domain.task.model.Task; -import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; -import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleRequest; -import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleResponse; -import me.gg.pinit.pinittask.interfaces.utils.MemberId; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.function.Function; -import java.util.stream.Collectors; - -@RestController -@RequestMapping("/v0/schedules") -@RequiredArgsConstructor -@Tag(name = "Schedule", description = "일정 관리 API") -@ApiResponses({ - @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "404", description = "대상을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "현재 상태와 충돌했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) -}) -@Deprecated -public class ScheduleControllerV0 { - private final DateTimeUtils dateTimeUtils; - private final ScheduleService scheduleService; - private final ScheduleAdjustmentService scheduleAdjustmentService; - private final ScheduleStateChangeService scheduleStateChangeService; - private final TaskService taskService; - - @PostMapping - @Operation(summary = "일정 생성", description = "새 일정과 의존 관계를 등록합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "201", description = "일정이 성공적으로 생성되었습니다."), - @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity createSchedule(@Parameter(hidden = true) @MemberId Long memberId, - @Valid @RequestBody ScheduleRequest request) { - Schedule saved = scheduleAdjustmentService.createScheduleLegacy(memberId, request.toCommand(null, memberId, dateTimeUtils)); - Task task = loadTask(saved); - return ResponseEntity.status(HttpStatus.CREATED).body(ScheduleResponse.from(saved, task)); - } - - @PatchMapping("/{scheduleId}") - @Operation(summary = "일정 수정", description = "일정 본문과 의존 관계를 함께 수정합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "일정이 수정되었습니다."), - @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity updateSchedule(@Parameter(hidden = true) @MemberId Long memberId, - @PathVariable Long scheduleId, - @Valid @RequestBody ScheduleRequest request) { - Schedule updated = scheduleAdjustmentService.adjustSchedule(memberId, request.toCommand(scheduleId, memberId, dateTimeUtils)); - Task task = loadTask(updated); - return ResponseEntity.ok(ScheduleResponse.from(updated, task)); - } - - @GetMapping - @Operation(summary = "일정 목록 조회", description = "지정한 날짜의 일정을 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "목록 조회에 성공했습니다."), - @ApiResponse(responseCode = "400", description = "날짜 형식이 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public List getSchedules(@Parameter(hidden = true) @MemberId Long memberId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, - @RequestParam ZoneId zoneId) { - List schedules = scheduleService.getScheduleList(memberId, dateTimeUtils.toZonedDateTime(time, zoneId)); - Map taskMap = loadTaskMap(memberId, schedules); - return schedules.stream() - .map(schedule -> ScheduleResponse.from(schedule, taskMap.get(schedule.getTaskId()))) - .toList(); - } - - @GetMapping("/{scheduleId}") - @Operation(summary = "일정 단건 조회", description = "특정 일정의 상세 정보를 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "단건 조회에 성공했습니다."), - @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ScheduleResponse getSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId) { - Schedule schedule = scheduleService.getSchedule(memberId, scheduleId); - Task task = loadTask(schedule); - return ScheduleResponse.from(schedule, task); - } - - @GetMapping("/week") - @Operation(summary = "주간 일정 조회", description = "해당 주의 일정을 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "주간 일정 조회에 성공했습니다."), - @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public List getWeeklySchedules(@Parameter(hidden = true) @MemberId Long memberId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, - @RequestParam ZoneId zoneId) { - List schedules = scheduleService.getScheduleListForWeek(memberId, dateTimeUtils.toZonedDateTime(time, zoneId)); - Map taskMap = loadTaskMap(memberId, schedules); - return schedules.stream() - .map(schedule -> ScheduleResponse.from(schedule, taskMap.get(schedule.getTaskId()))) - .toList(); - } - - @PostMapping("/{scheduleId}/start") - @Operation(summary = "일정 시작", description = "일정을 진행 중 상태로 변경합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "일정이 시작되었습니다."), - @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity startSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, - @RequestParam ZoneId zoneId) { - scheduleStateChangeService.startSchedule(memberId, scheduleId, dateTimeUtils.toZonedDateTime(time, zoneId)); - return ResponseEntity.noContent().build(); - } - - @PostMapping("/{scheduleId}/complete") - @Operation(summary = "일정 완료", description = "진행 중인 일정을 완료 처리합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "일정이 완료되었습니다."), - @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity completeSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, - @RequestParam ZoneId zoneId) { - scheduleStateChangeService.completeSchedule(memberId, scheduleId, dateTimeUtils.toZonedDateTime(time, zoneId)); - return ResponseEntity.noContent().build(); - } - - @PostMapping("/{scheduleId}/suspend") - @Operation(summary = "일정 일시중지", description = "진행 중인 일정을 일시중지합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "일정이 일시중지되었습니다."), - @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity suspendSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, - @RequestParam ZoneId zoneId) { - scheduleStateChangeService.suspendSchedule(memberId, scheduleId, dateTimeUtils.toZonedDateTime(time, zoneId)); - return ResponseEntity.noContent().build(); - } - - @PostMapping("/{scheduleId}/cancel") - @Operation(summary = "일정 취소", description = "예정되었거나 진행 중인 일정을 취소합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "일정이 취소되었습니다."), - @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity cancelSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId) { - scheduleStateChangeService.cancelSchedule(memberId, scheduleId); - return ResponseEntity.noContent().build(); - } - - @DeleteMapping("/{scheduleId}") - @Operation(summary = "일정 삭제", description = "지정한 일정을 삭제합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "일정이 삭제되었습니다."), - @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity deleteSchedule(@Parameter(hidden = true) @MemberId Long memberId, - @PathVariable Long scheduleId) { - scheduleService.deleteSchedule(memberId, scheduleId); - return ResponseEntity.noContent().build(); - } - - private Task loadTask(Schedule schedule) { - if (schedule.getTaskId() == null) { - return null; - } - return taskService.getTask(schedule.getOwnerId(), schedule.getTaskId()); - } - - private Map loadTaskMap(Long memberId, List schedules) { - List taskIds = schedules.stream() - .map(Schedule::getTaskId) - .filter(Objects::nonNull) - .distinct() - .toList(); - return taskService.findTasksByIds(memberId, taskIds).stream() - .collect(Collectors.toMap(Task::getId, Function.identity())); - } -} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV1.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV1.java deleted file mode 100644 index 53459ac..0000000 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV1.java +++ /dev/null @@ -1,203 +0,0 @@ -package me.gg.pinit.pinittask.interfaces.schedule; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; -import me.gg.pinit.pinittask.application.schedule.service.ScheduleService; -import me.gg.pinit.pinittask.application.schedule.service.ScheduleStateChangeService; -import me.gg.pinit.pinittask.application.task.service.TaskService; -import me.gg.pinit.pinittask.domain.schedule.model.Schedule; -import me.gg.pinit.pinittask.domain.task.model.Task; -import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; -import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimplePatchRequest; -import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleRequest; -import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleResponse; -import me.gg.pinit.pinittask.interfaces.utils.MemberId; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; - -@Deprecated -@RestController -@RequestMapping("/v1/schedules") -@RequiredArgsConstructor -@Tag(name = "ScheduleV1", description = "작업과 분리된 일정 관리 API") -@ApiResponses({ - @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "404", description = "대상을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "현재 상태와 충돌했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) -}) -public class ScheduleControllerV1 { - private final DateTimeUtils dateTimeUtils; - private final ScheduleService scheduleService; - private final ScheduleStateChangeService scheduleStateChangeService; - private final TaskService taskService; - - @PostMapping - @Operation(summary = "일정 생성 (작업 없이)", description = "작업과 연결하지 않는 단순 일정을 등록합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "201", description = "일정이 생성되었습니다.", content = @Content(schema = @Schema(implementation = ScheduleSimpleResponse.class))), - @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity createSchedule(@Parameter(hidden = true) @MemberId Long memberId, - @Valid @RequestBody ScheduleSimpleRequest request) { - Schedule saved = scheduleService.addSchedule(request.toSchedule(memberId, dateTimeUtils)); - return ResponseEntity.status(HttpStatus.CREATED).body(ScheduleSimpleResponse.from(saved)); - } - - @GetMapping - @Operation(summary = "일정 목록 조회 (작업 없이)", description = "지정한 날짜의 일정을 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "일정 목록 조회 성공", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ScheduleSimpleResponse.class)))), - @ApiResponse(responseCode = "400", description = "날짜 형식이 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public List getSchedules(@Parameter(hidden = true) @MemberId Long memberId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, - @RequestParam ZoneId zoneId) { - List schedules = scheduleService.getScheduleList(memberId, dateTimeUtils.toZonedDateTime(time, zoneId)); - Map taskMap = taskService.findTasksByIds(memberId, schedules.stream() - .map(Schedule::getTaskId) - .filter(id -> id != null) - .toList()) - .stream() - .collect(Collectors.toMap(Task::getId, Function.identity())); - return schedules.stream() - .map(schedule -> ScheduleSimpleResponse.from(schedule, taskMap.get(schedule.getTaskId()))) - .toList(); - } - - @GetMapping("/week") - @Operation(summary = "주간 일정 조회 (작업 없이)", description = "주어진 날짜가 포함된 주간의 일정을 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "주간 일정 조회 성공", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ScheduleSimpleResponse.class)))), - @ApiResponse(responseCode = "400", description = "날짜 형식이 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public List getWeeklySchedules(@Parameter(hidden = true) @MemberId Long memberId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, - @RequestParam ZoneId zoneId) { - List schedules = scheduleService.getScheduleListForWeek(memberId, dateTimeUtils.toZonedDateTime(time, zoneId)); - Map taskMap = taskService.findTasksByIds(memberId, schedules.stream() - .map(Schedule::getTaskId) - .filter(id -> id != null) - .toList()) - .stream() - .collect(Collectors.toMap(Task::getId, Function.identity())); - return schedules.stream() - .map(schedule -> ScheduleSimpleResponse.from(schedule, taskMap.get(schedule.getTaskId()))) - .toList(); - } - - @GetMapping("/{scheduleId}") - @Operation(summary = "일정 단건 조회 (작업 없이)") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "일정 단건 조회 성공", content = @Content(schema = @Schema(implementation = ScheduleSimpleResponse.class))), - @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ScheduleSimpleResponse getSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId) { - Schedule schedule = scheduleService.getSchedule(memberId, scheduleId); - Task task = null; - if (schedule.getTaskId() != null) { - task = taskService.getTask(memberId, schedule.getTaskId()); - } - return ScheduleSimpleResponse.from(schedule, task); - } - - @PatchMapping("/{scheduleId}") - @Operation(summary = "일정 수정 (작업 없이)") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "일정이 수정되었습니다.", content = @Content(schema = @Schema(implementation = ScheduleSimpleResponse.class))), - @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "현재 상태와 충돌했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity updateSchedule(@Parameter(hidden = true) @MemberId Long memberId, - @PathVariable Long scheduleId, - @RequestBody @Valid ScheduleSimplePatchRequest request) { - Schedule updated = scheduleService.updateSchedule(memberId, scheduleId, request.toPatch(dateTimeUtils)); - return ResponseEntity.ok(ScheduleSimpleResponse.from(updated)); - } - - @PostMapping("/{scheduleId}/start") - @Operation(summary = "일정 시작 (작업 없이)") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "일정이 시작되었습니다."), - @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity startSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, - @RequestParam ZoneId zoneId) { - scheduleStateChangeService.startSchedule(memberId, scheduleId, dateTimeUtils.toZonedDateTime(time, zoneId)); - return ResponseEntity.noContent().build(); - } - - @PostMapping("/{scheduleId}/complete") - @Operation(summary = "일정 완료 (작업 없이)") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "일정이 완료되었습니다."), - @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity completeSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, - @RequestParam ZoneId zoneId) { - scheduleStateChangeService.completeSchedule(memberId, scheduleId, dateTimeUtils.toZonedDateTime(time, zoneId)); - return ResponseEntity.noContent().build(); - } - - @PostMapping("/{scheduleId}/suspend") - @Operation(summary = "일정 일시중지 (작업 없이)") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "일정이 일시중지되었습니다."), - @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity suspendSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, - @RequestParam ZoneId zoneId) { - scheduleStateChangeService.suspendSchedule(memberId, scheduleId, dateTimeUtils.toZonedDateTime(time, zoneId)); - return ResponseEntity.noContent().build(); - } - - @PostMapping("/{scheduleId}/cancel") - @Operation(summary = "일정 취소 (작업 없이)") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "일정이 취소되었습니다."), - @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity cancelSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId) { - scheduleStateChangeService.cancelSchedule(memberId, scheduleId); - return ResponseEntity.noContent().build(); - } - - @DeleteMapping("/{scheduleId}") - @Operation(summary = "일정 삭제 (작업 없이)") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "일정이 삭제되었습니다."), - @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity deleteSchedule(@Parameter(hidden = true) @MemberId Long memberId, - @PathVariable Long scheduleId) { - scheduleService.deleteSchedule(memberId, scheduleId); - return ResponseEntity.noContent().build(); - } -} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV0.java b/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV0.java deleted file mode 100644 index a50d0aa..0000000 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV0.java +++ /dev/null @@ -1,48 +0,0 @@ -package me.gg.pinit.pinittask.interfaces.statistics; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; -import me.gg.pinit.pinittask.application.statistics.service.StatisticsService; -import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; -import me.gg.pinit.pinittask.interfaces.statistics.dto.StatisticsResponse; -import me.gg.pinit.pinittask.interfaces.utils.MemberId; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.time.LocalDateTime; -import java.time.ZoneId; - -@Deprecated -@RestController -@RequestMapping("/v0/statistics") -@Tag(name = "Statistics", description = "통계 조회 API") -public class StatisticsControllerV0 { - private final StatisticsService statisticsService; - private final DateTimeUtils dateTimeUtils; - - public StatisticsControllerV0(StatisticsService statisticsService, DateTimeUtils dateTimeUtils) { - this.statisticsService = statisticsService; - this.dateTimeUtils = dateTimeUtils; - } - - @GetMapping - @Operation(summary = "사용자 통계 조회", description = "주어진 시점 기준 주간 통계를 조회합니다.") - @ApiResponse(responseCode = "200", description = "통계 조회 성공") - @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - @ApiResponse(responseCode = "404", description = "통계를 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - public StatisticsResponse getStatistics( - @Parameter(hidden = true) @MemberId Long memberId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, - @RequestParam ZoneId zoneId - ) { - return StatisticsResponse.from(statisticsService.getStatistics(memberId, dateTimeUtils.toZonedDateTime(time, zoneId))); - } -} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV1.java b/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV1.java deleted file mode 100644 index b93da49..0000000 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV1.java +++ /dev/null @@ -1,49 +0,0 @@ -package me.gg.pinit.pinittask.interfaces.statistics; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; -import me.gg.pinit.pinittask.application.statistics.service.StatisticsService; -import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; -import me.gg.pinit.pinittask.interfaces.statistics.dto.StatisticsResponse; -import me.gg.pinit.pinittask.interfaces.utils.MemberId; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.time.LocalDateTime; -import java.time.ZoneId; - -@Deprecated -@RestController -@RequestMapping("/v1/statistics") -@Tag(name = "StatisticsV1", description = "통계 조회 API (v1)") -@RequiredArgsConstructor -public class StatisticsControllerV1 { - - private final StatisticsService statisticsService; - private final DateTimeUtils dateTimeUtils; - - @GetMapping - @Operation(summary = "사용자 통계 조회", description = "주어진 시점 기준 주간 통계를 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "통계 조회 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "404", description = "통계를 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public StatisticsResponse getStatistics( - @Parameter(hidden = true) @MemberId Long memberId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, - @RequestParam ZoneId zoneId - ) { - return StatisticsResponse.from(statisticsService.getStatistics(memberId, dateTimeUtils.toZonedDateTime(time, zoneId))); - } -} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerAdvice.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerAdvice.java index f396e9f..a98dd6c 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerAdvice.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerAdvice.java @@ -15,8 +15,7 @@ @Slf4j @RestControllerAdvice(assignableTypes = { - TaskControllerV2.class, - TaskControllerV1.class + TaskControllerV2.class }) public class TaskControllerAdvice { diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV1.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV1.java deleted file mode 100644 index a5b7c01..0000000 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV1.java +++ /dev/null @@ -1,195 +0,0 @@ -package me.gg.pinit.pinittask.interfaces.task; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; -import me.gg.pinit.pinittask.application.dependency.service.DependencyService; -import me.gg.pinit.pinittask.application.schedule.service.ScheduleService; -import me.gg.pinit.pinittask.application.task.service.TaskAdjustmentService; -import me.gg.pinit.pinittask.application.task.service.TaskService; -import me.gg.pinit.pinittask.domain.schedule.model.Schedule; -import me.gg.pinit.pinittask.domain.task.model.Task; -import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; -import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleResponse; -import me.gg.pinit.pinittask.interfaces.task.dto.*; -import me.gg.pinit.pinittask.interfaces.utils.MemberId; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDate; -import java.util.List; -@RestController -@RequestMapping("/v1/tasks") -@RequiredArgsConstructor -@Tag(name = "Task", description = "작업 관리 API") -@ApiResponses({ - @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "404", description = "대상을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "현재 상태와 충돌했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) -}) -@Deprecated -public class TaskControllerV1 { - private final DateTimeUtils dateTimeUtils; - private final DependencyService dependencyService; - private final TaskAdjustmentService taskAdjustmentService; - private final TaskService taskService; - private final ScheduleService scheduleService; - - @PostMapping - @Operation(summary = "작업 생성", description = "새 작업과 의존 관계를 등록합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "201", description = "작업이 생성되었습니다.", content = @Content(schema = @Schema(implementation = TaskResponse.class))), - @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "현재 상태와 충돌했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity createTask(@Parameter(hidden = true) @MemberId Long memberId, - @Valid @RequestBody TaskCreateRequest request) { - Task saved = taskAdjustmentService.createTask(memberId, request.toCommand(null, memberId, dateTimeUtils)); - return ResponseEntity.status(HttpStatus.CREATED).body(TaskResponse.from(saved)); - } - - @PatchMapping("/{taskId}") - @Operation(summary = "작업 수정", description = "작업 본문과 의존 관계를 함께 수정합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "작업이 수정되었습니다.", content = @Content(schema = @Schema(implementation = TaskResponse.class))), - @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "404", description = "작업을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "현재 상태와 충돌했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity updateTask(@Parameter(hidden = true) @MemberId Long memberId, - @PathVariable Long taskId, - @Valid @RequestBody TaskUpdateRequest request) { - Task updated = taskAdjustmentService.updateTask(memberId, request.toCommand(taskId, memberId, dateTimeUtils)); - return ResponseEntity.ok(TaskResponse.from(updated)); - } - - @GetMapping - @Operation(summary = "작업 목록 조회", description = """ - 회원의 작업 목록을 조회합니다. - 포함 대상: 미완료 작업 전체 + 오늘(회원 UTC 오프셋 기준) 이후 또는 오늘 마감인 완료 작업. - 정렬: 마감일 오름차순, page/size 페이징. - readyOnly=true면 선행 작업 없는 미완료 작업만 필터링합니다. - """) - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "작업 목록 조회 성공") - }) - public Page getTasks(@Parameter(hidden = true) @MemberId Long memberId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestParam(defaultValue = "false") boolean readyOnly) { - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Order.asc("temporalConstraint.deadline.date"))); - Page tasks = taskService.getTasks(memberId, pageable, readyOnly); - var dependencyMap = dependencyService.getDependencyInfoForTasks(memberId, tasks.getContent().stream().map(Task::getId).toList()); - return tasks.map(task -> TaskResponse.from(task, dependencyMap.get(task.getId()))); - } - - @GetMapping("/cursor") - @Operation(summary = "작업 목록 커서 조회", description = """ - 마감 날짜(자정 00:00:00) asc, id asc 커서 기반 페이지네이션. - 포함 대상: 미완료 작업 전체 + 오늘(회원 UTC 오프셋 기준) 이후 또는 오늘 마감인 완료 작업. - cursor 형식: 'YYYY-MM-DDTHH:MM:SS|taskId' (시간은 00:00:00 고정). 데이터가 없으면 nextCursor=null. - """) - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "커서 기반 작업 목록 조회 성공", content = @Content(schema = @Schema(implementation = TaskCursorPageResponse.class))), - @ApiResponse(responseCode = "400", description = "커서 형식이 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public TaskCursorPageResponse getTasksByCursor(@Parameter(hidden = true) @MemberId Long memberId, - @RequestParam(defaultValue = "20") int size, - @RequestParam(required = false) String cursor, - @RequestParam(defaultValue = "false") boolean readyOnly) { - return taskService.getTasksByCursor(memberId, size, cursor, readyOnly); - } - - @GetMapping("/by-deadline") - @Operation(summary = "마감 날짜 기준 작업 조회", description = """ - 특정 날짜(YYYY-MM-DD)에 마감이 설정된 작업들을 조회합니다. - 완료/미완료 상태와 무관하게 해당 날짜 마감인 모든 작업을 반환합니다. - """) - public List getTasksByDeadline(@Parameter(hidden = true) @MemberId Long memberId, - @Parameter(description = "마감 날짜", example = "2025-02-01") - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { - List tasks = taskService.getTasksByDeadline(memberId, date); - var dependencyInfoMap = dependencyService.getDependencyInfoForTasks(memberId, tasks.stream().map(Task::getId).toList()); - return tasks.stream() - .map(task -> TaskResponse.from(task, dependencyInfoMap.get(task.getId()))) - .toList(); - } - - @GetMapping("/{taskId}") - @Operation(summary = "작업 단건 조회", description = "특정 작업의 상세 정보를 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "작업 단건 조회 성공", content = @Content(schema = @Schema(implementation = TaskResponse.class))), - @ApiResponse(responseCode = "404", description = "작업을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public TaskResponse getTask(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long taskId) { - Task task = taskService.getTask(memberId, taskId); - var dependencyInfo = dependencyService.getDependencyInfo(memberId, taskId); - return TaskResponse.from(task, dependencyInfo); - } - - @PostMapping("/{taskId}/complete") - @Operation(summary = "작업 완료", description = "작업을 완료 상태로 변경합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "작업이 완료되었습니다."), - @ApiResponse(responseCode = "404", description = "작업을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity completeTask(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long taskId) { - taskService.markCompleted(memberId, taskId); - return ResponseEntity.noContent().build(); - } - - @PostMapping("/{taskId}/reopen") - @Operation(summary = "작업 되돌리기", description = "작업을 미완료 상태로 되돌립니다.") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "작업이 되돌려졌습니다."), - @ApiResponse(responseCode = "404", description = "작업을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity reopenTask(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long taskId) { - taskService.markIncomplete(memberId, taskId); - return ResponseEntity.noContent().build(); - } - - @DeleteMapping("/{taskId}") - @Operation(summary = "작업 삭제", description = "작업과 그 작업에 관련된 의존 관계를 삭제합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "작업이 삭제되었습니다."), - @ApiResponse(responseCode = "404", description = "작업을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity deleteTask(@Parameter(hidden = true) @MemberId Long memberId, - @PathVariable Long taskId, - @RequestParam(defaultValue = "false") boolean deleteSchedules) { - taskService.deleteTask(memberId, taskId, deleteSchedules); - return ResponseEntity.noContent().build(); - } - - @PostMapping("/{taskId}/schedules") - @Operation(summary = "작업을 일정으로 등록", description = "기존 작업을 지정한 시간의 일정으로 복사합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "201", description = "작업이 일정으로 등록되었습니다.", content = @Content(schema = @Schema(implementation = ScheduleSimpleResponse.class))), - @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "404", description = "작업을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - public ResponseEntity createScheduleFromTask(@Parameter(hidden = true) @MemberId Long memberId, - @PathVariable Long taskId, - @Valid @RequestBody TaskScheduleRequest request) { - Task task = taskService.getTask(memberId, taskId); - Schedule saved = scheduleService.addSchedule(request.toSchedule(task, memberId, dateTimeUtils)); - return ResponseEntity.status(HttpStatus.CREATED).body(ScheduleSimpleResponse.from(saved)); - } -} From 7823fae18a65f0ed78c8fb2beac86a79f59ae269 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:16:22 +0900 Subject: [PATCH 28/42] =?UTF-8?q?feat:=20Deprecated=20Controller=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScheduleControllerV1IntegrationTest.java | 160 --------------- .../web/ScheduleControllerV1Test.java | 105 ---------- .../web/TaskControllerV1IntegrationTest.java | 186 ------------------ .../interfaces/web/TaskControllerV1Test.java | 127 ------------ 4 files changed, 578 deletions(-) delete mode 100644 src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1IntegrationTest.java delete mode 100644 src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1Test.java delete mode 100644 src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1IntegrationTest.java delete mode 100644 src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1Test.java diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1IntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1IntegrationTest.java deleted file mode 100644 index 46d681b..0000000 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1IntegrationTest.java +++ /dev/null @@ -1,160 +0,0 @@ -package me.gg.pinit.pinittask.interfaces.web; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import me.gg.pinit.pinittask.domain.member.model.Member; -import me.gg.pinit.pinittask.domain.member.repository.MemberRepository; -import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; -import me.gg.pinit.pinittask.infrastructure.events.RabbitEventPublisher; -import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; -import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleRequest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.time.ZoneId; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles("test") -@Transactional -class ScheduleControllerV1IntegrationTest { - - private static final long MEMBER_ID = 1L; - private static final ZoneId MEMBER_ZONE = ZoneId.of("Asia/Seoul"); - - @Autowired - MockMvc mockMvc; - @Autowired - ObjectMapper objectMapper; - @Autowired - MemberRepository memberRepository; - - @MockitoBean - RabbitEventPublisher rabbitEventPublisher; - - @BeforeEach - void setUpMember() { - if (!memberRepository.existsById(MEMBER_ID)) { - memberRepository.save(new Member(MEMBER_ID, "tester", MEMBER_ZONE)); - } - } - - @Test - void createAndRetrieveSimpleSchedule() throws Exception { - ScheduleSimpleRequest request = new ScheduleSimpleRequest( - "팀 회의", - "주간 회의", - new DateTimeWithZone(LocalDateTime.of(2024, 1, 1, 9, 0), MEMBER_ZONE), - ScheduleType.DEEP_WORK - ); - - MvcResult createResult = mockMvc.perform(post("/v1/schedules") - .header("X-Member-Id", MEMBER_ID) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").isNumber()) - .andExpect(jsonPath("$.ownerId").value(MEMBER_ID)) - .andExpect(jsonPath("$.title").value("팀 회의")) - .andExpect(jsonPath("$.date.dateTime").value("2024-01-01T00:00:00")) - .andExpect(jsonPath("$.scheduleType").value("DEEP_WORK")) - .andExpect(jsonPath("$.state").value("NOT_STARTED")) - .andReturn(); - - JsonNode created = objectMapper.readTree(createResult.getResponse().getContentAsString()); - long scheduleId = created.get("id").asLong(); - assertThat(scheduleId).isPositive(); - - mockMvc.perform(get("/v1/schedules/{scheduleId}", scheduleId) - .header("X-Member-Id", MEMBER_ID)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(scheduleId)) - .andExpect(jsonPath("$.title").value("팀 회의")) - .andExpect(jsonPath("$.ownerId").value(MEMBER_ID)) - .andExpect(jsonPath("$.scheduleType").value("DEEP_WORK")); - - mockMvc.perform(get("/v1/schedules") - .header("X-Member-Id", MEMBER_ID) - .param("time", "2024-01-01T00:00:00") - .param("zoneId", MEMBER_ZONE.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(scheduleId)) - .andExpect(jsonPath("$[0].title").value("팀 회의")) - .andExpect(jsonPath("$[0].date.dateTime").value("2024-01-01T00:00:00")) - .andExpect(jsonPath("$[0].scheduleType").value("DEEP_WORK")); - } - - @Test - void completeSchedule_updatesStateToCompleted() throws Exception { - ScheduleSimpleRequest request = new ScheduleSimpleRequest( - "운동", - "아침 러닝", - new DateTimeWithZone(LocalDateTime.of(2024, 1, 2, 7, 0), MEMBER_ZONE), - ScheduleType.QUICK_TASK - ); - - JsonNode created = objectMapper.readTree( - mockMvc.perform(post("/v1/schedules") - .header("X-Member-Id", MEMBER_ID) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andReturn() - .getResponse() - .getContentAsString() - ); - long scheduleId = created.get("id").asLong(); - - mockMvc.perform(post("/v1/schedules/{scheduleId}/complete", scheduleId) - .header("X-Member-Id", MEMBER_ID) - .param("time", "2024-01-02T07:00:00") - .param("zoneId", MEMBER_ZONE.getId())) - .andExpect(status().isNoContent()); - - mockMvc.perform(get("/v1/schedules/{scheduleId}", scheduleId) - .header("X-Member-Id", MEMBER_ID)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.state").value("COMPLETED")); - } - - @Test - void createSchedule_validationErrors_returnDetailedErrors() throws Exception { - String payload = """ - { - "title": " ", - "description": "", - "date": null, - "scheduleType": null - } - """; - - mockMvc.perform(post("/v1/schedules") - .header("X-Member-Id", MEMBER_ID) - .contentType(MediaType.APPLICATION_JSON) - .content(payload)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation failed for 4 field(s)")) - .andExpect(jsonPath("$.errors", hasSize(4))) - .andExpect(jsonPath("$.errors[?(@.field=='title')].reason", hasItem(containsString("must not be blank")))) - .andExpect(jsonPath("$.errors[?(@.field=='description')].reason", hasItem(containsString("must not be blank")))) - .andExpect(jsonPath("$.errors[?(@.field=='date')].reason", hasItem(containsString("must not be null")))) - .andExpect(jsonPath("$.errors[?(@.field=='scheduleType')].reason", hasItem(containsString("must not be null")))); - } -} diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1Test.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1Test.java deleted file mode 100644 index 8a6e103..0000000 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1Test.java +++ /dev/null @@ -1,105 +0,0 @@ -package me.gg.pinit.pinittask.interfaces.web; - -import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; -import me.gg.pinit.pinittask.application.schedule.service.ScheduleService; -import me.gg.pinit.pinittask.application.schedule.service.ScheduleStateChangeService; -import me.gg.pinit.pinittask.application.task.service.TaskService; -import me.gg.pinit.pinittask.domain.schedule.model.Schedule; -import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; -import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; -import me.gg.pinit.pinittask.interfaces.schedule.ScheduleControllerV1; -import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleRequest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.lang.reflect.Field; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class ScheduleControllerV1Test { - - @Mock - ScheduleService scheduleService; - @Mock - ScheduleStateChangeService scheduleStateChangeService; - @Mock - TaskService taskService; - DateTimeUtils dateTimeUtils = new DateTimeUtils(); - - @InjectMocks - ScheduleControllerV1 controller; - - Long memberId; - - @BeforeEach - void setUp() { - memberId = 1L; - controller = new ScheduleControllerV1(dateTimeUtils, scheduleService, scheduleStateChangeService, taskService); - } - - @Test - void createSchedule_returnsCreatedResponse() throws Exception { - ScheduleSimpleRequest request = new ScheduleSimpleRequest( - "title", - "desc", - new DateTimeWithZone(LocalDateTime.of(2024, 1, 1, 9, 0), ZoneId.of("UTC")), - ScheduleType.DEEP_WORK - ); - Schedule saved = new Schedule(memberId, null, request.title(), request.description(), - dateTimeUtils.toZonedDateTime(request.date().dateTime(), request.date().zoneId()), - request.scheduleType()); - setScheduleId(saved, 99L); - when(scheduleService.addSchedule(any(Schedule.class))).thenReturn(saved); - - ResponseEntity response = controller.createSchedule(memberId, request); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(response.getBody()).isNotNull(); - verify(scheduleService).addSchedule(any(Schedule.class)); - } - - @Test - void deleteSchedule_deletesScheduleOnly() throws Exception { - Long scheduleId = 77L; - - ResponseEntity response = controller.deleteSchedule(memberId, scheduleId); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - verify(scheduleService).deleteSchedule(memberId, scheduleId); - } - - @Test - void getSchedules_returnsScheduleList() { - ZonedDateTime zdt = ZonedDateTime.of(LocalDateTime.of(2024, 1, 2, 9, 0), ZoneId.of("Asia/Seoul")); - Schedule schedule = new Schedule(memberId, null, "title", "desc", zdt, ScheduleType.DEEP_WORK); - when(scheduleService.getScheduleList(eq(memberId), any())).thenReturn(List.of(schedule)); - - var result = controller.getSchedules(memberId, zdt.toLocalDateTime(), zdt.getZone()); - - assertThat(result).hasSize(1); - assertThat(result.get(0).title()).isEqualTo("title"); - ArgumentCaptor zdtCaptor = ArgumentCaptor.forClass(ZonedDateTime.class); - verify(scheduleService).getScheduleList(eq(memberId), zdtCaptor.capture()); - assertThat(zdtCaptor.getValue().toLocalDate()).isEqualTo(zdt.toLocalDate()); - } - - private void setScheduleId(Schedule schedule, Long id) throws Exception { - Field idField = Schedule.class.getDeclaredField("id"); - idField.setAccessible(true); - idField.set(schedule, id); - } -} diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1IntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1IntegrationTest.java deleted file mode 100644 index e75e0d2..0000000 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1IntegrationTest.java +++ /dev/null @@ -1,186 +0,0 @@ -package me.gg.pinit.pinittask.interfaces.web; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import me.gg.pinit.pinittask.domain.member.model.Member; -import me.gg.pinit.pinittask.domain.member.repository.MemberRepository; -import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; -import me.gg.pinit.pinittask.infrastructure.events.RabbitEventPublisher; -import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; -import me.gg.pinit.pinittask.interfaces.task.dto.TaskCreateRequest; -import me.gg.pinit.pinittask.interfaces.task.dto.TaskScheduleRequest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles("test") -@Transactional -class TaskControllerV1IntegrationTest { - - private static final long MEMBER_ID = 3L; - private static final ZoneId MEMBER_ZONE = ZoneId.of("Asia/Seoul"); - - @Autowired - MockMvc mockMvc; - @Autowired - ObjectMapper objectMapper; - @Autowired - MemberRepository memberRepository; - - @MockitoBean - RabbitEventPublisher rabbitEventPublisher; - - @BeforeEach - void setUpMember() { - if (!memberRepository.existsById(MEMBER_ID)) { - memberRepository.save(new Member(MEMBER_ID, "task-user", MEMBER_ZONE)); - } - } - - @Test - void taskLifecycle_create_retrieve_list_cursor_complete_reopen_delete() throws Exception { - TaskCreateRequest createRequest = new TaskCreateRequest( - "리포트 작성", - "주간 리포트 초안 작성", - new DateTimeWithZone(LocalDateTime.of(2024, 4, 1, 18, 0), MEMBER_ZONE), - 5, - 3, - List.of() - ); - - MvcResult createResult = mockMvc.perform(post("/v1/tasks") - .header("X-Member-Id", MEMBER_ID) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createRequest))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").isNumber()) - .andExpect(jsonPath("$.ownerId").value(MEMBER_ID)) - .andExpect(jsonPath("$.title").value("리포트 작성")) - .andExpect(jsonPath("$.dueDate.dateTime").value("2024-04-01T00:00:00")) - .andExpect(jsonPath("$.completed").value(false)) - .andReturn(); - - JsonNode created = objectMapper.readTree(createResult.getResponse().getContentAsString()); - long taskId = created.get("id").asLong(); - assertThat(taskId).isPositive(); - - // 작업을 일정으로 등록 - var scheduleResult = mockMvc.perform(post("/v1/tasks/{taskId}/schedules", taskId) - .header("X-Member-Id", MEMBER_ID) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString( - new TaskScheduleRequest( - null, - null, - new DateTimeWithZone(LocalDateTime.of(2024, 3, 30, 9, 0), MEMBER_ZONE), - ScheduleType.QUICK_TASK - ) - ))) - .andExpect(status().isCreated()) - .andReturn(); - long scheduleId = objectMapper.readTree(scheduleResult.getResponse().getContentAsString()).get("id").asLong(); - - mockMvc.perform(get("/v1/tasks/{taskId}", taskId) - .header("X-Member-Id", MEMBER_ID)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(taskId)) - .andExpect(jsonPath("$.title").value("리포트 작성")); - - mockMvc.perform(get("/v1/tasks") - .header("X-Member-Id", MEMBER_ID) - .param("page", "0") - .param("size", "5") - .param("readyOnly", "false")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[0].id").value(taskId)); - - MvcResult cursorResult = mockMvc.perform(get("/v1/tasks/cursor") - .header("X-Member-Id", MEMBER_ID) - .param("size", "10") - .param("cursor", "2000-01-01T00:00:00|0") - .param("readyOnly", "true")) - .andExpect(status().isOk()) - .andReturn(); - - JsonNode cursorNode = objectMapper.readTree(cursorResult.getResponse().getContentAsString()); - assertThat(cursorNode.get("data").isArray()).isTrue(); - assertThat(cursorNode.get("data")).isNotEmpty(); - assertThat(cursorNode.get("data").get(0).get("id").asLong()).isEqualTo(taskId); - assertThat(cursorNode.get("hasNext").asBoolean()).isFalse(); - - mockMvc.perform(post("/v1/tasks/{taskId}/complete", taskId) - .header("X-Member-Id", MEMBER_ID)) - .andExpect(status().isNoContent()); - - mockMvc.perform(get("/v1/tasks/{taskId}", taskId) - .header("X-Member-Id", MEMBER_ID)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.completed").value(true)); - - mockMvc.perform(get("/v1/schedules/{scheduleId}", scheduleId) - .header("X-Member-Id", MEMBER_ID)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.scheduleType").value("QUICK_TASK")) - .andExpect(jsonPath("$.state").value("COMPLETED")); - - mockMvc.perform(post("/v1/tasks/{taskId}/reopen", taskId) - .header("X-Member-Id", MEMBER_ID)) - .andExpect(status().isNoContent()); - - mockMvc.perform(get("/v1/tasks/{taskId}", taskId) - .header("X-Member-Id", MEMBER_ID)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.completed").value(false)); - - mockMvc.perform(delete("/v1/tasks/{taskId}", taskId) - .header("X-Member-Id", MEMBER_ID) - .param("deleteSchedules", "false")) - .andExpect(status().isNoContent()); - } - - @Test - void createTask_validationErrors_returnDetailedErrors() throws Exception { - String payload = """ - { - "title": "", - "description": "desc", - "dueDate": null, - "importance": 0, - "difficulty": 4, - "addDependencies": [] - } - """; - - mockMvc.perform(post("/v1/tasks") - .header("X-Member-Id", MEMBER_ID) - .contentType(MediaType.APPLICATION_JSON) - .content(payload)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation failed for 4 field(s)")) - .andExpect(jsonPath("$.errors", hasSize(4))) - .andExpect(jsonPath("$.errors[?(@.field=='title')].reason", hasItem(containsString("must not be blank")))) - .andExpect(jsonPath("$.errors[?(@.field=='dueDate')].reason", hasItem(containsString("must not be null")))) - .andExpect(jsonPath("$.errors[?(@.field=='importance')].reason", hasItem(containsString("greater than or equal to 1")))) - .andExpect(jsonPath("$.errors[?(@.field=='difficulty')].reason", hasItem(containsString("난이도")))); - } -} diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1Test.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1Test.java deleted file mode 100644 index 73111c4..0000000 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1Test.java +++ /dev/null @@ -1,127 +0,0 @@ -package me.gg.pinit.pinittask.interfaces.web; - -import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; -import me.gg.pinit.pinittask.application.dependency.service.DependencyService; -import me.gg.pinit.pinittask.application.schedule.service.ScheduleService; -import me.gg.pinit.pinittask.application.task.service.TaskAdjustmentService; -import me.gg.pinit.pinittask.application.task.service.TaskService; -import me.gg.pinit.pinittask.domain.schedule.model.Schedule; -import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; -import me.gg.pinit.pinittask.domain.task.model.Task; -import me.gg.pinit.pinittask.domain.task.vo.ImportanceConstraint; -import me.gg.pinit.pinittask.domain.task.vo.TemporalConstraint; -import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; -import me.gg.pinit.pinittask.interfaces.task.TaskControllerV1; -import me.gg.pinit.pinittask.interfaces.task.dto.TaskCursorPageResponse; -import me.gg.pinit.pinittask.interfaces.task.dto.TaskScheduleRequest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.lang.reflect.Field; -import java.time.Duration; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class TaskControllerV1Test { - - @Mock - DateTimeUtils dateTimeUtils; - @Mock - TaskAdjustmentService taskAdjustmentService; - @Mock - TaskService taskService; - @Mock - ScheduleService scheduleService; - @Mock - DependencyService dependencyService; - - @InjectMocks - TaskControllerV1 controller; - - Long memberId; - - @BeforeEach - void setUp() { - memberId = 1L; - } - - @Test - void deleteTask_forwardsFlagToService() { - Long taskId = 10L; - - ResponseEntity response = controller.deleteTask(memberId, taskId, true); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - verify(taskService).deleteTask(memberId, taskId, true); - } - - @Test - void createScheduleFromTask_usesExistingTaskAndAddsSchedule() throws Exception { - Long taskId = 11L; - Task task = buildTask(memberId); - setTaskId(task, taskId); - TaskScheduleRequest request = new TaskScheduleRequest( - null, - null, - new DateTimeWithZone(LocalDateTime.of(2024, 1, 1, 10, 0), ZoneId.of("UTC")), - ScheduleType.DEEP_WORK - ); - ZonedDateTime targetTime = ZonedDateTime.of(request.date().dateTime(), request.date().zoneId()); - when(taskService.getTask(memberId, taskId)).thenReturn(task); - when(dateTimeUtils.toZonedDateTime(request.date().dateTime(), request.date().zoneId())).thenReturn(targetTime); - Schedule saved = new Schedule(memberId, taskId, task.getTitle(), task.getDescription(), targetTime, ScheduleType.DEEP_WORK); - when(scheduleService.addSchedule(any(Schedule.class))).thenReturn(saved); - - ResponseEntity response = controller.createScheduleFromTask(memberId, taskId, request); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - verify(taskService).getTask(memberId, taskId); - ArgumentCaptor scheduleCaptor = ArgumentCaptor.forClass(Schedule.class); - verify(scheduleService).addSchedule(scheduleCaptor.capture()); - Schedule schedule = scheduleCaptor.getValue(); - assertThat(schedule.getTaskId()).isEqualTo(taskId); - assertThat(schedule.getDesignatedStartTime()).isEqualTo(targetTime); - } - - @Test - void getTasksByCursor_delegatesToServiceAndReturnsBody() { - TaskCursorPageResponse expected = TaskCursorPageResponse.of(List.of(), "next", true); - when(taskService.getTasksByCursor(memberId, 15, "c1", true)).thenReturn(expected); - - TaskCursorPageResponse resp = controller.getTasksByCursor(memberId, 15, "c1", true); - - assertThat(resp).isSameAs(expected); - verify(taskService).getTasksByCursor(memberId, 15, "c1", true); - } - - private Task buildTask(Long ownerId) { - return new Task( - ownerId, - "title", - "desc", - new TemporalConstraint(ZonedDateTime.now().plusDays(1), Duration.ZERO), - new ImportanceConstraint(5, 5) - ); - } - - private void setTaskId(Task task, Long id) throws Exception { - Field idField = Task.class.getDeclaredField("id"); - idField.setAccessible(true); - idField.set(task, id); - } -} From 92cf0e2c9dfd1c623135223171e8125a5f8b8101 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:17:35 +0900 Subject: [PATCH 29/42] =?UTF-8?q?feat:=20Deprecated=20Controller=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScheduleControllerV0IntegrationTest.java | 162 ------------------ 1 file changed, 162 deletions(-) delete mode 100644 src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV0IntegrationTest.java diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV0IntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV0IntegrationTest.java deleted file mode 100644 index 8856806..0000000 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV0IntegrationTest.java +++ /dev/null @@ -1,162 +0,0 @@ -package me.gg.pinit.pinittask.interfaces.web; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import me.gg.pinit.pinittask.domain.member.model.Member; -import me.gg.pinit.pinittask.domain.member.repository.MemberRepository; -import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; -import me.gg.pinit.pinittask.domain.task.repository.TaskRepository; -import me.gg.pinit.pinittask.infrastructure.events.RabbitEventPublisher; -import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; -import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleRequest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles("test") -@Transactional -class ScheduleControllerV0IntegrationTest { - - private static final long MEMBER_ID = 2L; - private static final ZoneId MEMBER_ZONE = ZoneId.of("Asia/Seoul"); - - @Autowired - MockMvc mockMvc; - @Autowired - ObjectMapper objectMapper; - @Autowired - MemberRepository memberRepository; - @Autowired - TaskRepository taskRepository; - - @MockitoBean - RabbitEventPublisher rabbitEventPublisher; - - @BeforeEach - void setUpMember() { - if (!memberRepository.existsById(MEMBER_ID)) { - memberRepository.save(new Member(MEMBER_ID, "legacy-user", MEMBER_ZONE)); - } - } - - @Test - void createLegacyScheduleAndListWithTaskDetails() throws Exception { - ScheduleRequest request = new ScheduleRequest( - "스터디 준비", - "발표 자료 정리", - null, - new DateTimeWithZone(LocalDateTime.of(2024, 3, 1, 18, 0), MEMBER_ZONE), - 5, - 3, - ScheduleType.QUICK_TASK, - new DateTimeWithZone(LocalDateTime.of(2024, 2, 28, 9, 0), MEMBER_ZONE), - List.of(), - List.of() - ); - - MvcResult createResult = mockMvc.perform(post("/v0/schedules") - .header("X-Member-Id", MEMBER_ID) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").isNumber()) - .andExpect(jsonPath("$.taskId").isNumber()) - .andExpect(jsonPath("$.title").value("스터디 준비")) - .andExpect(jsonPath("$.deadline.dateTime").value("2024-03-01T00:00:00")) - .andExpect(jsonPath("$.deadline.zoneId").value("+09:00")) - .andExpect(jsonPath("$.importance").value(5)) - .andExpect(jsonPath("$.difficulty").value(3)) - .andReturn(); - - JsonNode created = objectMapper.readTree(createResult.getResponse().getContentAsString()); - long scheduleId = created.get("id").asLong(); - assertThat(scheduleId).isPositive(); - - mockMvc.perform(get("/v0/schedules") - .header("X-Member-Id", MEMBER_ID) - .param("time", "2024-02-28T00:00:00") - .param("zoneId", MEMBER_ZONE.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(scheduleId)) - .andExpect(jsonPath("$[0].taskId").isNumber()) - .andExpect(jsonPath("$[0].importance").value(5)) - .andExpect(jsonPath("$[0].date.dateTime").value("2024-02-28T00:00:00")); - } - - @Test - void startAndCompleteSchedule_marksTaskCompleted_andClearsRunning() throws Exception { - ScheduleRequest request = new ScheduleRequest( - "코딩", - "레거시 일정 생성", - null, - new DateTimeWithZone(LocalDateTime.of(2024, 4, 1, 9, 0), MEMBER_ZONE), - 6, - 5, - ScheduleType.DEEP_WORK, - new DateTimeWithZone(LocalDateTime.of(2024, 3, 31, 10, 0), MEMBER_ZONE), - List.of(), - List.of() - ); - - JsonNode created = objectMapper.readTree( - mockMvc.perform(post("/v0/schedules") - .header("X-Member-Id", MEMBER_ID) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andReturn() - .getResponse() - .getContentAsString() - ); - long scheduleId = created.get("id").asLong(); - long taskId = created.get("taskId").asLong(); - - mockMvc.perform(post("/v0/schedules/{scheduleId}/start", scheduleId) - .header("X-Member-Id", MEMBER_ID) - .param("time", "2024-03-31T10:00:00") - .param("zoneId", MEMBER_ZONE.getId())) - .andExpect(status().isNoContent()); - - mockMvc.perform(get("/v0/schedules/{scheduleId}", scheduleId) - .header("X-Member-Id", MEMBER_ID)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.state").value("IN_PROGRESS")); - - mockMvc.perform(post("/v0/schedules/{scheduleId}/complete", scheduleId) - .header("X-Member-Id", MEMBER_ID) - .param("time", "2024-03-31T12:00:00") - .param("zoneId", MEMBER_ZONE.getId())) - .andExpect(status().isNoContent()); - - mockMvc.perform(get("/v0/schedules/{scheduleId}", scheduleId) - .header("X-Member-Id", MEMBER_ID)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.state").value("COMPLETED")); - - boolean taskCompleted = taskRepository.findById(taskId).orElseThrow().isCompleted(); - assertThat(taskCompleted).isTrue(); - - Long nowRunning = memberRepository.findById(MEMBER_ID).orElseThrow().getNowRunningScheduleId(); - assertThat(nowRunning).isNull(); - } -} From 881ceab183d2b09fe475a61301b491cdc68720c6 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:26:00 +0900 Subject: [PATCH 30/42] =?UTF-8?q?feat:=20ScheduleControllerV2=EC=9D=98=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EB=8C=80=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...leControllerV2TimeZoneIntegrationTest.java | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV2TimeZoneIntegrationTest.java diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV2TimeZoneIntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV2TimeZoneIntegrationTest.java new file mode 100644 index 0000000..67ed25e --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV2TimeZoneIntegrationTest.java @@ -0,0 +1,91 @@ +package me.gg.pinit.pinittask.interfaces.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import me.gg.pinit.pinittask.domain.member.model.Member; +import me.gg.pinit.pinittask.domain.member.repository.MemberRepository; +import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; +import me.gg.pinit.pinittask.infrastructure.events.RabbitEventPublisher; +import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class ScheduleControllerV2TimeZoneIntegrationTest { + + private static final long MEMBER_ID = 55L; + private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Seoul"); + + @Autowired + MockMvc mockMvc; + @Autowired + ObjectMapper objectMapper; + @Autowired + MemberRepository memberRepository; + + @MockitoBean + RabbitEventPublisher rabbitEventPublisher; + + @BeforeEach + void setUpMember() { + if (!memberRepository.existsById(MEMBER_ID)) { + memberRepository.save(new Member(MEMBER_ID, "tz-user", DEFAULT_ZONE)); + } + } + + @Test + void scheduleIsRenderedInRequestedTimeZone() throws Exception { + ScheduleSimpleRequest request = new ScheduleSimpleRequest( + "심야 작업", + "시간대 테스트", + new DateTimeWithZone(LocalDateTime.of(2026, 2, 1, 9, 0), DEFAULT_ZONE), + ScheduleType.DEEP_WORK + ); + + mockMvc.perform(post("/v2/schedules") + .header("X-Member-Id", MEMBER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + // KST 뷰: 원본 입력과 동일하게 09:00 표시 + mockMvc.perform(get("/v2/schedules") + .header("X-Member-Id", MEMBER_ID) + .param("time", "2026-02-01T12:00:00") + .param("zoneId", DEFAULT_ZONE.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].date.dateTime").value("2026-02-01T09:00:00")) + .andExpect(jsonPath("$[0].date.zoneId").value(DEFAULT_ZONE.getId())); + + // 미국 서부 뷰: 같은 일정이 전날 16:00로 보인다. + mockMvc.perform(get("/v2/schedules") + .header("X-Member-Id", MEMBER_ID) + .param("time", "2026-01-31T12:00:00") + .param("zoneId", "America/Los_Angeles")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].date.dateTime").value("2026-01-31T16:00:00")) + .andExpect(jsonPath("$[0].date.zoneId").value("America/Los_Angeles")); + } +} From b29899e0d1e4ac74b3e63787285b166a11fa8a3e Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:30:29 +0900 Subject: [PATCH 31/42] =?UTF-8?q?feat:=20TaskCreateRequestV2=20=EB=B0=8F?= =?UTF-8?q?=20TaskUpdateRequestV2=EC=97=90=EC=84=9C=20UTC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20zoneId=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../task/dto/TaskCreateRequestV2.java | 17 ++++++++++++++--- .../task/dto/TaskUpdateRequestV2.java | 17 ++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java index a52f25a..d0d67ff 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java @@ -12,6 +12,8 @@ import me.gg.pinit.pinittask.interfaces.dto.DateWithOffset; import me.gg.pinit.pinittask.interfaces.utils.FibonacciDifficulty; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -24,7 +26,7 @@ public record TaskCreateRequestV2( @Schema(description = "작업 설명", example = "다음 주 발표 자료 정리") String description, @NotNull - @Schema(description = "마감 날짜(+오프셋)", example = "{\"date\":\"2024-03-01\",\"offset\":\"+09:00\"}") + @Schema(description = "마감 날짜(+오프셋, zoneId는 선택)", example = "{\"date\":\"2024-03-01\",\"offset\":\"+09:00\",\"zoneId\":\"Asia/Seoul\"}") @Valid DateWithOffset dueDate, @NotNull @@ -39,16 +41,18 @@ public record TaskCreateRequestV2( @Schema(description = "추가할 의존 관계 목록 (생성 시 각 항목에 fromId 또는 toId 중 하나는 0)") List<@Valid DependencyRequest> addDependencies ) { - public TaskDependencyAdjustCommand toCommand(Long taskId, Long ownerId, DateTimeUtils dateTimeUtils) { + public TaskDependencyAdjustCommand toCommand(Long taskId, Long ownerId, ZoneId memberZoneId, DateTimeUtils dateTimeUtils) { validateMustContainSelfPlaceholder(addDependencies); List remove = List.of(); // 생성 시 remove는 허용하지 않음 List add = toDependencyDtos(addDependencies); + ZoneId effectiveZone = dueDate.resolveZoneId(memberZoneId); + validateOffsetMatchesZone(dueDate, effectiveZone); return new TaskDependencyAdjustCommand( taskId, ownerId, title, description, - dateTimeUtils.toStartOfDay(dueDate.date(), dueDate.offset()), + dateTimeUtils.toStartOfDay(dueDate.date(), effectiveZone), importance, difficulty, remove, @@ -73,4 +77,11 @@ private void validateMustContainSelfPlaceholder(List dependen } }); } + + private void validateOffsetMatchesZone(DateWithOffset dateWithOffset, ZoneId effectiveZone) { + ZoneOffset expectedOffset = dateWithOffset.date().atStartOfDay(effectiveZone).getOffset(); + if (!expectedOffset.equals(dateWithOffset.offset())) { + throw new IllegalArgumentException("전달된 offset이 해당 zoneId의 규칙과 일치하지 않습니다."); + } + } } diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java index 1774838..e7b78ab 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java @@ -12,6 +12,8 @@ import me.gg.pinit.pinittask.interfaces.dto.DateWithOffset; import me.gg.pinit.pinittask.interfaces.utils.FibonacciDifficulty; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -24,7 +26,7 @@ public record TaskUpdateRequestV2( @Schema(description = "작업 설명", example = "다음 주 발표 자료 정리") String description, @NotNull - @Schema(description = "마감 날짜(+오프셋)", example = "{\"date\":\"2024-03-01\",\"offset\":\"+09:00\"}") + @Schema(description = "마감 날짜(+zoneId, 오프셋은 선택)", example = "{\"date\":\"2024-03-01\",\"offset\":\"+09:00\",\"zoneId\":\"Asia/Seoul\"}") @Valid DateWithOffset dueDate, @NotNull @@ -41,17 +43,19 @@ public record TaskUpdateRequestV2( @Schema(description = "추가할 의존 관계 목록 (수정 시 0 사용 금지)") List<@Valid DependencyRequest> addDependencies ) { - public TaskDependencyAdjustCommand toCommand(Long taskId, Long ownerId, DateTimeUtils dateTimeUtils) { + public TaskDependencyAdjustCommand toCommand(Long taskId, Long ownerId, ZoneId memberZoneId, DateTimeUtils dateTimeUtils) { validateNoPlaceholder(removeDependencies); validateNoPlaceholder(addDependencies); List remove = toDependencyDtos(removeDependencies); List add = toDependencyDtos(addDependencies); + ZoneId effectiveZone = dueDate.resolveZoneId(memberZoneId); + validateOffsetMatchesZone(dueDate, effectiveZone); return new TaskDependencyAdjustCommand( taskId, ownerId, title, description, - dateTimeUtils.toStartOfDay(dueDate.date(), dueDate.offset()), + dateTimeUtils.toStartOfDay(dueDate.date(), effectiveZone), importance, difficulty, remove, @@ -76,4 +80,11 @@ private List toDependencyDtos(List requests) { .map(request -> new DependencyDto(null, request.fromId(), request.toId())) .toList(); } + + private void validateOffsetMatchesZone(DateWithOffset dateWithOffset, ZoneId effectiveZone) { + ZoneOffset expectedOffset = dateWithOffset.date().atStartOfDay(effectiveZone).getOffset(); + if (!expectedOffset.equals(dateWithOffset.offset())) { + throw new IllegalArgumentException("전달된 offset이 해당 zoneId의 규칙과 일치하지 않습니다."); + } + } } From 9db8553ca0c8c34515a794b8a53f3a3e6997b004 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:50:40 +0900 Subject: [PATCH 32/42] =?UTF-8?q?feat:=20TaskCreateRequestV2=20=EB=B0=8F?= =?UTF-8?q?=20TaskUpdateRequestV2=EC=97=90=EC=84=9C=20IANA=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=20=EA=B8=B0=EB=B0=98=20=EB=A7=88=EA=B0=90=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/task/TaskControllerV2.java | 42 ++++++++++++------- .../task/dto/TaskCreateRequestV2.java | 2 +- .../interfaces/task/dto/TaskResponseV2.java | 2 +- .../task/dto/TaskUpdateRequestV2.java | 2 +- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV2.java index a335a0e..9a216c7 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV2.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV2.java @@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor; import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; import me.gg.pinit.pinittask.application.dependency.service.DependencyService; +import me.gg.pinit.pinittask.application.member.service.MemberService; import me.gg.pinit.pinittask.application.schedule.service.ScheduleService; import me.gg.pinit.pinittask.application.task.service.TaskAdjustmentService; import me.gg.pinit.pinittask.application.task.service.TaskArchiveService; @@ -31,13 +32,14 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDate; +import java.time.ZoneId; import java.time.ZoneOffset; import java.util.List; @RestController @RequestMapping("/v2/tasks") @RequiredArgsConstructor -@Tag(name = "TaskV2", description = "작업 관리 API (마감 날짜 + 오프셋 기반)") +@Tag(name = "TaskV2", description = "작업 관리 API (마감 날짜 + IANA 시간대 기반). 클라이언트는 로컬 날짜와 IANA `zoneId`를 보내고, 필요 시 해당 날짜의 offset을 함께 전달합니다.") @ApiResponses({ @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "404", description = "대상을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @@ -47,13 +49,14 @@ public class TaskControllerV2 { private final DateTimeUtils dateTimeUtils; private final DependencyService dependencyService; + private final MemberService memberService; private final TaskArchiveService taskArchiveService; private final TaskAdjustmentService taskAdjustmentService; private final TaskService taskService; private final ScheduleService scheduleService; @PostMapping - @Operation(summary = "작업 생성", description = "새 작업과 의존 관계를 등록합니다. 마감은 날짜 + UTC 오프셋(시간 00:00 고정)으로 입력합니다.") + @Operation(summary = "작업 생성", description = "새 작업과 의존 관계를 등록합니다. 마감은 '사용자 로컬 날짜 + IANA `zoneId`(00:00 고정)'로 입력합니다. `offset`은 해당 날짜의 DST 오프셋을 함께 명시하고 싶을 때만 추가로 전달하세요.") @ApiResponses({ @ApiResponse(responseCode = "201", description = "작업이 생성되었습니다.", content = @Content(schema = @Schema(implementation = TaskResponseV2.class))), @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @@ -61,12 +64,13 @@ public class TaskControllerV2 { }) public ResponseEntity createTask(@Parameter(hidden = true) @MemberId Long memberId, @Valid @RequestBody TaskCreateRequestV2 request) { - Task saved = taskAdjustmentService.createTask(memberId, request.toCommand(null, memberId, dateTimeUtils)); + ZoneId memberZoneId = memberService.findZoneIdOfMember(memberId); + Task saved = taskAdjustmentService.createTask(memberId, request.toCommand(null, memberId, memberZoneId, dateTimeUtils)); return ResponseEntity.status(HttpStatus.CREATED).body(TaskResponseV2.from(saved)); } @PatchMapping("/{taskId}") - @Operation(summary = "작업 수정", description = "작업 본문과 의존 관계를 함께 수정합니다. 마감 날짜는 00:00:00 기준으로 저장됩니다.") + @Operation(summary = "작업 수정", description = "작업 본문과 의존 관계를 함께 수정합니다. 마감 날짜는 '로컬 날짜 + IANA `zoneId`(00:00:00)' 기준으로 저장하며, 전달된 `offset`이 있으면 해당 날짜의 오프셋과 일치하는지 검증합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "작업이 수정되었습니다.", content = @Content(schema = @Schema(implementation = TaskResponseV2.class))), @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @@ -76,14 +80,15 @@ public ResponseEntity createTask(@Parameter(hidden = true) @Memb public ResponseEntity updateTask(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long taskId, @Valid @RequestBody TaskUpdateRequestV2 request) { - Task updated = taskAdjustmentService.updateTask(memberId, request.toCommand(taskId, memberId, dateTimeUtils)); + ZoneId memberZoneId = memberService.findZoneIdOfMember(memberId); + Task updated = taskAdjustmentService.updateTask(memberId, request.toCommand(taskId, memberId, memberZoneId, dateTimeUtils)); return ResponseEntity.ok(TaskResponseV2.from(updated)); } @GetMapping @Operation(summary = "작업 목록 조회", description = """ 회원의 작업 목록을 조회합니다. - 포함 대상: 미완료 작업 전체 + 오늘(회원 UTC 오프셋 기준) 이후 또는 오늘 마감인 완료 작업. + 포함 대상: 미완료 작업 전체 + 오늘(회원 IANA 시간대 기준) 이후 또는 오늘 마감인 완료 작업. 정렬: 마감일 오름차순, page/size 페이징. readyOnly=true면 선행 작업 없는 미완료 작업만 필터링합니다. """) @@ -103,7 +108,7 @@ public Page getTasks(@Parameter(hidden = true) @MemberId Long me @GetMapping("/cursor") @Operation(summary = "작업 목록 커서 조회", description = """ 마감 날짜(00:00:00) asc, id asc 커서 기반 페이지네이션. - 포함 대상: 미완료 작업 전체 + 오늘(회원 UTC 오프셋 기준) 이후 또는 오늘 마감인 완료 작업. + 포함 대상: 미완료 작업 전체 + 오늘(회원 IANA 시간대 기준) 이후 또는 오늘 마감인 완료 작업. cursor 형식: 'YYYY-MM-DDTHH:MM:SS|taskId' (시간은 항상 00:00:00). """) @ApiResponses({ @@ -124,13 +129,17 @@ public TaskCursorPageResponseV2 getTasksByCursor(@Parameter(hidden = true) @Memb @GetMapping("/by-deadline") @Operation(summary = "마감 날짜 기준 작업 조회", description = """ - 특정 날짜(YYYY-MM-DD)에 마감이 설정된 작업들을 조회합니다. - 완료/미완료 상태와 무관하게 해당 날짜 마감인 모든 작업을 반환합니다. + 특정 로컬 날짜(YYYY-MM-DD)에 마감이 설정된 작업들을 조회합니다. + 기본적으로 회원에 설정된 IANA `zoneId`로 날짜를 해석하며, 요청에 `zoneId`를 주면 그 시간대를 사용합니다. + DST/지역 변경에 안전하게 해석되도록 IANA 시간대를 표준으로 사용합니다. """) public List getTasksByDeadline(@Parameter(hidden = true) @MemberId Long memberId, @Parameter(description = "마감 날짜", example = "2025-02-01") - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { - List tasks = taskService.getTasksByDeadline(memberId, date); + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @Parameter(description = "마감 날짜를 해석할 IANA 시간대. 없으면 회원 설정값 사용", example = "Asia/Seoul") + @RequestParam(required = false) ZoneId zoneId) { + ZoneId effectiveZoneId = zoneId == null ? memberService.findZoneIdOfMember(memberId) : zoneId; + List tasks = taskService.getTasksByDeadline(memberId, date, effectiveZoneId); var dependencyInfoMap = dependencyService.getDependencyInfoForTasks(memberId, tasks.stream().map(Task::getId).toList()); return tasks.stream() .map(task -> TaskResponseV2.from(task, dependencyInfoMap.get(task.getId()))) @@ -142,7 +151,7 @@ public List getTasksByDeadline(@Parameter(hidden = true) @Member description = """ 오늘 00:00 기준 이전(어제까지) 마감일을 가진 완료 작업을 커서 기반으로 내려줍니다. 정렬: deadline DESC, id DESC. - 커서 포맷: `yyyy-MM-dd|taskId` (예: `2025-01-07|42`). `offset`을 넣으면 cutoff 기준 시각이 해당 오프셋으로 계산됩니다. + 커서 포맷: `yyyy-MM-dd|taskId` (예: `2025-01-07|42`). 기본은 회원의 IANA `zoneId`로 cutoff를 계산하며, 필요 시 동일 날짜의 `offset`을 함께 주면 검증에 사용합니다(레거시 옵션). 첫 페이지 호출 시 cursor를 비우면 자동으로 cutoff 다음 날(`cutoff+1|Long.MAX_VALUE`)로 시작합니다. """) @ApiResponses({ @@ -154,9 +163,11 @@ public TaskArchiveCursorPageResponseV2 getCompletedTasksArchive(@Parameter(hidde @RequestParam(defaultValue = "20") Integer size, @Parameter(description = "커서 `yyyy-MM-dd|taskId` (예: 2025-01-07|42)", example = "2025-01-07|42") @RequestParam(required = false) String cursor, - @Parameter(description = "컷오프 계산에 사용할 UTC 오프셋. 없으면 회원 설정값 사용", example = "+09:00") + @Parameter(description = "컷오프 계산에 사용할 IANA 시간대. 없으면 회원 설정값 사용", example = "Asia/Seoul") + @RequestParam(required = false) ZoneId zoneId, + @Parameter(description = "컷오프 계산에 사용할 UTC 오프셋(레거시, zoneId 사용 권장)", example = "+09:00") @RequestParam(required = false) ZoneOffset offset) { - TaskArchiveService.CursorPage page = taskArchiveService.getCompletedArchive(memberId, size, cursor, offset); + TaskArchiveService.CursorPage page = taskArchiveService.getCompletedArchive(memberId, size, cursor, offset, zoneId); var dependencyInfoMap = dependencyService.getDependencyInfoForTasks(memberId, page.tasks().stream().map(Task::getId).toList()); List data = page.tasks().stream() .map(task -> TaskResponseV2.from(task, dependencyInfoMap.get(task.getId()))) @@ -225,6 +236,7 @@ public ResponseEntity createScheduleFromTask(@Parameter( @Valid @RequestBody TaskScheduleRequest request) { Task task = taskService.getTask(memberId, taskId); Schedule saved = scheduleService.addSchedule(request.toSchedule(task, memberId, dateTimeUtils)); - return ResponseEntity.status(HttpStatus.CREATED).body(ScheduleSimpleResponse.from(saved)); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ScheduleSimpleResponse.from(saved, task, request.date().zoneId())); } } diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java index d0d67ff..6fc90e9 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java @@ -26,7 +26,7 @@ public record TaskCreateRequestV2( @Schema(description = "작업 설명", example = "다음 주 발표 자료 정리") String description, @NotNull - @Schema(description = "마감 날짜(+오프셋, zoneId는 선택)", example = "{\"date\":\"2024-03-01\",\"offset\":\"+09:00\",\"zoneId\":\"Asia/Seoul\"}") + @Schema(description = "마감 날짜(IANA `zoneId` 필수, `offset`은 해당 날짜의 오프셋을 함께 명시하고 싶을 때 선택)", example = "{\"date\":\"2024-03-01\",\"offset\":\"+09:00\",\"zoneId\":\"Asia/Seoul\"}") @Valid DateWithOffset dueDate, @NotNull diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskResponseV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskResponseV2.java index 299dbe9..447fb8c 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskResponseV2.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskResponseV2.java @@ -20,7 +20,7 @@ public record TaskResponseV2( String title, @Schema(description = "작업 설명") String description, - @Schema(description = "마감 날짜(+오프셋, 00:00 시각)") + @Schema(description = "마감 날짜(+IANA 시간대, 00:00 시각). `offset`은 해당 날짜의 오프셋으로 반환됩니다.") DateWithOffset dueDate, @Schema(description = "중요도") int importance, diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java index e7b78ab..3a3a805 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java @@ -26,7 +26,7 @@ public record TaskUpdateRequestV2( @Schema(description = "작업 설명", example = "다음 주 발표 자료 정리") String description, @NotNull - @Schema(description = "마감 날짜(+zoneId, 오프셋은 선택)", example = "{\"date\":\"2024-03-01\",\"offset\":\"+09:00\",\"zoneId\":\"Asia/Seoul\"}") + @Schema(description = "마감 날짜(IANA `zoneId` 필수, `offset`은 해당 날짜의 오프셋을 함께 명시하고 싶을 때 선택)", example = "{\"date\":\"2024-03-01\",\"offset\":\"+09:00\",\"zoneId\":\"Asia/Seoul\"}") @Valid DateWithOffset dueDate, @NotNull From 4a3a0c5aa5709f6d5c22e84e47141334993458db Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 22:55:49 +0900 Subject: [PATCH 33/42] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20IAN?= =?UTF-8?q?A=20=EC=8B=9C=EA=B0=84=EB=8C=80=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?=EC=98=A4=ED=94=84=EC=85=8B=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20deprecated=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/member/MemberControllerV2.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV2.java index 3d6e008..69abc77 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV2.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV2.java @@ -39,8 +39,19 @@ public ResponseEntity getNowInProgressScheduleId(@Parameter(hidden = true) return ResponseEntity.ok(scheduleId); } + @GetMapping("/zone-id") + @Operation(summary = "사용자 IANA 시간대 조회", description = "사용자에 저장된 IANA 시간대(`zoneId`)를 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사용자 시간대 조회 성공", content = @Content(schema = @Schema(implementation = String.class, example = "Asia/Seoul"))), + }) + public ResponseEntity getMemberZoneId(@Parameter(hidden = true) @MemberId Long memberId) { + String zoneId = memberService.findZoneIdOfMember(memberId).getId(); + return ResponseEntity.ok(zoneId); + } + + @Deprecated @GetMapping("/zone-offset") - @Operation(summary = "사용자 시간대 조회", description = "사용자의 시간대를 조회합니다.") + @Operation(summary = "사용자 시간대 조회(레거시)", description = "사용자의 UTC 오프셋을 조회합니다. IANA `zoneId` 사용을 권장합니다.", deprecated = true) @ApiResponses({ @ApiResponse(responseCode = "200", description = "사용자 시간대 조회 성공", content = @Content(schema = @Schema(implementation = String.class, example = "+09:00"))), }) From 96874d55d473202a3e155179a38b29c9b8e48189 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 23:03:10 +0900 Subject: [PATCH 34/42] =?UTF-8?q?refactor:=20TaskCreateRequestV2=20?= =?UTF-8?q?=EB=B0=8F=20TaskUpdateRequestV2=EC=97=90=EC=84=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/task/dto/TaskCreateRequestV2.java | 9 --------- .../interfaces/task/dto/TaskUpdateRequestV2.java | 9 --------- 2 files changed, 18 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java index 6fc90e9..6c47801 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java @@ -13,7 +13,6 @@ import me.gg.pinit.pinittask.interfaces.utils.FibonacciDifficulty; import java.time.ZoneId; -import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -46,7 +45,6 @@ public TaskDependencyAdjustCommand toCommand(Long taskId, Long ownerId, ZoneId m List remove = List.of(); // 생성 시 remove는 허용하지 않음 List add = toDependencyDtos(addDependencies); ZoneId effectiveZone = dueDate.resolveZoneId(memberZoneId); - validateOffsetMatchesZone(dueDate, effectiveZone); return new TaskDependencyAdjustCommand( taskId, ownerId, @@ -77,11 +75,4 @@ private void validateMustContainSelfPlaceholder(List dependen } }); } - - private void validateOffsetMatchesZone(DateWithOffset dateWithOffset, ZoneId effectiveZone) { - ZoneOffset expectedOffset = dateWithOffset.date().atStartOfDay(effectiveZone).getOffset(); - if (!expectedOffset.equals(dateWithOffset.offset())) { - throw new IllegalArgumentException("전달된 offset이 해당 zoneId의 규칙과 일치하지 않습니다."); - } - } } diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java index 3a3a805..de778e6 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java @@ -13,7 +13,6 @@ import me.gg.pinit.pinittask.interfaces.utils.FibonacciDifficulty; import java.time.ZoneId; -import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -49,7 +48,6 @@ public TaskDependencyAdjustCommand toCommand(Long taskId, Long ownerId, ZoneId m List remove = toDependencyDtos(removeDependencies); List add = toDependencyDtos(addDependencies); ZoneId effectiveZone = dueDate.resolveZoneId(memberZoneId); - validateOffsetMatchesZone(dueDate, effectiveZone); return new TaskDependencyAdjustCommand( taskId, ownerId, @@ -80,11 +78,4 @@ private List toDependencyDtos(List requests) { .map(request -> new DependencyDto(null, request.fromId(), request.toId())) .toList(); } - - private void validateOffsetMatchesZone(DateWithOffset dateWithOffset, ZoneId effectiveZone) { - ZoneOffset expectedOffset = dateWithOffset.date().atStartOfDay(effectiveZone).getOffset(); - if (!expectedOffset.equals(dateWithOffset.offset())) { - throw new IllegalArgumentException("전달된 offset이 해당 zoneId의 규칙과 일치하지 않습니다."); - } - } } From 32bda3f2c6346572b53dbcbd0fba594480671a7e Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 23:03:39 +0900 Subject: [PATCH 35/42] =?UTF-8?q?docs:=20DateWithOffset=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=EC=97=90=EC=84=9C=20IANA=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=20ID=20=EB=B0=8F=20=EC=98=A4=ED=94=84?= =?UTF-8?q?=EC=85=8B=20=EC=84=A4=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java index 94de1b4..311130f 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java @@ -13,10 +13,10 @@ public record DateWithOffset( @NotNull @Schema(description = "날짜", example = "2024-03-01") LocalDate date, - @Schema(description = "UTC 기준 오프셋(+HH:mm)", example = "+09:00") + @Schema(description = "UTC 기준 오프셋(+HH:mm). IANA `zoneId`로 계산된 값을 명시하고 싶을 때 선택적으로 포함", example = "+09:00") ZoneOffset offset, @NotNull - @Schema(description = "IANA 시간대 ID", example = "Asia/Seoul") + @Schema(description = "IANA 시간대 ID (필수)", example = "Asia/Seoul") ZoneId zoneId ) { public DateWithOffset { From b4e09a556f43a1d38059ea9a825c7b073e923c39 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 23:07:52 +0900 Subject: [PATCH 36/42] =?UTF-8?q?feat:=20TaskArchiveControllerV2=20?= =?UTF-8?q?=EB=B0=8F=20TaskControllerV2=EC=97=90=EC=84=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20UTC=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...askArchiveControllerV2IntegrationTest.java | 6 +++--- .../web/TaskControllerV2IntegrationTest.java | 20 ++++++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskArchiveControllerV2IntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskArchiveControllerV2IntegrationTest.java index 8cca8f6..a2a926e 100644 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskArchiveControllerV2IntegrationTest.java +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskArchiveControllerV2IntegrationTest.java @@ -40,7 +40,7 @@ class TaskArchiveControllerV2IntegrationTest { private static final long MEMBER_ID = 44L; - private static final ZoneOffset MEMBER_OFFSET = ZoneOffset.of("+09:00"); + private static final ZoneId MEMBER_ZONE = ZoneId.of("Asia/Seoul"); @Autowired MockMvc mockMvc; @@ -57,7 +57,7 @@ class TaskArchiveControllerV2IntegrationTest { @BeforeEach void setUpMember() { if (!memberRepository.existsById(MEMBER_ID)) { - memberRepository.save(new Member(MEMBER_ID, "archive-user", MEMBER_OFFSET)); + memberRepository.save(new Member(MEMBER_ID, "archive-user", MEMBER_ZONE)); } } @@ -116,7 +116,7 @@ void completedArchive_rejectsInvalidSize() throws Exception { } private Task completedTask(LocalDate deadlineDate) { - ZonedDateTime deadline = deadlineDate.atStartOfDay(MEMBER_OFFSET); + ZonedDateTime deadline = deadlineDate.atStartOfDay(MEMBER_ZONE); Task task = new Task(MEMBER_ID, "archive", "desc", new TemporalConstraint(deadline, Duration.ZERO), new ImportanceConstraint(1, 1)); task.markCompleted(); return task; diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV2IntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV2IntegrationTest.java index ffb9318..53b1399 100644 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV2IntegrationTest.java +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV2IntegrationTest.java @@ -19,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.time.ZoneId; import java.time.ZoneOffset; import java.util.List; @@ -36,6 +37,7 @@ class TaskControllerV2IntegrationTest { private static final long MEMBER_ID = 4L; + private static final ZoneId MEMBER_ZONE = ZoneId.of("Asia/Seoul"); private static final ZoneOffset OFFSET = ZoneOffset.of("+09:00"); @Autowired @@ -51,7 +53,7 @@ class TaskControllerV2IntegrationTest { @BeforeEach void setUpMember() { if (!memberRepository.existsById(MEMBER_ID)) { - memberRepository.save(new Member(MEMBER_ID, "task-v2-user", OFFSET)); + memberRepository.save(new Member(MEMBER_ID, "task-v2-user", MEMBER_ZONE)); } } @@ -60,7 +62,7 @@ void createAndFetchTasksWithDateOffsetCursor() throws Exception { TaskCreateRequestV2 createRequest = new TaskCreateRequestV2( "리포트 작성", "주간 리포트 초안 작성", - new DateWithOffset(java.time.LocalDate.of(2024, 4, 1), OFFSET), + new DateWithOffset(java.time.LocalDate.of(2024, 4, 1), OFFSET, ZoneId.of("Asia/Seoul")), 5, 3, List.of() @@ -73,6 +75,7 @@ void createAndFetchTasksWithDateOffsetCursor() throws Exception { .andExpect(status().isCreated()) .andExpect(jsonPath("$.dueDate.date").value("2024-04-01")) .andExpect(jsonPath("$.dueDate.offset").value("+09:00")) + .andExpect(jsonPath("$.dueDate.zoneId").value("Asia/Seoul")) .andReturn(); JsonNode created = objectMapper.readTree(createResult.getResponse().getContentAsString()); @@ -86,7 +89,8 @@ void createAndFetchTasksWithDateOffsetCursor() throws Exception { .andExpect(jsonPath("$.content", hasSize(1))) .andExpect(jsonPath("$.content[0].id").value(taskId)) .andExpect(jsonPath("$.content[0].dueDate.date").value("2024-04-01")) - .andExpect(jsonPath("$.content[0].dueDate.offset").value("+09:00")); + .andExpect(jsonPath("$.content[0].dueDate.offset").value("+09:00")) + .andExpect(jsonPath("$.content[0].dueDate.zoneId").value("Asia/Seoul")); var cursorResult = mockMvc.perform(get("/v2/tasks/cursor") .header("X-Member-Id", MEMBER_ID) @@ -100,6 +104,7 @@ void createAndFetchTasksWithDateOffsetCursor() throws Exception { assertThat(cursorNode.get("data").isArray()).isTrue(); assertThat(cursorNode.get("data").get(0).get("dueDate").get("date").asText()).isEqualTo("2024-04-01"); assertThat(cursorNode.get("data").get(0).get("dueDate").get("offset").asText()).isEqualTo("+09:00"); + assertThat(cursorNode.get("data").get(0).get("dueDate").get("zoneId").asText()).isEqualTo("Asia/Seoul"); } @Test @@ -108,7 +113,7 @@ void fetchTasksByDeadlineReturnsOnlyMatchingDate() throws Exception { TaskCreateRequestV2 d1Req = new TaskCreateRequestV2( "D1-1", "same date 1", - new DateWithOffset(LocalDate.of(2025, 2, 1), OFFSET), + new DateWithOffset(LocalDate.of(2025, 2, 1), OFFSET, ZoneId.of("Asia/Seoul")), 3, 2, List.of() @@ -116,7 +121,7 @@ void fetchTasksByDeadlineReturnsOnlyMatchingDate() throws Exception { TaskCreateRequestV2 d1Req2 = new TaskCreateRequestV2( "D1-2", "same date 2", - new DateWithOffset(LocalDate.of(2025, 2, 1), OFFSET), + new DateWithOffset(LocalDate.of(2025, 2, 1), OFFSET, ZoneId.of("Asia/Seoul")), 2, 1, List.of() @@ -124,7 +129,7 @@ void fetchTasksByDeadlineReturnsOnlyMatchingDate() throws Exception { TaskCreateRequestV2 otherReq = new TaskCreateRequestV2( "Other", "other date", - new DateWithOffset(LocalDate.of(2025, 2, 2), OFFSET), + new DateWithOffset(LocalDate.of(2025, 2, 2), OFFSET, ZoneId.of("Asia/Seoul")), 1, 1, List.of() @@ -140,7 +145,8 @@ void fetchTasksByDeadlineReturnsOnlyMatchingDate() throws Exception { mockMvc.perform(get("/v2/tasks/by-deadline") .header("X-Member-Id", MEMBER_ID) - .param("date", "2025-02-01")) + .param("date", "2025-02-01") + .param("zoneId", "Asia/Seoul")) .andExpect(status().isOk()) .andExpect(jsonPath("$", hasSize(2))) .andExpect(jsonPath("$[0].dueDate.date").value("2025-02-01")) From bc4c35fc4396e176356f65ba22d4148a23d2c834 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 23:08:37 +0900 Subject: [PATCH 37/42] =?UTF-8?q?feat:=20MemberCreatedEventListener?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=8B=9C=EA=B0=84=EB=8C=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EB=A5=BC=20Asia/Seoul=EB=A1=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../events/auth/MemberCreatedEventListener.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/me/gg/pinit/pinittask/infrastructure/events/auth/MemberCreatedEventListener.java b/src/main/java/me/gg/pinit/pinittask/infrastructure/events/auth/MemberCreatedEventListener.java index 1a688b4..2457de4 100644 --- a/src/main/java/me/gg/pinit/pinittask/infrastructure/events/auth/MemberCreatedEventListener.java +++ b/src/main/java/me/gg/pinit/pinittask/infrastructure/events/auth/MemberCreatedEventListener.java @@ -5,6 +5,8 @@ import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; +import java.time.ZoneId; + @Component public class MemberCreatedEventListener { private final MemberService memberService; @@ -15,7 +17,11 @@ public MemberCreatedEventListener(MemberService memberService) { @RabbitListener(queues = AuthMemberMessaging.MEMBER_CREATED_QUEUE) public void on(MemberCreatedEventDto event) { - memberService.enrollMember(event.memberId(), event.nickname()); + memberService.enrollMember(event.memberId(), event.nickname(), resolveZoneId()); + } + + private ZoneId resolveZoneId() { + return ZoneId.of("Asia/Seoul"); } } From 9f9718c0122919f138e711435c81231a37cb7d80 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 23:27:26 +0900 Subject: [PATCH 38/42] =?UTF-8?q?docs:=20=EC=8B=9C=EA=B0=84=EB=8C=80=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EA=B4=80=EB=A6=AC=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/events/auth/MemberCreatedEventListener.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/me/gg/pinit/pinittask/infrastructure/events/auth/MemberCreatedEventListener.java b/src/main/java/me/gg/pinit/pinittask/infrastructure/events/auth/MemberCreatedEventListener.java index 2457de4..45fd86b 100644 --- a/src/main/java/me/gg/pinit/pinittask/infrastructure/events/auth/MemberCreatedEventListener.java +++ b/src/main/java/me/gg/pinit/pinittask/infrastructure/events/auth/MemberCreatedEventListener.java @@ -21,6 +21,8 @@ public void on(MemberCreatedEventDto event) { } private ZoneId resolveZoneId() { + // TODO 이벤트 페이로드에 timezone 정보를 추가해야 하나? 아니면 gRPC로 MemberService에 조회를 하나? + // 어쩌면 이벤트가 아닌 프론트에서 직접 등록 API를 호출하게 하는 것이 맞을수도 (일단 킵 return ZoneId.of("Asia/Seoul"); } From 6942051fee0d3ad95ce34afe684510fc7bc97416 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 23:27:37 +0900 Subject: [PATCH 39/42] =?UTF-8?q?feat:=20OpenApiConfig=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20v2.1-time?= =?UTF-8?q?zone=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/pinit/pinittask/infrastructure/config/OpenApiConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/me/gg/pinit/pinittask/infrastructure/config/OpenApiConfig.java b/src/main/java/me/gg/pinit/pinittask/infrastructure/config/OpenApiConfig.java index a4d727a..6f3c433 100644 --- a/src/main/java/me/gg/pinit/pinittask/infrastructure/config/OpenApiConfig.java +++ b/src/main/java/me/gg/pinit/pinittask/infrastructure/config/OpenApiConfig.java @@ -10,7 +10,7 @@ @OpenAPIDefinition( info = @Info( title = "Pinit Task API", - version = "v2", + version = "v2.1-timezone", description = "일정/작업 관리와 의존 관계 기능을 제공하는 API", contact = @Contact(name = "Pinit Team", email = "support@pinit.local") ), From 390985c62e97e31998199c4280cbd01f5148b83c Mon Sep 17 00:00:00 2001 From: GoGradually Date: Mon, 2 Feb 2026 23:28:10 +0900 Subject: [PATCH 40/42] =?UTF-8?q?feat:=20=EC=8B=9C=EA=B0=84=EB=8C=80=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EA=B4=80=EB=A6=AC=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20=EB=B0=B1=ED=95=84=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timezone-migration-plan.md" | 136 ++++++++++++++++++ ...0\353\246\254 \353\260\251\354\213\235.md" | 60 ++++++++ 2 files changed, 196 insertions(+) create mode 100644 "docs/005\354\213\234\352\260\204\353\214\200 \354\240\225\353\263\264 \352\264\200\353\246\254 \353\260\251\354\213\235/timezone-migration-plan.md" create mode 100644 "docs/005\354\213\234\352\260\204\353\214\200 \354\240\225\353\263\264 \352\264\200\353\246\254 \353\260\251\354\213\235/\354\213\234\352\260\204\353\214\200 \354\240\225\353\263\264 \352\264\200\353\246\254 \353\260\251\354\213\235.md" diff --git "a/docs/005\354\213\234\352\260\204\353\214\200 \354\240\225\353\263\264 \352\264\200\353\246\254 \353\260\251\354\213\235/timezone-migration-plan.md" "b/docs/005\354\213\234\352\260\204\353\214\200 \354\240\225\353\263\264 \352\264\200\353\246\254 \353\260\251\354\213\235/timezone-migration-plan.md" new file mode 100644 index 0000000..82441e8 --- /dev/null +++ "b/docs/005\354\213\234\352\260\204\353\214\200 \354\240\225\353\263\264 \352\264\200\353\246\254 \353\260\251\354\213\235/timezone-migration-plan.md" @@ -0,0 +1,136 @@ +# 시간대 컬럼/ZoneId 확장 백필 계획 (2026-02-01) + +## 대상 및 범위 + +- `task.deadline_zone_id`: 이미 IANA ZoneId 저장. 추가 컬럼 필요 시 (예: 반복 일정용 `zone_id`) 동일 패턴 적용. +- 추후 반복 일정/리마인더 테이블 신설 시 `zone_id` 및 로컬 정의 컬럼(`local_time`, `day_of_week_set`) 필수. + +## 백필 단계 + +1) **스키마 추가**: 새 `zone_id` 또는 `local_time` 컬럼을 NULL 허용으로 추가. 기본값 지정 금지. +2) **데이터 백필**: 기존 레코드별로 회원 프로필의 `time_zone` 또는 추론 가능한 IANA 값을 넣는다. + - 예: `task.deadline_zone_id`가 비어있다면 `member.time_zone`로 채움. +3) **검증 쿼리**: NULL/빈 문자열이 남아 있지 않은지 집계. +4) **널 금지 전환**: 애플리케이션 배포 후 컬럼을 `NOT NULL`로 변경. +5) **인덱스/조회 정합성 테스트**: 날짜 범위 + `zone_id` 복합 인덱스가 필요한지 점검. + +## 마이그레이션 안전장치 + +- 롤백 시: 새 컬럼만 drop 하고 기존 컬럼 보존. +- 배포 시점에 애플리케이션은 새 컬럼이 NULL이어도 동작하도록 방어 코드 유지. + +## 운영 체크리스트 + +- 배포 직후 메트릭/로그로 `zone_id` 누락 건 모니터링. +- 데이터 백필 스크립트는 트랜잭션 단위로 커밋해 long-running 잠금 회피. + +## 실행 가능한 SQL 예시 (MySQL 8.x, **현재 존재하는 테이블만 사용**) + +아래 순서는 `member`, `task`, `statistics`에 이미 존재하는 컬럼을 기준으로 한다. 신규 테이블 생성 없음. + +### 1) 컬럼 NULL 허용 상태 점검 및 백필 + +회원 기본 시간대(ZoneId) 보정 (기본값을 `Asia/Seoul`로 지정): + +```sql +UPDATE member +SET zone_id = 'Asia/Seoul' +WHERE zone_id IS NULL + OR TRIM(zone_id) = ''; +``` + +작업 마감 `deadline_zone_id` 백필 (회원 TZ 사용): + +```sql +UPDATE task t + JOIN member m +ON t.owner_id = m.member_id +SET t.deadline_zone_id = m.zone_id + WHERE + (t.deadline_zone_id IS NULL OR TRIM(t.deadline_zone_id) = '') + AND m.zone_id IS NOT NULL; +``` + +통계 `start_of_week_zone_id` 백필 (회원 TZ 사용): + +```sql +UPDATE statistics s + JOIN member m +ON s.member_id = m.member_id +SET s.start_of_week_zone_id = m.zone_id + WHERE +(s.start_of_week_zone_id IS NULL OR TRIM(s.start_of_week_zone_id) = '') +AND m.zone_id IS NOT NULL; +``` + +### 2) 검증 쿼리 (NULL/공백 잔존 확인) + +```sql +SELECT COUNT(*) AS null_member_zone +FROM member +WHERE zone_id IS NULL + OR TRIM(zone_id) = ''; +SELECT COUNT(*) AS null_task_zone +FROM task +WHERE deadline_zone_id IS NULL + OR TRIM(deadline_zone_id) = ''; +SELECT COUNT(*) AS null_stats_zone +FROM statistics +WHERE start_of_week_zone_id IS NULL + OR TRIM(start_of_week_zone_id) = ''; +``` + +세 값이 모두 0인지 확인. + +### 3) NOT NULL 전환 + +```sql +ALTER TABLE member + MODIFY COLUMN zone_id VARCHAR (64) NOT NULL; + +ALTER TABLE task + MODIFY COLUMN deadline_zone_id VARCHAR (64) NOT NULL, + MODIFY COLUMN deadline_offset_id VARCHAR (16) NOT NULL, + MODIFY COLUMN deadline_date DATE NOT NULL; + +ALTER TABLE statistics + MODIFY COLUMN start_of_week_zone_id VARCHAR (64) NOT NULL, + MODIFY COLUMN start_of_week_offset_id VARCHAR (16) NOT NULL, + MODIFY COLUMN start_of_week_date DATE NOT NULL; +``` + +### 4) 인덱스 점검/보완 + +- `task`: 이미 `idx_task_owner_deadline (owner_id, deadline_date, task_id)` 존재. 필요 시 ZoneId 포함 복합 인덱스 추가: + +```sql +CREATE INDEX idx_task_owner_deadline_zone + ON task (owner_id, deadline_date, deadline_zone_id); +``` + +- `statistics`: 주차 집계 조회 최적화: + +```sql +CREATE INDEX idx_statistics_member_week_zone + ON statistics (member_id, start_of_week_date, start_of_week_zone_id); +``` + +### 5) 롤백 시나리오 + +```sql +ALTER TABLE statistics + DROP INDEX idx_statistics_member_week_zone; +ALTER TABLE task + DROP INDEX idx_task_owner_deadline_zone; +-- NOT NULL을 다시 NULL 허용으로 돌릴 경우 (필요 시만) +ALTER TABLE statistics + MODIFY COLUMN start_of_week_zone_id VARCHAR (64) NULL, + MODIFY COLUMN start_of_week_offset_id VARCHAR (16) NULL, + MODIFY COLUMN start_of_week_date DATE NULL; +ALTER TABLE task + MODIFY COLUMN deadline_zone_id VARCHAR (64) NULL, + MODIFY COLUMN deadline_offset_id VARCHAR (16) NULL, + MODIFY COLUMN deadline_date DATE NULL; +ALTER TABLE member + MODIFY COLUMN zone_id VARCHAR (64) NULL; +``` diff --git "a/docs/005\354\213\234\352\260\204\353\214\200 \354\240\225\353\263\264 \352\264\200\353\246\254 \353\260\251\354\213\235/\354\213\234\352\260\204\353\214\200 \354\240\225\353\263\264 \352\264\200\353\246\254 \353\260\251\354\213\235.md" "b/docs/005\354\213\234\352\260\204\353\214\200 \354\240\225\353\263\264 \352\264\200\353\246\254 \353\260\251\354\213\235/\354\213\234\352\260\204\353\214\200 \354\240\225\353\263\264 \352\264\200\353\246\254 \353\260\251\354\213\235.md" new file mode 100644 index 0000000..508ae14 --- /dev/null +++ "b/docs/005\354\213\234\352\260\204\353\214\200 \354\240\225\353\263\264 \352\264\200\353\246\254 \353\260\251\354\213\235/\354\213\234\352\260\204\353\214\200 \354\240\225\353\263\264 \352\264\200\353\246\254 \353\260\251\354\213\235.md" @@ -0,0 +1,60 @@ +# 시간대 정보 관리 체크리스트 (순서형) + +사용자별 시간대를 올바르게 처리하기 위해 단계별로 진행할 수 있는 체크리스트다. 위에서 아래로 내려가며 항목을 확인/수정하면 된다. + +## 0. 현행 상태 점검 (2026-02-01 기준) + +- [x] DB 커넥션 `serverTimezone=UTC` 설정 확인 (`application-dev.yml`, `application-prod.yml`). +- [x] 회원 기본 TZ가 `Asia/Seoul`로 하드코딩됨 (`MemberService#enrollMember`) → 가입 시 전달된 IANA TZ 저장으로 교체. +- [x] 마감/데일리 VO가 `LocalDate + ZoneOffset`만 저장 (`ZonedDateAttribute`, `TemporalConstraint`) → ZoneId 보존 설계/마이그레이션 완료. +- [x] DTO 불일치: `DateWithOffset`(Offset만) vs `DateTimeWithZone`(ZoneId 포함) → ZoneId 포함 DTO로 통일. +- [x] `findZoneOffsetOfMember`가 `Instant.now()` 기준으로 오프셋 산출 → 이벤트 시각 기준 오프셋 계산 API/유틸 보강. + +## 1. 회원 시간대 수집·보관 준비 + +- [x] 회원 `timeZone` 필드는 **IANA Zone ID**(예: `Asia/Seoul`, `America/Los_Angeles`)를 “단일 진실 값”으로 저장한다. 기본값 하드코딩 금지, + `UTC+09:00` 같은 오프셋 문자열을 `zoneId` 자리에 넣지 않는다. +- [x] 클라이언트가 보내는 TZ + 오프셋을 검증 후 저장(유효한 IANA인지 확인). + +## 2. 도메인·DB 정비 + +- [x] 모든 시간 컬럼을 UTC로 저장한다. MySQL 사용 시 `DATETIME(6)`+UTC 해석 컨버터(`InstantToDatetime6UtcConverter`) 적용. +- [x] 반복 일정/리마인더: 로컬 시간(사용자 TZ)으로 정의를 저장, 전개 시 UTC 발생 시각 생성. +- [x] 마감·데일리 값은 IANA Zone ID를 함께 저장해 언제든 정확한 오프셋을 다시 계산할 수 있게 한다. 오프셋(`+09:00`)은 필요 시 파생 캐시로만 사용한다. + +## 3. API 계약 및 버전 관리 + +- [x] 요청/응답 DTO에 시간대 정보를 필수 포함(가급적 ZoneId, 최소 Offset). `DateWithOffset`/`DateTimeWithZone` 등 스키마를 통일. +- [x] “특정 날짜의 모든 일정/작업” 요청은 사용자 TZ 기준 하루 시작/끝을 계산해 `startUtc`/`endUtc` 범위로 쿼리. (일정: UTC 범위 적용, 작업: zoneId 필터로 정렬 완료) +- [x] “특정 주차(week) 조회”도 동일하게 사용자 TZ에서 주 시작/끝을 구해 UTC 범위로 변환한다. 주 시작 요일(ISO 월요일 등)을 스키마/문서로 명시하고 서버·클라가 동일 규칙을 사용하도록 한다. +- [x] DST로 하루/주 길이가 달라질 수 있으니 `LocalDate → ZonedDateTime → Instant`로 구간 계산. +- [x] 시간·스키마 규칙이 바뀌면 컨트롤러 버전 상승(`TaskControllerV3` 등) 및 `OpenApiConfig.info.version` 동시 갱신. + +## 4. 서비스·유틸 및 쿼리/배치 구현 + +- [x] 공용 변환 유틸/서비스(`TimeZoneConverter` 등)로 TZ ↔ UTC 변환을 일원화하고 테스트 가능하게 유지. +- [x] 오프셋 계산은 이벤트 시각 기준으로 수행하는 함수 제공(단순 `Instant.now()` 의존 제거). +- [x] 배치/스케줄러는 UTC 기준 트리거 또는 `zone="UTC"` 명시; 사용자 로컬 시간 기반 작업은 TZ 버킷별로 UTC 시간 계산. +- [x] 리트라이·지연 큐가 UTC 단위로 처리되는지 확인. + +## 5. 프런트엔드 연동 + +- [ ] 클라이언트에서 IANA TZ와 오프셋을 함께 전송하고, 받은 UTC를 로컬 TZ로 렌더링. +- [ ] 디바이스 TZ 변경 시 오프라인/캐시 데이터가 재계산되는지 확인. + +## 6. 테스트·모니터링·마이그레이션 + +- [x] 단위 테스트: 변환 유틸의 TZ·DST 파라미터화 케이스. +- [x] 통합 테스트: 서로 다른 TZ 계정으로 동일 이벤트가 다른 시각에 보이는지 검증. +- [x] 로그/메트릭에 `requesterTimeZone`, `computedUtc`, `renderedLocal` 등을 남겨 디버깅 가능하게 한다. +- [x] ZoneId 추가나 컬럼 타입 변경이 필요하면 마이그레이션/백필 계획을 수립하고 적용. + +## 7. V2 API 호환성 메모 + +- 일정 조회 V2 (`/v2/schedules`, `/v2/schedules/week`)는 `LocalDateTime + ZoneId`를 받으므로 상기 규칙과 일치. +- 작업 생성/수정 V2는 `마감 LocalDate + ZoneOffset`을 요구: 체크리스트의 “가급적 ZoneId, 최소 Offset” 범위 안에 있으나, ZoneId 기반으로 전환 시 컨트롤러 버전업 필요. +- 작업 마감일 조회 V2 (`/v2/tasks/by-deadline`)는 `LocalDate`만 받음: 사용자 TZ 입력 없이 저장된 Offset에 의존. 체크리스트 3단계(날짜 조회는 TZ→UTC 범위 변환)에 + 맞추려면 신규 버전에서 TZ 입력을 추가해야 함. +- 작업 완료 아카이브 V2 (`/v2/tasks/completed`)는 Optional `offset`을 받고, 없으면 `findZoneOffsetOfMember(now)`를 사용: 이벤트 시각 기준 오프셋 + 계산으로 교체하거나, ZoneId 입력을 받는 버전으로 승격 필요. +- 일반 목록/커서 조회 V2는 회원 프로필의 현재 Offset을 사용해 “오늘”을 계산하는 구조: DST/미래 일정 정확도를 높이려면 TZ + 기준 시각을 받아 계산하도록 차기 버전에서 수정. From 05e530b1fb6e109e227b26ff4d017843ca061f77 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Tue, 3 Feb 2026 01:40:25 +0900 Subject: [PATCH 41/42] =?UTF-8?q?feat:=20ZoneId=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20@NotNull=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20resolveOf?= =?UTF-8?q?fset=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/pinit/pinittask/interfaces/dto/DateWithOffset.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java index 311130f..b7111fd 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java @@ -15,7 +15,6 @@ public record DateWithOffset( LocalDate date, @Schema(description = "UTC 기준 오프셋(+HH:mm). IANA `zoneId`로 계산된 값을 명시하고 싶을 때 선택적으로 포함", example = "+09:00") ZoneOffset offset, - @NotNull @Schema(description = "IANA 시간대 ID (필수)", example = "Asia/Seoul") ZoneId zoneId ) { @@ -37,9 +36,4 @@ public ZoneId resolveZoneId(ZoneId fallbackZoneId) { Objects.requireNonNull(fallbackZoneId, "fallbackZoneId must not be null"); return zoneId == null ? fallbackZoneId : zoneId; } - - public ZoneOffset resolveOffset(ZoneId fallbackZoneId) { - ZoneId effectiveZone = resolveZoneId(fallbackZoneId); - return date.atStartOfDay(effectiveZone).getOffset(); - } } From 42661b24f7937fa76c1e0cdc1abec82079ad768c Mon Sep 17 00:00:00 2001 From: GoGradually Date: Tue, 3 Feb 2026 01:49:35 +0900 Subject: [PATCH 42/42] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20v1,=20v0=20member=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/member/MemberControllerV0.java | 44 ---------------- .../interfaces/member/MemberControllerV1.java | 52 ------------------- 2 files changed, 96 deletions(-) delete mode 100644 src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV0.java delete mode 100644 src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV1.java diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV0.java b/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV0.java deleted file mode 100644 index 4640de8..0000000 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV0.java +++ /dev/null @@ -1,44 +0,0 @@ -package me.gg.pinit.pinittask.interfaces.member; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import me.gg.pinit.pinittask.application.member.service.MemberService; -import me.gg.pinit.pinittask.interfaces.utils.MemberId; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Deprecated -@RestController -@RequestMapping("/v0") -@Tag(name = "Member", description = "회원 관련 정보 API") -public class MemberControllerV0 { - private final MemberService memberService; - - public MemberControllerV0(MemberService memberService) { - this.memberService = memberService; - } - - @GetMapping("/now") - @Operation(summary = "현재 진행 중인 일정 ID 조회", description = "사용자의 현재 진행 중인 일정 ID를 조회합니다.") - public ResponseEntity getNowInProgressScheduleId(@Parameter(hidden = true) @MemberId Long memberId) { - Long scheduleId = memberService.getNowInProgressScheduleId(memberId); - return ResponseEntity.ok(scheduleId); - } - - @GetMapping("/zone-offset") - @Operation(summary = "사용자 시간대 조회", description = "사용자의 시간대를 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "사용자 시간대 조회 성공", content = @Content(schema = @Schema(implementation = String.class, example = "+09:00"))), - }) - public ResponseEntity getMemberZoneOffset(@Parameter(hidden = true) @MemberId Long memberId) { - String zoneOffset = memberService.findZoneOffsetOfMember(memberId).toString(); - return ResponseEntity.ok(zoneOffset); - } -} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV1.java b/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV1.java deleted file mode 100644 index 41f0cee..0000000 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV1.java +++ /dev/null @@ -1,52 +0,0 @@ -package me.gg.pinit.pinittask.interfaces.member; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import me.gg.pinit.pinittask.application.member.service.MemberService; -import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; -import me.gg.pinit.pinittask.interfaces.utils.MemberId; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Deprecated -@RestController -@RequestMapping("/v1/members") -@Tag(name = "MemberV1", description = "회원 관련 정보 API (v1)") -@ApiResponses({ - @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "404", description = "대상을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) -}) -@RequiredArgsConstructor -public class MemberControllerV1 { - - private final MemberService memberService; - - @GetMapping("/now") - @Operation(summary = "현재 진행 중인 일정 ID 조회", description = "사용자의 현재 진행 중인 일정 ID를 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "현재 진행 중인 일정 ID 조회 성공", content = @Content(schema = @Schema(implementation = Long.class, nullable = true))) - }) - public ResponseEntity getNowInProgressScheduleId(@Parameter(hidden = true) @MemberId Long memberId) { - Long scheduleId = memberService.getNowInProgressScheduleId(memberId); - return ResponseEntity.ok(scheduleId); - } - - @GetMapping("/zone-offset") - @Operation(summary = "사용자 시간대 조회", description = "사용자의 시간대를 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "사용자 시간대 조회 성공", content = @Content(schema = @Schema(implementation = String.class, example = "+09:00"))), - }) - public ResponseEntity getMemberZoneOffset(@Parameter(hidden = true) @MemberId Long memberId) { - String zoneOffset = memberService.findZoneOffsetOfMember(memberId).toString(); - return ResponseEntity.ok(zoneOffset); - } -}