Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@
import me.pinitnotification.domain.notification.UpcomingScheduleNotification;
import me.pinitnotification.domain.notification.UpcomingScheduleNotificationRepository;
import me.pinitnotification.domain.shared.IdGenerator;
import me.pinitnotification.domain.shared.ScheduleStartTimeFormatter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;

@Service
public class ScheduleNotificationService {
private static final Logger log = LoggerFactory.getLogger(ScheduleNotificationService.class);
Expand All @@ -39,7 +42,7 @@ public void handleUpcomingUpdated(UpcomingUpdatedCommand command) {
resolveScheduleStartTime(command, existing),
command.idempotentKey()
),
() -> notificationRepository.save(buildNotification(command.ownerId(), command.scheduleId(), command.idempotentKey(), toStringValue(command.newUpcomingTime())))
() -> notificationRepository.save(buildNotification(command.ownerId(), command.scheduleId(), command.idempotentKey(), toInstantValue(command.newUpcomingTime())))
);
}

Expand Down Expand Up @@ -79,9 +82,11 @@ public void handleScheduleCompleted(ScheduleStateChangedCommand command) {
}
}

private UpcomingScheduleNotification buildNotification(Long ownerId, Long scheduleId, String idempotentKey, String scheduleStartTimeOverride) {
private UpcomingScheduleNotification buildNotification(Long ownerId, Long scheduleId, String idempotentKey, Instant scheduleStartTimeOverride) {
ScheduleBasics basics = scheduleQueryPort.getScheduleBasics(scheduleId, ownerId);
String scheduleStartTime = scheduleStartTimeOverride != null ? scheduleStartTimeOverride : basics.designatedStartTime();
Instant scheduleStartTime = scheduleStartTimeOverride != null
? scheduleStartTimeOverride
: ScheduleStartTimeFormatter.parse(basics.designatedStartTime());

return new UpcomingScheduleNotification(
idGenerator.generate(),
Expand All @@ -93,12 +98,12 @@ private UpcomingScheduleNotification buildNotification(Long ownerId, Long schedu
);
}

private String toStringValue(java.time.OffsetDateTime dateTime) {
return dateTime == null ? null : dateTime.toString();
private Instant toInstantValue(java.time.OffsetDateTime dateTime) {
return dateTime == null ? null : dateTime.toInstant();
}

private String resolveScheduleStartTime(UpcomingUpdatedCommand command, UpcomingScheduleNotification existing) {
String newStartTime = toStringValue(command.newUpcomingTime());
private Instant resolveScheduleStartTime(UpcomingUpdatedCommand command, UpcomingScheduleNotification existing) {
Instant newStartTime = toInstantValue(command.newUpcomingTime());
return newStartTime != null ? newStartTime : existing.getScheduleStartTime();
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package me.pinitnotification.domain.notification;

import lombok.Getter;
import me.pinitnotification.domain.shared.ScheduleStartTimeFormatter;

import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;

Expand All @@ -13,12 +13,12 @@ public class UpcomingScheduleNotification implements Notification {
private Long ownerId;
private Long scheduleId;
private String scheduleTitle;
private String scheduleStartTime;
private Instant scheduleStartTime;
private String idempotentKey;

protected UpcomingScheduleNotification() {}

public UpcomingScheduleNotification(UUID id, Long ownerId, Long scheduleId, String scheduleTitle, String scheduleStartTime, String idempotentKey) {
public UpcomingScheduleNotification(UUID id, Long ownerId, Long scheduleId, String scheduleTitle, Instant scheduleStartTime, String idempotentKey) {
this.id = id;
this.ownerId = ownerId;
this.scheduleId = scheduleId;
Expand All @@ -27,7 +27,7 @@ public UpcomingScheduleNotification(UUID id, Long ownerId, Long scheduleId, Stri
this.idempotentKey = idempotentKey;
}

public UpcomingScheduleNotification(Long ownerId, Long scheduleId, String scheduleTitle, String scheduleStartTime, String idempotentKey) {
public UpcomingScheduleNotification(Long ownerId, Long scheduleId, String scheduleTitle, Instant scheduleStartTime, String idempotentKey) {
this.ownerId = ownerId;
this.scheduleId = scheduleId;
this.scheduleTitle = scheduleTitle;
Expand All @@ -39,24 +39,16 @@ public UpcomingScheduleNotification(Long ownerId, Long scheduleId, String schedu
public Map<String, String> getData() {
return Map.of("scheduleId", String.valueOf(scheduleId),
"scheduleTitle", scheduleTitle,
"scheduleStartTime", scheduleStartTime,
"scheduleStartTime", ScheduleStartTimeFormatter.format(scheduleStartTime),
"idempotentKey", idempotentKey);
}

public void updateScheduleStartTime(String scheduleStartTime, String idempotentKey) {
public void updateScheduleStartTime(Instant scheduleStartTime, String idempotentKey) {
this.scheduleStartTime = scheduleStartTime;
this.idempotentKey = idempotentKey;
}

public boolean isDue(OffsetDateTime now) {
if (scheduleStartTime == null) {
return false;
}
try {
OffsetDateTime startTime = OffsetDateTime.parse(scheduleStartTime);
return !startTime.isAfter(now);
} catch (DateTimeParseException ex) {
return false;
}
public boolean isDue(Instant now) {
return scheduleStartTime != null && !scheduleStartTime.isAfter(now);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package me.pinitnotification.domain.notification;

import java.time.Instant;
import java.util.List;
import java.util.Optional;

Expand All @@ -9,7 +10,7 @@ public interface UpcomingScheduleNotificationRepository {
boolean existsByScheduleIdAndOwnerId(Long scheduleId, Long ownerId);
UpcomingScheduleNotification save(UpcomingScheduleNotification notification);

void updateScheduleStartTimeAndIdempotentKey(Long scheduleId, Long ownerId, String scheduleStartTime, String idempotentKey);
void updateScheduleStartTimeAndIdempotentKey(Long scheduleId, Long ownerId, Instant scheduleStartTime, String idempotentKey);
void deleteByScheduleIdAndOwnerId(Long scheduleId, Long ownerId);
void deleteAllInBatch(List<UpcomingScheduleNotification> notifications);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package me.pinitnotification.domain.shared;

import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;

public final class ScheduleStartTimeFormatter {
private static final DateTimeFormatter UTC_SECONDS_FORMATTER = DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC);

private ScheduleStartTimeFormatter() {
}

public static String format(Instant instant) {
return UTC_SECONDS_FORMATTER.format(instant.truncatedTo(ChronoUnit.SECONDS));
}

public static String format(OffsetDateTime offsetDateTime) {
return format(offsetDateTime.toInstant());
}

public static String normalize(String rawScheduleStartTime) {
return format(parse(rawScheduleStartTime));
}

public static Instant parse(String rawScheduleStartTime) {
if (rawScheduleStartTime == null || rawScheduleStartTime.isBlank()) {
throw new IllegalArgumentException("scheduleStartTime must not be null or blank");
}

try {
return OffsetDateTime.parse(rawScheduleStartTime).toInstant();
} catch (DateTimeParseException ignored) {
return Instant.parse(rawScheduleStartTime);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import me.gg.pinit.pinittask.grpc.ScheduleGrpcServiceGrpc;
import me.pinitnotification.application.notification.query.ScheduleBasics;
import me.pinitnotification.application.notification.query.ScheduleQueryPort;
import me.pinitnotification.domain.shared.ScheduleStartTimeFormatter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
Expand Down Expand Up @@ -46,6 +47,6 @@ public ScheduleBasics getScheduleBasics(Long scheduleId, Long ownerId) {

private String toIsoString(Timestamp timestamp) {
Instant instant = Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos());
return instant.toString();
return ScheduleStartTimeFormatter.format(instant);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;

import java.sql.Timestamp;
import java.time.Instant;
import java.util.*;

Expand Down Expand Up @@ -34,16 +35,21 @@ public NotificationDispatchQueryRepositoryAdapter(JdbcClient jdbcClient) {
@Override
public List<NotificationDispatchItem> findAllDueNotificationsWithTokens(Instant now) {
List<DispatchRow> rows = jdbcClient.sql(FIND_DUE_WITH_TOKENS_SQL)
.param(now.toString())
.query((rs, rowNum) -> new DispatchRow(
UUID.fromString(rs.getString("public_id")),
rs.getLong("owner_id"),
rs.getLong("schedule_id"),
rs.getString("schedule_title"),
rs.getString("schedule_start_time"),
rs.getString("idempotent_key"),
rs.getString("token")
))
.param(Timestamp.from(now))
.query((rs, rowNum) -> {
Timestamp scheduleStartAt = rs.getTimestamp("schedule_start_time");
Instant scheduleStartTime = scheduleStartAt == null ? null : scheduleStartAt.toInstant();

return new DispatchRow(
UUID.fromString(rs.getString("public_id")),
rs.getLong("owner_id"),
rs.getLong("schedule_id"),
rs.getString("schedule_title"),
scheduleStartTime,
rs.getString("idempotent_key"),
rs.getString("token")
);
})
.list();

if (rows.isEmpty()) {
Expand Down Expand Up @@ -85,7 +91,7 @@ private record DispatchRow(
Long ownerId,
Long scheduleId,
String scheduleTitle,
String scheduleStartTime,
Instant scheduleStartTime,
String idempotentKey,
String token
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

import java.time.Instant;
import java.util.UUID;

@Entity
Expand Down Expand Up @@ -35,7 +36,7 @@ public class UpcomingScheduleNotificationEntity {
@Column(name = "schedule_title", nullable = false)
private String scheduleTitle;
@Column(name = "schedule_start_time", nullable = false)
private String scheduleStartTime;
private Instant scheduleStartTime;
@Column(name = "idempotent_key", nullable = false)
Comment on lines 38 to 40
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(1) 문제점: schedule_start_time 컬럼 매핑이 StringInstant로 변경되어 JPA가 DB 컬럼을 TIMESTAMP/DATETIME 계열로 기대하게 됩니다. 현재 prod 설정이 ddl-auto: validate라서 운영 DB 스키마가 즉시 반영되지 않으면 애플리케이션이 기동 단계에서 스키마 검증 실패로 내려갈 수 있습니다.
(2) 영향: 운영 배포 시 서비스 기동 불가(또는 dev에서는 자동 스키마 변경으로 데이터/인덱스 변환이 발생) 가능성이 있으며, 기존 VARCHAR 데이터가 있다면 변환 과정에서 파싱 실패/값 손실 위험이 있습니다.
(3) 수정 제안: 이 변경과 함께 명시적인 DB 마이그레이션(예: ALTER TABLE ... MODIFY schedule_start_time TIMESTAMP(6) ... + 기존 문자열 데이터를 UTC TIMESTAMP로 변환)과 배포 순서(선-스키마, 후-애플리케이션)를 PR에 포함하거나, 최소한 운영 반영 절차를 문서/릴리즈 노트로 남겨주세요.

Copilot uses AI. Check for mistakes.
private String idempotentKey;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.Instant;
import java.util.Optional;
import java.util.UUID;

Expand All @@ -23,7 +24,7 @@ public interface UpcomingScheduleNotificationJpaRepository extends JpaRepository
int updateScheduleStartTimeAndIdempotentKey(
@Param("scheduleId") Long scheduleId,
@Param("ownerId") Long ownerId,
@Param("scheduleStartTime") String scheduleStartTime,
@Param("scheduleStartTime") Instant scheduleStartTime,
@Param("idempotentKey") String idempotentKey
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Repository;

import java.time.Instant;
import java.util.List;
import java.util.Optional;

Expand Down Expand Up @@ -44,8 +45,13 @@ public UpcomingScheduleNotification save(UpcomingScheduleNotification notificati
}

@Override
public void updateScheduleStartTimeAndIdempotentKey(Long scheduleId, Long ownerId, String scheduleStartTime, String idempotentKey) {
int updatedRows = jpaRepository.updateScheduleStartTimeAndIdempotentKey(scheduleId, ownerId, scheduleStartTime, idempotentKey);
public void updateScheduleStartTimeAndIdempotentKey(Long scheduleId, Long ownerId, Instant scheduleStartTime, String idempotentKey) {
int updatedRows = jpaRepository.updateScheduleStartTimeAndIdempotentKey(
scheduleId,
ownerId,
scheduleStartTime,
idempotentKey
);
if (updatedRows == 0) {
log.debug("Skip updating notification. scheduleId={}, ownerId={} not found", scheduleId, ownerId);
}
Expand Down
4 changes: 3 additions & 1 deletion src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ spring:
ddl-auto: update # dev: update, create-drop / prod: validate 추천
properties:
hibernate:
jdbc:
time_zone: UTC
format_sql: true
show_sql: true
open-in-view: false

path:
key:
firebase: ${HOME}/pinit/keys/pinit-firebase-key.json
firebase: ${HOME}/pinit/keys/pinit-firebase-key.json
6 changes: 5 additions & 1 deletion src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ spring:
jpa:
hibernate:
ddl-auto: validate # dev: update, create-drop / prod: validate 추천
properties:
hibernate:
jdbc:
time_zone: UTC
open-in-view: false
datasource:
url: jdbc:mysql://${DB_HOST}:${DB_PORT}/pinit_notification?characterEncoding=UTF-8
Expand All @@ -22,4 +26,4 @@ spring:

path:
key:
firebase: /etc/keys/pinit-firebase-key.json
firebase: /etc/keys/pinit-firebase-key.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ void setUp() {

@Test
void dispatchDueNotifications_sendsAndDeletesNotificationsFromQuery() {
UpcomingScheduleNotification notification = new UpcomingScheduleNotification(1L, 10L, "title", "2024-06-01T09:50Z", "key-1");
UpcomingScheduleNotification notification = new UpcomingScheduleNotification(
1L, 10L, "title", Instant.parse("2024-06-01T09:50:00Z"), "key-1"
);

when(dispatchQueryRepository.findAllDueNotificationsWithTokens(any()))
.thenReturn(List.of(new NotificationDispatchItem(notification, List.of("token-1", "token-2"))));
Expand All @@ -57,7 +59,9 @@ void dispatchDueNotifications_sendsAndDeletesNotificationsFromQuery() {

@Test
void dispatchDueNotifications_deletesEvenWhenNoTokens() {
UpcomingScheduleNotification past = new UpcomingScheduleNotification(2L, 20L, "title", "2024-06-01T09:00Z", "key-3");
UpcomingScheduleNotification past = new UpcomingScheduleNotification(
2L, 20L, "title", Instant.parse("2024-06-01T09:00:00Z"), "key-3"
);

when(dispatchQueryRepository.findAllDueNotificationsWithTokens(any()))
.thenReturn(List.of(new NotificationDispatchItem(past, List.of())));
Expand All @@ -71,7 +75,9 @@ void dispatchDueNotifications_deletesEvenWhenNoTokens() {

@Test
void dispatchDueNotifications_collectsInvalidTokensAndDeletesInBatch() {
UpcomingScheduleNotification notification = new UpcomingScheduleNotification(3L, 30L, "title", "2024-06-01T09:30Z", "key-4");
UpcomingScheduleNotification notification = new UpcomingScheduleNotification(
3L, 30L, "title", Instant.parse("2024-06-01T09:30:00Z"), "key-4"
);

when(dispatchQueryRepository.findAllDueNotificationsWithTokens(any()))
.thenReturn(List.of(new NotificationDispatchItem(notification, List.of("token-1", "token-2", "token-3"))));
Expand Down
Loading
Loading