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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.mnode.ical4j:ical4j:3.2.17'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/com/tryna/domain/event/entity/Events.java
Original file line number Diff line number Diff line change
Expand Up @@ -321,4 +321,26 @@ public void resurrectExternalEvent(ExternalCalendars externalCalendar, String ti
this.eventStatus = EventStatus.CONFIRMED;
this.deletedAt = null;
}

// ๊ณตํœด์ผ ์ „์šฉ ์ƒ์„ฑ ์ •์  ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ
public static Events createHolidayEvent(
String title,
LocalDate startDate,
String externalEventId
) {
Events event = new Events();
event.title = title;
event.sourceText = title;
event.startDate = startDate;
event.endDate = startDate;
event.isAllDay = true;
event.isRecurring = false;
event.recurrenceType = RecurrenceType.NONE;
event.recurrenceDayOfWeek = RecurrenceDayOfWeek.NONE;
event.sourceType = SourceType.HOLIDAY;
event.eventStatus = EventStatus.CONFIRMED;
event.externalEventId = externalEventId;
event.provider = Provider.APPLE;
return event;
}
}
3 changes: 2 additions & 1 deletion src/main/java/com/tryna/domain/event/enums/SourceType.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ public enum SourceType {
USER_NATURAL_LANGUAGE,
USER_MANUAL_EDIT,
EXTERNAL_CALENDAR,
EXTERNAL_BASED_INTERNAL
EXTERNAL_BASED_INTERNAL,
HOLIDAY
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,6 @@

public interface EventsRepository extends JpaRepository<Events, Long> {

@Query(value = """
SELECT e.*
FROM events e
JOIN user_events ue ON ue.event_id = e.event_id
WHERE e.external_calendar_id = :externalCalendarId
AND e.external_event_id IN :externalEventIds
AND e.source_type = 'EXTERNAL_CALENDAR'
""", nativeQuery = true)
List<Events> findAllIncludingDeletedByExternalCalendarIdAndExternalEventIdIn(
@Param("externalCalendarId") Long externalCalendarId,
@Param("externalEventIds") List<String> externalEventIds
);

@Query("""
SELECT COUNT(e) > 0
FROM Events e
Expand Down Expand Up @@ -148,4 +135,33 @@ List<Events> findAllIncludingDeletedByUserIdAndExternalEventIdIn(
@Param("userId") Long userId,
@Param("externalEventIds") Collection<String> externalEventIds
);

/**
* ํŠน์ • ๊ธฐ๊ฐ„ ๋‚ด์˜ ์‹œ์Šคํ…œ ๊ณตํœด์ผ ์ผ์ •๋งŒ ๋‹จ๋…์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
*/
@Query("""
SELECT e
FROM Events e
WHERE e.sourceType = com.tryna.domain.event.enums.SourceType.HOLIDAY
AND e.startDate IS NOT NULL
AND e.startDate <= :endDate
AND COALESCE(e.endDate, e.startDate) >= :startDate
AND e.deletedAt IS NULL
""")
List<Events> findHolidaysInRange(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);

/**
* ๊ณตํœด์ผ ์ค‘๋ณต ์‚ฝ์ž… ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด ์กด์žฌ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.
*/
@Query("""
SELECT COUNT(e) > 0
FROM Events e
WHERE e.sourceType = com.tryna.domain.event.enums.SourceType.HOLIDAY
AND e.externalEventId = :externalEventId
AND e.deletedAt IS NULL
""")
boolean existsHolidayByExternalEventId(@Param("externalEventId") String externalEventId);
}
47 changes: 37 additions & 10 deletions src/main/java/com/tryna/domain/event/service/EventQueryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ public class EventQueryService {
EventStatus.NEEDS_CONFIRMATION
);

// ๊ฐ€์ƒ ๋ผ๋ฒจ ID ์ƒ์ˆ˜ ์ถ”๊ฐ€
private static final Long HOLIDAY_LABEL_ID = -1L;

private final EventsRepository eventsRepository;
private final UserEventsRepository userEventsRepository;
private final ActionItemsRepository actionItemsRepository;
Expand All @@ -66,7 +69,9 @@ public CalendarMainResponse getCalendarMain(Long userId, Integer year, Integer m

CalendarMonthlyResponse monthlyCalendar = buildMonthlyCalendar(userId, year, month);
CalendarDateEventsResponse dateEvents = buildDateEvents(userId, selectedDate);
boolean hasEvents = userEventsRepository.countVisibleEventsByUserId(userId, VISIBLE_EVENT_STATUSES) > 0;

// ๊ฐœ์ธ ์ผ์ • ์—ฌ๋ถ€๋ฅผ ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„
boolean hasPersonalEvents = userEventsRepository.countVisibleEventsByUserId(userId, VISIBLE_EVENT_STATUSES) > 0;

List<CalendarMonthlyResponse.DayEventCount> monthlyEventDays = monthlyCalendar.days()
.stream()
Expand All @@ -78,14 +83,31 @@ public CalendarMainResponse getCalendarMain(Long userId, Integer year, Integer m
month,
monthlyCalendar.today(),
selectedDate,
hasEvents,
hasPersonalEvents, // ๊ธฐ์กด DTO ํ•„๋“œ๋ฅผ ํ™œ์šฉํ•˜์—ฌ '๊ฐœ์ธ ์ผ์ • ์—ฌ๋ถ€(personal-event state)'๋กœ ์‚ฌ์šฉ
false,
resolveMainEmptyState(hasEvents, dateEvents),
// ์ด๋ฒˆ ๋‹ฌ๋ ฅ์— ํ‘œ์‹œํ•  ์ผ์ •(๊ณตํœด์ผ ๋“ฑ)์ด ์žˆ๋Š”์ง€๋„ Empty State ํŒ๋‹จ์— ๋„˜๊น€
resolveMainEmptyState(hasPersonalEvents, !monthlyEventDays.isEmpty(), dateEvents),
monthlyEventDays,
dateEvents.events()
);
}

/**
* ๊ฐœ์ธ ์ผ์ •๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ๊ณตํœด์ผ์„ ํฌํ•จํ•ด ์บ˜๋ฆฐ๋” ๋นˆ ํ™”๋ฉด ์ƒํƒœ๋ฅผ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค.
*/
private String resolveMainEmptyState(boolean hasPersonalEvents, boolean hasMonthlyEvents, CalendarDateEventsResponse dateEvents) {
// ์„ ํƒํ•œ ๋‚ ์งœ์— ํ‘œ์‹œํ•  ์ผ์ •(๊ณตํœด์ผ ํฌํ•จ)์ด ๋‹จ ํ•˜๋‚˜๋ผ๋„ ์žˆ๋‹ค๋ฉด Empty State ํ•ด์ œ
if (!dateEvents.events().isEmpty()) {
return null;
}
// ์‚ฌ์šฉ์ž๊ฐ€ ์ž‘์„ฑํ•œ ๊ฐœ์ธ ์ผ์ •๋„ ์—†๊ณ , ์ด๋ฒˆ ๋‹ฌ๋ ฅ์— ๊ทธ๋ ค์ค„ ๊ณตํœด์ผ์กฐ์ฐจ ์™„์ „ํžˆ ์—†๋‹ค๋ฉด ๊ธ€๋กœ๋ฒŒ Empty State ๋ฐ˜ํ™˜
if (!hasPersonalEvents && !hasMonthlyEvents) {
return NO_EVENTS;
}
// ๊ทธ ์™ธ (๋‹ฌ๋ ฅ์— ๊ณตํœด์ผ์€ ์žˆ์–ด์„œ ๋ Œ๋”๋งํ•ด์•ผ ํ•˜์ง€๋งŒ, ์„ ํƒํ•œ ๋‚ ์งœ์—” ์•„๋ฌด๊ฒƒ๋„ ์—†๋Š” ๊ฒฝ์šฐ)
return dateEvents.emptyStateType(); // -> NO_SELECTED_DATE_EVENTS
}

/**
* ์ œ๊ณต๋œ ์—ฐ๋„์— ์‚ฌ์šฉ์ž๊ฐ€ ๋ณผ ์ˆ˜ ์žˆ๋Š”(ํ‘œ์‹œ๋˜๋Š”) ์ผ์ •์ด ์žˆ๋Š”์ง€ ํ™•์ธ
* ์ €์žฅ๋œ ์ผ์ •๊ณผ ํ•ด๋‹น ๋ฒ”์œ„ ๋‚ด์—์„œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐ˜๋ณต ์ผ์ •์„ ๋ชจ๋‘ ๊ณ ๋ คํ•ฉ๋‹ˆ๋‹ค.
Expand Down Expand Up @@ -158,13 +180,6 @@ public CalendarDateEventsResponse getDateEvents(Long userId, String dateValue) {
return buildDateEvents(userId, date);
}

private String resolveMainEmptyState(boolean hasEvents, CalendarDateEventsResponse dateEvents) {
if (!hasEvents) {
return NO_EVENTS;
}
return dateEvents.emptyStateType();
}

private CalendarDateEventsResponse buildDateEvents(Long userId, LocalDate date) {
List<UserEvents> directUserEvents = userEventsRepository.findUserEventsByDate(
userId,
Expand Down Expand Up @@ -193,6 +208,12 @@ private CalendarDateEventsResponse buildDateEvents(Long userId, LocalDate date)

occurrences.addAll(recurringOccurrences);

// ํ•ด๋‹น ๋‚ ์งœ์˜ ๊ณตํœด์ผ ๋ณ„๋„ ์กฐํšŒ ํ›„ ๋ณ‘ํ•ฉ
List<Events> holidays = eventsRepository.findHolidaysInRange(date, date);
holidays.forEach(holiday ->
occurrences.add(new EventOccurrence(holiday, holiday.getStartDate(), HOLIDAY_LABEL_ID))
);

Set<DeletedOccurrenceKey> deletedOccurrences = findDeletedOccurrenceKeys(occurrences);

List<CalendarDateEventsResponse.EventSummary> events = occurrences.stream()
Expand Down Expand Up @@ -409,6 +430,12 @@ private Map<LocalDate, List<EventOccurrence>> getOccurrencesByDate(Long userId,
}
}

// ๊ณตํœด์ผ ๋ณ„๋„ ์กฐํšŒ ํ›„ ๋ณ‘ํ•ฉ
List<Events> holidays = eventsRepository.findHolidaysInRange(startDate, endDate);
holidays.forEach(holiday ->
occurrences.add(new EventOccurrence(holiday, holiday.getStartDate(), HOLIDAY_LABEL_ID))
);

Set<DeletedOccurrenceKey> deletedOccurrences = findDeletedOccurrenceKeys(occurrences);
for (EventOccurrence occurrence : occurrences) {
if (isDeletedOccurrence(
Expand Down
145 changes: 145 additions & 0 deletions src/main/java/com/tryna/domain/event/service/HolidaySyncService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package com.tryna.domain.event.service;

import com.tryna.domain.event.entity.Events;
import com.tryna.domain.event.repository.EventsRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.fortuna.ical4j.data.CalendarBuilder;
import net.fortuna.ical4j.model.Calendar;
import net.fortuna.ical4j.model.Component;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.util.CompatibilityHints;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.GZIPInputStream;

@Slf4j
@Service
@RequiredArgsConstructor
public class HolidaySyncService {

private final EventsRepository eventsRepository;

// ์• ํ”Œ ์บ˜๋ฆฐ๋” URL
private static final String APPLE_HOLIDAY_URL = "https://calendars.icloud.com/holidays/kr_ko.ics";

// ๋„คํŠธ์›Œํฌ ํƒ€์ž„์•„์›ƒ ์„ค์ • (10์ดˆ)
private static final int TIMEOUT_MILLIS = 10_000;

@EventListener(ApplicationReadyEvent.class)
@Scheduled(cron = "0 0 4 * * *", zone = "Asia/Seoul")
public void syncAppleKoreanHolidays() {
System.setProperty("net.fortuna.ical4j.parser.relaxed", "true");
System.setProperty("net.fortuna.ical4j.relaxed.parsing.enabled", "true");
CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_RELAXED_PARSING, true);
CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_RELAXED_UNFOLDING, true);

log.info("์• ํ”Œ ๊ณตํœด์ผ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” ๋ฐ ์ฃผ๊ธฐ์  ๋™๊ธฐํ™”(Sync)๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.");

try {
URLConnection connection = new URL(APPLE_HOLIDAY_URL).openConnection();
// ํƒ€์ž„์•„์›ƒ ์ง€์ • (๋ฌดํ•œ ๋Œ€๊ธฐ ๋ฐฉ์ง€)
connection.setConnectTimeout(TIMEOUT_MILLIS);
connection.setReadTimeout(TIMEOUT_MILLIS);

// ๋ธŒ๋ผ์šฐ์ €์ธ ์ฒ™ ์œ„์žฅ (์• ํ”Œ ์„œ๋ฒ„ ์ฐจ๋‹จ ๋ฐฉ์ง€)
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)");

InputStream rawStream = connection.getInputStream();
String encoding = connection.getContentEncoding();

// ์• ํ”Œ ์„œ๋ฒ„๊ฐ€ ๋ฐ์ดํ„ฐ๋ฅผ GZIP์œผ๋กœ ์••์ถ•ํ•ด์„œ ๋ณด๋ƒˆ์„ ๊ฒฝ์šฐ ์••์ถ• ํ•ด์ œ ์ฒ˜๋ฆฌ
InputStream finalStream = (encoding != null && encoding.equalsIgnoreCase("gzip"))
? new GZIPInputStream(rawStream)
: rawScaleStreamCheck(rawStream); // ์•ˆ์ „ํ•œ ์ŠคํŠธ๋ฆผ ์ฒ˜๋ฆฌ

List<ParsedHolidayDto> parsedHolidays = new ArrayList<>();
try (InputStream is = finalStream) {
CalendarBuilder builder = new CalendarBuilder();
Calendar calendar = builder.build(is);

log.info("์• ํ”Œ ๊ณตํœด์ผ ์บ˜๋ฆฐ๋” ํŒŒ์‹ฑ ์‹œ์ž‘...");

for (Object component : calendar.getComponents(Component.VEVENT)) {
VEvent event = (VEvent) component;

// ํ•„์ˆ˜๊ฐ’์ด ์—†๋Š” ๋ฐ์ดํ„ฐ๋Š” ๊ฑด๋„ˆ๋›ฐ๊ธฐ
if (event.getUid() == null || event.getSummary() == null || event.getStartDate() == null) {
continue;
}

String uid = event.getUid().getValue();
String title = event.getSummary().getValue();
String dateStr = event.getStartDate().getValue();

LocalDate startDate;
try {
// ๊ณตํœด์ผ ํฌ๋งท์ธ yyyyMMdd ๋งŒ ํŒŒ์‹ฑ ์‹œ๋„
startDate = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd"));
} catch (Exception e) {
continue; // ๋‚ ์งœ ํฌ๋งท์ด ์ด์ƒํ•˜๋ฉด ์กฐ์šฉํžˆ ๋ฌด์‹œํ•˜๊ณ  ๋‹ค์Œ ์ผ์ •์œผ๋กœ
}

parsedHolidays.add(new ParsedHolidayDto(title, startDate, uid));
}
}

// DB ์ €์žฅ ๋ฐ ๋™์‹œ์„ฑ ์ถฉ๋Œ ๋ฐฉ์ง€ ๋กœ์ง ํ˜ธ์ถœ
saveHolidaysAtomically(parsedHolidays);

} catch (Exception e) {
log.error("๊ณตํœด์ผ ๋™๊ธฐํ™” ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e);
}
}

private InputStream rawScaleStreamCheck(InputStream rawStream) {
return rawStream;
}

/**
* ๊ฐœ๋ณ„ ๊ฑด๋ณ„ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ ๋ฐ ์ €์žฅ์„ ํŠธ๋žœ์žญ์…˜ ๋‹จ์œ„๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•˜์—ฌ
* ๋‹ค์ค‘ ์„œ๋ฒ„ ํ™˜๊ฒฝ(๋™์‹œ์„ฑ ์ด์Šˆ)์—์„œ๋„ ์ค‘๋ณต ์‚ฝ์ž…์„ ์›์ฒœ ๋ฐฉ์–ดํ•ฉ๋‹ˆ๋‹ค.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveHolidaysAtomically(List<ParsedHolidayDto> parsedHolidays) {
int savedCount = 0;
for (ParsedHolidayDto dto : parsedHolidays) {
// ์›์ž์ (Atomic) ๋ฐฉ์–ด: ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ์—๋งŒ ์•ˆ์ „ํ•˜๊ฒŒ ์‚ฝ์ž…
if (!eventsRepository.existsHolidayByExternalEventId(dto.uid())) {
try {
Events holidayEvent = Events.createHolidayEvent(dto.title(), dto.startDate(), dto.uid());
eventsRepository.save(holidayEvent);
savedCount++;
} catch (Exception e) {
// ๋™์‹œ ์š”์ฒญ์œผ๋กœ ์ธํ•ด ์œ ๋‹ˆํฌ ์ถฉ๋Œ ๋“ฑ์ด ๋ฐœ์ƒํ•  ๊ฒฝ์šฐ ๋ฌด์‹œ (Race Condition ๋ฐฉ์–ด)
log.debug("๊ณตํœด์ผ ๋™๊ธฐํ™” ์ค‘ ์ค‘๋ณต ์‚ฝ์ž… ์ถฉ๋Œ ๋ฌด์‹œ: uid={}", dto.uid());
}
}
}

if (savedCount > 0) {
log.info("์ƒˆ๋กœ์šด ๊ณตํœด์ผ {}๊ฑด ๋™๊ธฐํ™” ์™„๋ฃŒ", savedCount);
} else {
log.info("์ถ”๊ฐ€ํ•  ์ƒˆ๋กœ์šด ๊ณตํœด์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.");
}
}

private record ParsedHolidayDto(
String title,
LocalDate startDate,
String uid
) {
}
}
3 changes: 2 additions & 1 deletion src/main/java/com/tryna/domain/label/enums/LabelType.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

public enum LabelType {
USER,
EXTERNAL_CALENDAR
EXTERNAL_CALENDAR,
SYSTEM,
}
Loading
Loading