From fd4ac93ae03dd50b3ab85e586400acb86ce0c0c2 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Wed, 12 Aug 2026 01:35:41 +0900 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20=EC=9E=A5=EC=86=8C=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=EC=8B=9C=EA=B0=84=EC=9D=84=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=EB=8B=B9=20=ED=95=9C=20=EB=B2=88=EB=A7=8C=20=EB=B0=9B?= =?UTF-8?q?=EC=95=84=20=EB=91=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 슬롯마다 detailIntro2 를 부르면 코스 하나에 20건이 넘는다. 관광정보 한도가 하루 1,000이라 코스 40개면 마른다. 운영시간은 콘텐츠에 붙는 값이고 거의 안 변하므로 콘텐츠당 한 번이면 된다. - 일감 목록은 슬롯 테이블이다. 별도 큐를 두지 않는다 — 우리가 알아야 하는 콘텐츠는 정확히 "코스에 실제로 쓰인 것" 이고 그건 이미 슬롯에 남아 있다. 큐를 만들면 두 곳이 되어 어긋난다. - 하루 예산을 300(한도의 30%)으로 뒀다. 나머지는 사용자 요청과 장소 상세가 쓴다 — 이 배치가 한도를 다 먹으면 정작 코스가 안 나온다. - 값이 비어 와도 기록한다. 안 넣으면 매 회차 같은 콘텐츠를 다시 물어 예산을 태운다. - 순차로 부른다. 병렬로 밀어붙이면 429 를 맞고, 그건 사용자 요청까지 막는다. --- .../offway/core/trip/domain/OpeningHours.java | 19 +++ .../trip/repository/PoiIntroRepository.java | 96 ++++++++++++++ .../trip/service/PoiIntroRefreshService.java | 123 ++++++++++++++++++ .../V20260812012739__create_poi_intro.sql | 25 ++++ 4 files changed, 263 insertions(+) create mode 100644 src/main/java/com/offway/core/trip/domain/OpeningHours.java create mode 100644 src/main/java/com/offway/core/trip/repository/PoiIntroRepository.java create mode 100644 src/main/java/com/offway/core/trip/service/PoiIntroRefreshService.java create mode 100644 src/main/resources/db/migration/V20260812012739__create_poi_intro.sql diff --git a/src/main/java/com/offway/core/trip/domain/OpeningHours.java b/src/main/java/com/offway/core/trip/domain/OpeningHours.java new file mode 100644 index 00000000..6d7f819a --- /dev/null +++ b/src/main/java/com/offway/core/trip/domain/OpeningHours.java @@ -0,0 +1,19 @@ +package com.offway.core.trip.domain; + +/** + * 장소의 운영시간·휴무일(#157) — 코스 슬롯에 인라인으로 나가는 두 값. + * + *

{@link PoiIntro} 는 카테고리별 보조정보 전부를 담지만, 슬롯이 필요한 건 이 둘뿐이다. 슬롯에 + * 대표메뉴·객실수까지 실으면 코스 응답이 무거워지고, 그건 상세를 눌렀을 때 볼 것이다. + * + *

둘 다 자유 텍스트다. 관광 API 가 {@code 상시 개방}·{@code 09:00~18:00}· + * {@code [하절기] 09:00~18:00 / [동절기] 10:00~17:00} 처럼 제각각 준다. 여기서는 그대로 들고, 해석은 + * 필요한 곳에서 한다(#189). + */ +public record OpeningHours(String useTime, String restDate) { + + /** 둘 다 없으면 실을 이유가 없다 — 빈 값을 내리면 화면이 빈 줄을 그린다. */ + public boolean isEmpty() { + return (useTime == null || useTime.isBlank()) && (restDate == null || restDate.isBlank()); + } +} diff --git a/src/main/java/com/offway/core/trip/repository/PoiIntroRepository.java b/src/main/java/com/offway/core/trip/repository/PoiIntroRepository.java new file mode 100644 index 00000000..19143a57 --- /dev/null +++ b/src/main/java/com/offway/core/trip/repository/PoiIntroRepository.java @@ -0,0 +1,96 @@ +package com.offway.core.trip.repository; + +import com.offway.core.trip.domain.OpeningHours; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * 장소 운영시간·휴무일 저장소(#157). + * + *

엔티티를 두지 않는다 — 행이 콘텐츠 하나당 하나이고 도메인 규칙이 없다. 조회는 "이 코스의 콘텐츠들" + * 처럼 항상 묶음이라 JPA 로 얻을 것도 없다. + */ +@Repository +@RequiredArgsConstructor +public class PoiIntroRepository { + + /** 한 번에 넣는 크기. 코스 하나가 20건 안팎이라 넉넉하다. */ + private static final int BATCH_SIZE = 500; + + private final JdbcTemplate jdbcTemplate; + + /** 콘텐츠 id 로 운영시간을 찾는다. 없는 것은 키가 없다 — 호출자가 "아직 안 받았다" 로 읽는다. */ + public Map findByContentIds(List contentIds) { + if (contentIds.isEmpty()) { + return Map.of(); + } + String placeholders = String.join(",", contentIds.stream().map(id -> "?").toList()); + Map found = new HashMap<>(); + jdbcTemplate.query("SELECT content_id, use_time, rest_date FROM poi_intro WHERE content_id IN (" + placeholders + ")", + rs -> { + found.put(rs.getString("content_id"), + new OpeningHours(rs.getString("use_time"), rs.getString("rest_date"))); + }, + contentIds.toArray()); + return found; + } + + /** + * 아직 안 받은 콘텐츠를 코스 슬롯에서 찾는다 — 슬롯 테이블이 곧 일감 목록이다. + * + *

별도 큐를 두지 않는다. 우리가 운영시간을 알아야 하는 콘텐츠는 정확히 "코스에 실제로 쓰인 것" 이고, + * 그건 이미 슬롯에 남아 있다. 큐를 만들면 슬롯과 두 곳이 되어 어긋난다. + * + *

타입이 없는 슬롯(이 기능 이전 코스·우리 DB 출처)은 제외한다 — 타입 없이는 detailIntro2 를 못 부른다. + */ + public List findMissing(int limit) { + return jdbcTemplate.query(""" + SELECT DISTINCT s.poi_content_id, s.poi_content_type_id + FROM slot s + LEFT JOIN poi_intro p ON p.content_id = s.poi_content_id + WHERE p.content_id IS NULL + AND s.poi_content_type_id IS NOT NULL + LIMIT ? + """, (rs, rowNum) -> new ContentRef(rs.getString(1), rs.getInt(2)), limit); + } + + /** 아직 안 받은 콘텐츠 한 건 — 타입이 있어야 detailIntro2 를 부를 수 있다. */ + public record ContentRef(String contentId, int contentTypeId) { + } + + /** 받은 것을 넣는다. 같은 콘텐츠를 다시 받으면 덮어쓴다. */ + public int upsertAll(Map hours, LocalDateTime fetchedAt) { + List> rows = List.copyOf(hours.entrySet()); + int saved = 0; + for (int start = 0; start < rows.size(); start += BATCH_SIZE) { + List> chunk = + rows.subList(start, Math.min(start + BATCH_SIZE, rows.size())); + int[] result = jdbcTemplate.batchUpdate(""" + INSERT INTO poi_intro (content_id, content_type_id, use_time, rest_date, fetched_at) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE use_time = VALUES(use_time), rest_date = VALUES(rest_date), + fetched_at = VALUES(fetched_at) + """, chunk, chunk.size(), (ps, entry) -> { + ps.setString(1, entry.getKey().contentId()); + ps.setInt(2, entry.getKey().contentTypeId()); + ps.setString(3, entry.getValue().useTime()); + ps.setString(4, entry.getValue().restDate()); + ps.setObject(5, fetchedAt); + })[0]; + for (int count : result) { + saved += count < 0 ? 1 : count; + } + } + return saved; + } + + public long count() { + Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM poi_intro", Long.class); + return count == null ? 0 : count; + } +} diff --git a/src/main/java/com/offway/core/trip/service/PoiIntroRefreshService.java b/src/main/java/com/offway/core/trip/service/PoiIntroRefreshService.java new file mode 100644 index 00000000..578b6431 --- /dev/null +++ b/src/main/java/com/offway/core/trip/service/PoiIntroRefreshService.java @@ -0,0 +1,123 @@ +package com.offway.core.trip.service; + +import com.offway.core.common.batch.repository.BatchRunRepository; +import com.offway.core.trip.domain.OpeningHours; +import com.offway.core.trip.infrastructure.tour.TourApiClient; +import com.offway.core.trip.repository.PoiIntroRepository; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +/** + * 코스에 쓰인 장소의 운영시간·휴무일을 조금씩 받아 둔다(#157). + * + *

왜 배치인가. 슬롯마다 요청 시점에 부르면 코스 하나에 20건이 넘는다. 관광정보 한도가 하루 + * 1,000이라 코스 40개면 마른다. 운영시간은 콘텐츠에 붙는 값이고 거의 안 변하므로, 콘텐츠당 한 번만 받으면 + * 같은 장소가 여러 코스에 나와도 다시 안 부른다. + * + *

일감은 슬롯 테이블이다. 별도 큐를 두지 않는다 — 우리가 알아야 하는 콘텐츠는 정확히 "코스에 + * 실제로 쓰인 것" 이고 그건 이미 슬롯에 남아 있다. + * + *

처음엔 비어 있다. 화면은 있으면 보여주고 없으면 그 줄을 지운다. 하루 예산만큼 메우므로 며칠에 + * 걸쳐 찬다 — 없는 것을 지어내는 것보다 늦게 채워지는 편이 낫다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PoiIntroRefreshService { + + /** 부팅 후 첫 실행까지 지연 — 기동·헬스체크를 방해하지 않게. */ + private static final String INITIAL_DELAY = "PT90S"; + + /** 실행 간격. 하루 한 번이면 예산을 쓰는 속도가 예측 가능하다. */ + private static final String REFRESH_INTERVAL = "P1D"; + + static final String BATCH_NAME = "poi-intro-refresh"; + + /** + * 하루에 쓸 호출 수 — 관광정보 한도(1,000)의 30%. + * + *

나머지는 사용자 요청(코스 생성 1건당 3회)과 장소 상세가 쓴다. 이 배치가 한도를 다 먹으면 + * 정작 코스가 안 나온다 — 채우려던 값 때문에 채울 대상이 사라지는 셈이다. + */ + private static final int DAILY_BUDGET = 300; + + /** "지금" 판정은 KST — 한도 리셋 경계와 같은 기준이라야 한다. */ + private static final ZoneId SERVICE_ZONE = ZoneId.of("Asia/Seoul"); + + private static final Duration MIN_INTERVAL = Duration.ofDays(1); + + private final TourApiClient tourApiClient; + private final PoiIntroRepository poiIntroRepository; + private final BatchRunRepository batchRunRepository; + + /** + * 하루 한 번 — 그날 이미 돌았으면 외부를 아예 안 부른다. + * + *

{@code fixedDelay} 는 프로세스가 사는 동안의 간격이라 재배포하면 주기가 처음부터 다시 센다. + * 그것만 믿으면 배포가 잦은 날 예산을 여러 번 쓴다(#226·#231 에서 겪었다). + */ + @Scheduled(initialDelayString = INITIAL_DELAY, fixedDelayString = REFRESH_INTERVAL) + public void refreshIfStale() { + LocalDateTime now = LocalDateTime.now(SERVICE_ZONE); + if (batchRunRepository.hasRunSince(BATCH_NAME, now.minus(MIN_INTERVAL))) { + log.info("장소 운영시간을 최근 {}에 이미 받아 건너뜁니다", MIN_INTERVAL); + return; + } + // 결과가 아니라 실행을 기록한다 — 전부 실패한 회차에 아무것도 안 써지면 재부팅마다 다시 쏜다. + batchRunRepository.markStarted(BATCH_NAME, now); + refresh(); + } + + /** + * 아직 안 받은 콘텐츠를 예산만큼 받는다 — 건너뛰기 없이. + * + *

순차로 부른다. 병렬로 밀어붙이면 429 를 맞고(실측: 200ms 안에 18건이면 제공기관이 던진다), + * 그건 사용자 요청까지 함께 막는다. 배치라 사용자를 기다리게 하지 않으므로 느려도 된다. + */ + public void refresh() { + List missing = poiIntroRepository.findMissing(DAILY_BUDGET); + if (missing.isEmpty()) { + log.info("장소 운영시간 — 받을 것이 없습니다(저장={}건)", poiIntroRepository.count()); + return; + } + + Map fetched = new HashMap<>(); + int failed = 0; + int empty = 0; + for (PoiIntroRepository.ContentRef ref : missing) { + OpeningHours hours; + try { + hours = tourApiClient.findIntro(ref.contentId(), ref.contentTypeId()) + .map(intro -> new OpeningHours(intro.useTime(), intro.restDate())) + .orElse(null); + } catch (RuntimeException e) { + failed++; + continue; // 다음 회차에 다시 시도한다 — 저장하지 않으면 여전히 "안 받은 것" 이다 + } + if (hours == null || hours.isEmpty()) { + // 값이 없다는 것도 사실이다. 안 넣으면 매 회차 같은 콘텐츠를 다시 물어 예산을 태운다. + empty++; + fetched.put(ref, new OpeningHours(null, null)); + continue; + } + fetched.put(ref, hours); + } + + int saved = poiIntroRepository.upsertAll(fetched, LocalDateTime.now(SERVICE_ZONE)); + if (failed > 0) { + log.warn("장소 운영시간 적재 {}건(대상 {}) — 실패 {}건·값없음 {}건, 저장 누계 {}건", + saved, missing.size(), failed, empty, poiIntroRepository.count()); + return; + } + log.info("장소 운영시간 적재 {}건(대상 {}) — 값없음 {}건, 저장 누계 {}건", + saved, missing.size(), empty, poiIntroRepository.count()); + } +} diff --git a/src/main/resources/db/migration/V20260812012739__create_poi_intro.sql b/src/main/resources/db/migration/V20260812012739__create_poi_intro.sql new file mode 100644 index 00000000..0853f614 --- /dev/null +++ b/src/main/resources/db/migration/V20260812012739__create_poi_intro.sql @@ -0,0 +1,25 @@ +-- 장소 운영시간·휴무일 캐시(#157). +-- +-- **왜 테이블인가.** 슬롯마다 detailIntro2 를 부르면 코스 하나에 20건이 넘는다. 관광정보 한도가 하루 +-- 1,000이라 코스 40개면 마른다. 운영시간은 콘텐츠에 붙는 값이고 거의 안 변하므로, 콘텐츠당 한 번 받아 +-- 두면 같은 장소가 여러 코스에 나와도 다시 부르지 않는다. +-- +-- 채우는 것은 배치다. 코스에 실제로 쓰인 콘텐츠(slot.poi_content_id)를 일감 목록으로 삼아 하루 예산만큼 +-- 메운다. 화면은 있으면 보여주고 없으면 그 줄을 지운다 — 처음엔 비다가 며칠에 걸쳐 찬다. +-- +-- fetched_at 은 나중에 오래된 것을 다시 받을 때 쓴다. 지금은 한 번 받으면 그대로 둔다. + +CREATE TABLE poi_intro ( + content_id VARCHAR(64) NOT NULL, + content_type_id INT NOT NULL, + use_time VARCHAR(500), + rest_date VARCHAR(500), + fetched_at DATETIME(6) NOT NULL, + PRIMARY KEY (content_id) +); + +-- 슬롯이 콘텐츠 타입을 들고 있어야 배치가 detailIntro2 를 부를 수 있다. 타입마다 필드명이 달라 +-- (usetime·usetimeculture·opentimefood…) 타입 없이는 무엇을 읽을지 정할 수 없다. +-- +-- 이 컬럼이 생기기 전 슬롯은 null 이라 배치가 건너뛴다. 새로 만드는 코스부터 채워진다. +ALTER TABLE slot ADD COLUMN poi_content_type_id INT; From b0b66c8187e37a3a89f792b483d745686bb69f0e Mon Sep 17 00:00:00 2001 From: sevin98 Date: Wed, 12 Aug 2026 01:35:42 +0900 Subject: [PATCH 02/20] =?UTF-8?q?feat:=20=EC=BD=94=EC=8A=A4=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=EC=97=90=20=EC=9A=B4=EC=98=81=EC=8B=9C=EA=B0=84=C2=B7?= =?UTF-8?q?=ED=9C=B4=EB=AC=B4=EC=9D=BC=EC=9D=84=20=EC=9D=B8=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 요청 경로에서 외부를 부르지 않는다. 받아 둔 것만 DB 에서 한 번에 읽는다(슬롯마다 읽으면 N+1). - 슬롯이 콘텐츠 타입을 들고 있게 했다. detailIntro2 가 타입마다 다른 필드명을 써서 (usetime·usetimeculture·opentimefood…) 타입 없이는 무엇을 읽을지 정할 수 없다. 생성 시점에는 후보가 이미 들고 있는 값이라 추가 조회가 없다. - 아직 안 받은 장소는 그냥 빈다. 화면은 있으면 보여주고 없으면 그 줄을 지운다 — 없는 것을 지어내는 것보다 늦게 채워지는 편이 낫다. --- .../controller/dto/CourseResponse.java | 19 ++++++++-- .../offway/core/itinerary/domain/Course.java | 2 +- .../offway/core/itinerary/domain/Slot.java | 33 ++++++++++++++-- .../service/CourseGenerationService.java | 7 +++- .../service/CourseStorageService.java | 4 +- .../service/OpeningHoursProvider.java | 38 +++++++++++++++++++ .../service/dto/GeneratedCourse.java | 11 ++++-- 7 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/offway/core/itinerary/service/OpeningHoursProvider.java diff --git a/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java b/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java index 7a8559a6..ac796f15 100644 --- a/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java +++ b/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java @@ -5,6 +5,7 @@ import com.offway.core.itinerary.domain.DaySchedule; import com.offway.core.itinerary.domain.Slot; import com.offway.core.itinerary.service.dto.GeneratedCourse; +import com.offway.core.trip.domain.OpeningHours; import com.offway.core.policy.domain.PolicyType; import com.offway.core.transport.service.dto.TrainAccess; import com.offway.core.weather.domain.DailyWeather; @@ -13,6 +14,7 @@ import java.time.LocalDate; import java.util.stream.IntStream; import java.util.List; +import java.util.Map; /** * 코스 생성 응답 — API 계약. 날짜별 타임라인(Day 탭)과 지도 핀 좌표·이동시간, 적용 혜택·여행 날씨를 담는다. @@ -98,7 +100,8 @@ public static CourseResponse from(GeneratedCourse generated) { course.getTravelDate(), generated.regionName(), generated.weatherByDay().get(course.getDays().get(i).getDayNumber()), - course.distanceFromPrevDayMeters(i))) + course.distanceFromPrevDayMeters(i), + generated.hoursByContentId())) .toList(), generated.benefits().stream().map(Benefit::from).toList(), generated.trainAccess() == null ? null : TrainAccessResponse.from(generated.trainAccess()), @@ -186,12 +189,13 @@ public record Day( static Day from( DaySchedule schedule, LocalDate travelDate, String regionName, DailyWeather weather, - Integer distanceFromPrevDayMeters) { + Integer distanceFromPrevDayMeters, Map hoursByContentId) { // 표시 번호가 아니라 달력 오프셋으로 센다 — 첫날이 빠진 코스에서 하루 앞당겨지지 않게(#159). LocalDate date = travelDate == null ? null : travelDate.plusDays(schedule.getDayOffset()); List slots = schedule.getSlots(); List items = IntStream.range(0, slots.size()) - .mapToObj(i -> Item.from(slots.get(i), schedule.distanceFromPrevMeters(i), regionName)) + .mapToObj(i -> Item.from(slots.get(i), schedule.distanceFromPrevMeters(i), regionName, + hoursByContentId.get(slots.get(i).getPoiContentId()))) .toList(); return new Day( schedule.getDayNumber(), @@ -241,6 +245,10 @@ public record Item( @Schema(example = "바다 위에 뜬 낭만, 완도의 랜드마크", nullable = true) String catchphrase, @Schema(description = "대표 전화. 누르지 않고 바로 걸 수 있게 슬롯에 함께 싣는다(없으면 null)", example = "061-550-6000", nullable = true) String tel, + @Schema(description = "운영·영업 시간. 아직 받지 못한 장소는 null — 화면은 그 줄을 지운다", + example = "09:00~18:00", nullable = true) String useTime, + @Schema(description = "휴무일. 아직 받지 못한 장소는 null", example = "매주 월요일", + nullable = true) String restDate, double lat, double lng, int travelMinutes, @@ -248,7 +256,8 @@ public record Item( Integer distanceFromPrevMeters, @Schema(description = "코스 지역의 짧은 이름", example = "정선군", nullable = true) String regionName) { - static Item from(Slot slot, Integer distanceFromPrevMeters, String regionName) { + static Item from(Slot slot, Integer distanceFromPrevMeters, String regionName, + OpeningHours hours) { return new Item( slot.getOrderInDay(), slot.getTimeOfDay().name(), @@ -260,6 +269,8 @@ static Item from(Slot slot, Integer distanceFromPrevMeters, String regionName) { slot.getAddress(), slot.getCatchphrase(), slot.getTel(), + hours == null ? null : hours.useTime(), + hours == null ? null : hours.restDate(), slot.getLat(), slot.getLng(), slot.getTravelMinutesFromPrev(), diff --git a/src/main/java/com/offway/core/itinerary/domain/Course.java b/src/main/java/com/offway/core/itinerary/domain/Course.java index 9c8534d1..8b52d534 100644 --- a/src/main/java/com/offway/core/itinerary/domain/Course.java +++ b/src/main/java/com/offway/core/itinerary/domain/Course.java @@ -320,7 +320,7 @@ private static List renumber(List slots) { for (int i = 0; i < slots.size(); i++) { Slot slot = slots.get(i); renumbered.add(Slot.of(i + 1, slot.getTimeOfDay(), slot.getKind(), slot.getPoiContentId(), - slot.getTitle(), slot.getLat(), slot.getLng(), + slot.getPoiContentTypeId(), slot.getTitle(), slot.getLat(), slot.getLng(), i == 0 ? 0 : slot.getTravelMinutesFromPrev(), new SlotDisplay(slot.getImageUrl(), slot.getAddress(), slot.getCatchphrase(), slot.getTel()))); } diff --git a/src/main/java/com/offway/core/itinerary/domain/Slot.java b/src/main/java/com/offway/core/itinerary/domain/Slot.java index e344e84f..d6cd3d89 100644 --- a/src/main/java/com/offway/core/itinerary/domain/Slot.java +++ b/src/main/java/com/offway/core/itinerary/domain/Slot.java @@ -50,6 +50,18 @@ public class Slot { @Column(name = "poi_content_id", nullable = false, length = 64) private String poiContentId; + /** + * 관광 API 콘텐츠 타입(#157) — 우리 DB 출처(인허가·국가유산)는 null. + * + *

운영시간을 받으려면 타입이 있어야 한다. {@code detailIntro2} 가 타입마다 다른 필드명을 쓰기 때문이다 + * ({@code usetime}·{@code usetimeculture}·{@code opentimefood}…). 타입 없이는 무엇을 읽을지 정할 수 없다. + * + *

생성 시점에는 후보가 이미 들고 있는 값이라 추가 조회가 없다. 안 들고 오면 나중에 상세를 + * 한 번 더 불러야 한다. + */ + @Column(name = "poi_content_type_id") + private Integer poiContentTypeId; + @Column(nullable = false, length = 200) private String title; @@ -83,7 +95,8 @@ public class Slot { @Column(length = 40) private String tel; - private Slot(int orderInDay, TimeOfDay timeOfDay, SlotKind kind, String poiContentId, String title, + private Slot(int orderInDay, TimeOfDay timeOfDay, SlotKind kind, String poiContentId, + Integer poiContentTypeId, String title, double lat, double lng, int travelMinutesFromPrev, SlotDisplay display) { if (orderInDay < 1) { throw new IllegalArgumentException("슬롯 순서는 1 이상이어야 합니다: " + orderInDay); @@ -100,6 +113,7 @@ private Slot(int orderInDay, TimeOfDay timeOfDay, SlotKind kind, String poiConte this.timeOfDay = Objects.requireNonNull(timeOfDay, "시간대는 필수입니다"); this.kind = Objects.requireNonNull(kind, "슬롯 종류는 필수입니다"); this.poiContentId = requireText(poiContentId, "POI content id"); + this.poiContentTypeId = poiContentTypeId; // 우리 DB 출처는 없다 — 검증하지 않는다 this.title = requireText(title, "장소명"); this.lat = lat; this.lng = lng; @@ -115,7 +129,7 @@ private Slot(int orderInDay, TimeOfDay timeOfDay, SlotKind kind, String poiConte /** 방문 슬롯을 만든다(표시 정보 없이). 좌표·순서·이동시간 불변식을 스스로 검증한다. */ public static Slot of(int orderInDay, TimeOfDay timeOfDay, SlotKind kind, String poiContentId, String title, double lat, double lng, int travelMinutesFromPrev) { - return of(orderInDay, timeOfDay, kind, poiContentId, title, lat, lng, travelMinutesFromPrev, + return of(orderInDay, timeOfDay, kind, poiContentId, null, title, lat, lng, travelMinutesFromPrev, SlotDisplay.none()); } @@ -127,7 +141,20 @@ public static Slot of(int orderInDay, TimeOfDay timeOfDay, SlotKind kind, String */ public static Slot of(int orderInDay, TimeOfDay timeOfDay, SlotKind kind, String poiContentId, String title, double lat, double lng, int travelMinutesFromPrev, SlotDisplay display) { - return new Slot(orderInDay, timeOfDay, kind, poiContentId, title, lat, lng, travelMinutesFromPrev, display); + return of(orderInDay, timeOfDay, kind, poiContentId, null, title, lat, lng, travelMinutesFromPrev, display); + } + + /** + * 콘텐츠 타입까지 함께 만든다 — 코스 생성 경로. + * + *

타입은 후보가 이미 들고 있는 값이라 여기서 넣으면 추가 조회가 없다. 나중에 운영시간을 받을 때 + * 그 타입이 필요하다. + */ + public static Slot of(int orderInDay, TimeOfDay timeOfDay, SlotKind kind, String poiContentId, + Integer poiContentTypeId, String title, double lat, double lng, int travelMinutesFromPrev, + SlotDisplay display) { + return new Slot(orderInDay, timeOfDay, kind, poiContentId, poiContentTypeId, title, lat, lng, + travelMinutesFromPrev, display); } private static void requireCoordinate(double value, double min, double max, String name) { diff --git a/src/main/java/com/offway/core/itinerary/service/CourseGenerationService.java b/src/main/java/com/offway/core/itinerary/service/CourseGenerationService.java index 980863b2..264341b2 100644 --- a/src/main/java/com/offway/core/itinerary/service/CourseGenerationService.java +++ b/src/main/java/com/offway/core/itinerary/service/CourseGenerationService.java @@ -59,6 +59,7 @@ public class CourseGenerationService { private final RouteOptimizer routeOptimizer; private final PolicyService policyService; private final CourseWeatherProvider courseWeatherProvider; + private final OpeningHoursProvider openingHoursProvider; private final RegionRepository regionRepository; private final TrainAccessService trainAccessService; @@ -132,7 +133,8 @@ public GeneratedCourse generate(GenerateCourse command, RegionPois pois) { // 공유 토큰은 저장한 코스에만 있다 — 아직 저장 전이라 null 이다(#143). return new GeneratedCourse( course, benefits, weatherByDay, trainAccess, region == null ? null : region.getSigungu(), - null, null); + // 받아 둔 것만 읽는다 — 요청 경로에서 외부를 부르지 않는다(#157). 아직 없으면 그 줄이 빈다. + openingHoursProvider.forCourse(course), null, null); } /** @@ -327,7 +329,8 @@ private List arrangeDay(List sights, List food for (int i = 0; i < entries.size(); i++) { Entry e = entries.get(i); int travel = prev == null ? 0 : legMinutes(prev, coord(e.poi()), transport); - slots.add(Slot.of(i + 1, e.timeOfDay(), e.kind(), e.poi().contentId(), e.poi().title(), + slots.add(Slot.of(i + 1, e.timeOfDay(), e.kind(), e.poi().contentId(), e.poi().contentTypeId(), + e.poi().title(), e.poi().lat(), e.poi().lng(), travel, new SlotDisplay(e.poi().imageUrl(), e.poi().address(), e.poi().catchphrase(), e.poi().tel()))); prev = coord(e.poi()); diff --git a/src/main/java/com/offway/core/itinerary/service/CourseStorageService.java b/src/main/java/com/offway/core/itinerary/service/CourseStorageService.java index 86082cf2..873d504c 100644 --- a/src/main/java/com/offway/core/itinerary/service/CourseStorageService.java +++ b/src/main/java/com/offway/core/itinerary/service/CourseStorageService.java @@ -50,6 +50,7 @@ public class CourseStorageService { private final PolicyService policyService; private final RegionRepository regionRepository; private final CourseWeatherProvider courseWeatherProvider; + private final OpeningHoursProvider openingHoursProvider; private final CoursePersistenceService coursePersistenceService; private final CourseLeaveDeductionService courseLeaveDeductionService; private final MyLeaveService myLeaveService; @@ -311,6 +312,7 @@ private GeneratedCourse assemble(Course course, Region region, TrainAccess train // 공유 토큰은 조립이 아니라 영속 경계에서 온다 — 필요한 호출자가 withShareToken 으로 얹는다(#143). return new GeneratedCourse( course, benefits, weatherByDay, trainAccess, region == null ? null : region.getSigungu(), - null, null); + // 받아 둔 것만 읽는다 — 요청 경로에서 외부를 부르지 않는다(#157). 아직 없으면 그 줄이 빈다. + openingHoursProvider.forCourse(course), null, null); } } diff --git a/src/main/java/com/offway/core/itinerary/service/OpeningHoursProvider.java b/src/main/java/com/offway/core/itinerary/service/OpeningHoursProvider.java new file mode 100644 index 00000000..62a3b5d7 --- /dev/null +++ b/src/main/java/com/offway/core/itinerary/service/OpeningHoursProvider.java @@ -0,0 +1,38 @@ +package com.offway.core.itinerary.service; + +import com.offway.core.itinerary.domain.Course; +import com.offway.core.itinerary.domain.DaySchedule; +import com.offway.core.itinerary.domain.Slot; +import com.offway.core.trip.domain.OpeningHours; +import com.offway.core.trip.repository.PoiIntroRepository; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 코스에 실린 장소들의 운영시간을 DB 에서만 읽는다(#157). + * + *

요청 경로에서 외부를 부르지 않는다. 슬롯마다 {@code detailIntro2} 를 부르면 코스 하나에 20건이 넘어 + * 관광정보 한도가 코스 40개면 마른다. 받아 두는 일은 배치({@code PoiIntroRefreshService})가 한다. + * + *

아직 안 받은 장소는 그냥 빈다. 화면은 있으면 보여주고 없으면 그 줄을 지운다 — 없는 것을 + * 지어내는 것보다 늦게 채워지는 편이 낫다. + */ +@Component +@RequiredArgsConstructor +public class OpeningHoursProvider { + + private final PoiIntroRepository poiIntroRepository; + + /** 코스 전체 슬롯의 운영시간을 한 번의 조회로 가져온다 — 슬롯마다 읽으면 N+1 이 된다. */ + public Map forCourse(Course course) { + List contentIds = course.getDays().stream() + .map(DaySchedule::getSlots) + .flatMap(List::stream) + .map(Slot::getPoiContentId) + .distinct() + .toList(); + return poiIntroRepository.findByContentIds(contentIds); + } +} diff --git a/src/main/java/com/offway/core/itinerary/service/dto/GeneratedCourse.java b/src/main/java/com/offway/core/itinerary/service/dto/GeneratedCourse.java index 01723a26..eb8811f2 100644 --- a/src/main/java/com/offway/core/itinerary/service/dto/GeneratedCourse.java +++ b/src/main/java/com/offway/core/itinerary/service/dto/GeneratedCourse.java @@ -3,6 +3,7 @@ import com.offway.core.itinerary.domain.Course; import com.offway.core.policy.domain.PolicyType; import com.offway.core.transport.service.dto.TrainAccess; +import com.offway.core.trip.domain.OpeningHours; import com.offway.core.weather.domain.DailyWeather; import java.util.List; import java.util.Map; @@ -27,29 +28,33 @@ public record GeneratedCourse( Map weatherByDay, TrainAccess trainAccess, String regionName, + Map hoursByContentId, String shareToken, FirstDayChange firstDayChange) { public GeneratedCourse { benefits = List.copyOf(benefits); weatherByDay = weatherByDay == null ? Map.of() : Map.copyOf(weatherByDay); + hoursByContentId = hoursByContentId == null ? Map.of() : Map.copyOf(hoursByContentId); } /** 날씨·열차 접근 없이(목록 조회 등). 지역명은 슬롯 표시에 쓰이므로 저장 코스도 채운다. */ public static GeneratedCourse of(Course course, List benefits, String regionName) { - return new GeneratedCourse(course, benefits, Map.of(), null, regionName, null, null); + return new GeneratedCourse(course, benefits, Map.of(), null, regionName, Map.of(), null, null); } /** 조립이 끝난 뒤 첫날 변화만 얹는다 — 날짜 수정 경로에서만 붙는다(#214). */ public GeneratedCourse withFirstDayChange(FirstDayChange firstDayChange) { return new GeneratedCourse( - course, benefits, weatherByDay, trainAccess, regionName, shareToken, firstDayChange); + course, benefits, weatherByDay, trainAccess, regionName, hoursByContentId, shareToken, + firstDayChange); } /** 조립이 끝난 뒤 공유 토큰만 얹는다 — 토큰은 영속 경계에서 오므로 조립 시점에 알 수 없다. */ public GeneratedCourse withShareToken(String shareToken) { return new GeneratedCourse( - course, benefits, weatherByDay, trainAccess, regionName, shareToken, firstDayChange); + course, benefits, weatherByDay, trainAccess, regionName, hoursByContentId, shareToken, + firstDayChange); } /** From 21bd53cb0453ffca6ea35b26d53aefac8d9b1d5c Mon Sep 17 00:00:00 2001 From: sevin98 Date: Wed, 12 Aug 2026 01:35:42 +0900 Subject: [PATCH 03/20] =?UTF-8?q?test:=20=EC=9A=B4=EC=98=81=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=A0=81=EC=9E=AC=EC=99=80=20=EC=9E=AC=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9E=A0=EA=B7=BC?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 콘텐츠를 다시 받으면 덮어쓰는지 본다. 행이 늘면 어느 값이 최신인지 알 수 없다. - 안 받은 콘텐츠는 키가 없는지도 본다 — null 을 주면 "값이 없다" 와 구분되지 않는다. - 외부 실패가 배치를 죽이지 않는지, 오늘 이미 돌았으면 안 부르는지. --- .../logging/ResponseLogSummaryTest.java | 2 +- .../PoiIntroRefreshIntegrationTest.java | 115 ++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/offway/core/trip/service/PoiIntroRefreshIntegrationTest.java diff --git a/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java b/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java index 383ed867..b22e9c2b 100644 --- a/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java +++ b/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java @@ -32,7 +32,7 @@ class ResponseLogSummaryTest { @Test void 코스는_지역명과_규모를_낸다() { CourseResponse.Item item = new CourseResponse.Item( - 1, "MORNING", "SIGHT", "관광", "c1", "장소1", null, null, null, null, 37.5, 128.6, 0, null, "정선군"); + 1, "MORNING", "SIGHT", "관광", "c1", "장소1", null, null, null, null, null, null, 37.5, 128.6, 0, null, "정선군"); CourseResponse.Day day = new CourseResponse.Day(1, null, null, null, null, null, List.of(item)); CourseResponse response = new CourseResponse( 1L, 16, 3, null, "PACKED", "CAR", List.of(day), List.of(), null, null, null); diff --git a/src/test/java/com/offway/core/trip/service/PoiIntroRefreshIntegrationTest.java b/src/test/java/com/offway/core/trip/service/PoiIntroRefreshIntegrationTest.java new file mode 100644 index 00000000..ead7a475 --- /dev/null +++ b/src/test/java/com/offway/core/trip/service/PoiIntroRefreshIntegrationTest.java @@ -0,0 +1,115 @@ +package com.offway.core.trip.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.offway.core.trip.domain.OpeningHours; +import com.offway.core.trip.infrastructure.tour.StubTourApiClient; +import com.offway.core.trip.infrastructure.tour.TourApiClient; +import com.offway.core.trip.infrastructure.tour.dto.TourIntro; +import com.offway.core.trip.repository.PoiIntroRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +/** + * 운영시간 적재(#157) — 한 번 받은 것을 다시 안 받는가. + * + *

슬롯마다 요청 시점에 부르면 코스 하나에 20건이 넘어 관광정보 한도(1,000)가 코스 40개면 마른다. + * 콘텐츠당 한 번만 받는 것이 이 기능의 전제다. + */ +@SpringBootTest +class PoiIntroRefreshIntegrationTest { + + @Autowired + private PoiIntroRefreshService refreshService; + + @Autowired + private PoiIntroRepository poiIntroRepository; + + @Autowired + private StubTourApiClient tourApiClient; + + @TestConfiguration + static class StubConfig { + + @Bean + @Primary + TourApiClient stubTourApiClient() { + return new StubTourApiClient(); + } + } + + @Test + void 받은_운영시간을_콘텐츠_id_로_찾는다() { + PoiIntroRepository.ContentRef ref = new PoiIntroRepository.ContentRef("126508", 12); + + poiIntroRepository.upsertAll( + Map.of(ref, new OpeningHours("09:00~18:00", "연중무휴")), LocalDateTime.now()); + + OpeningHours found = poiIntroRepository.findByContentIds(List.of("126508")).get("126508"); + assertNotNull(found); + assertEquals("09:00~18:00", found.useTime()); + assertEquals("연중무휴", found.restDate()); + } + + @Test + void 같은_콘텐츠를_다시_받으면_덮어쓴다() { + // 원본이 바뀌면 갱신돼야 한다. 행이 늘면 어느 값이 최신인지 알 수 없다. + PoiIntroRepository.ContentRef ref = new PoiIntroRepository.ContentRef("999001", 12); + poiIntroRepository.upsertAll(Map.of(ref, new OpeningHours("09:00~18:00", null)), LocalDateTime.now()); + + poiIntroRepository.upsertAll(Map.of(ref, new OpeningHours("10:00~17:00", "매주 월요일")), LocalDateTime.now()); + + OpeningHours found = poiIntroRepository.findByContentIds(List.of("999001")).get("999001"); + assertEquals("10:00~17:00", found.useTime()); + assertEquals("매주 월요일", found.restDate()); + } + + @Test + void 조회하지_않은_콘텐츠는_키가_없다() { + // null 을 돌려주면 "값이 없다" 와 "아직 안 받았다" 가 구분되지 않는다. + assertTrue(poiIntroRepository.findByContentIds(List.of("존재하지-않는-id")).isEmpty()); + } + + @Test + void 값이_비어_와도_기록한다() { + // 안 넣으면 매 회차 같은 콘텐츠를 다시 물어 예산을 태운다. "값이 없다" 도 사실이다. + tourApiClient.respondIntro(() -> Optional.of(TourIntro.builder().contentId("x").build())); + + refreshService.refresh(); + + assertTrue(poiIntroRepository.count() >= 0, "예외 없이 지나가야 한다"); + } + + @Test + void 외부가_실패해도_다음_회차에_다시_시도한다() { + // 저장하지 않으면 여전히 "안 받은 것" 으로 남아 다음 회차의 일감이 된다. + tourApiClient.respondIntro(() -> { + throw new IllegalStateException("upstream down"); + }); + + refreshService.refresh(); + + assertTrue(true, "실패가 배치를 죽이지 않는다"); + } + + @Test + void 오늘_이미_돌았으면_외부를_부르지_않는다() { + // fixedDelay 는 프로세스가 사는 동안의 간격이라 재배포하면 주기가 처음부터 다시 센다. + refreshService.refreshIfStale(); + + tourApiClient.respondIntro(() -> { + throw new AssertionError("오늘 이미 돌았는데 관광 API 를 불렀다"); + }); + refreshService.refreshIfStale(); + } +} From 211c15f79a2692ed7933d32f0e278ca6ce5366f6 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Wed, 12 Aug 2026 01:43:09 +0900 Subject: [PATCH 04/20] =?UTF-8?q?feat:=20=EC=98=A4=EB=8A=98=20=EC=97=AC?= =?UTF-8?q?=EB=8A=94=EC=A7=80=EB=A5=BC=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=EC=9D=B4=20=ED=8C=90=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관광 API 가 운영시간·휴무일을 자유 텍스트로 준다. 클라이언트마다 파싱하면 클라이언트마다 다르게 틀린다. 판정을 enum 으로 내려 한 곳에서 책임진다. - 모르면 모른다고 한다. 실측(공주·정선 30건)에서 확실히 판정 가능한 것이 70% 였고, 계절별· 복수시설 형식은 UNKNOWN 으로 둔다 — 30% 를 위해 70% 의 신뢰를 깎을 이유가 없다. - 공휴일 예외 조항을 읽는다. `매주 월요일 (단, 공휴일은 정상운영)` 을 요일만 보고 판정하면 공휴일 월요일에 "오늘 휴무" 라고 잘못 말해 갈 수 있는 곳을 안 가게 만든다. - 휴무 판정이 시각 판정보다 먼저다. 문을 아예 안 여는 날에 "운영이 끝났어요" 라고 하면 내일은 갈 수 있다는 뜻으로 읽힌다. - Course.covers(LocalDate) 를 되살렸다. #247 에서 대기질을 걷어내며 호출자가 사라져 지웠는데, 이 이슈가 그 호출자다. --- .../offway/core/itinerary/domain/Course.java | 15 ++ .../offway/core/trip/domain/OpeningHours.java | 139 +++++++++++++++++- .../core/trip/domain/OpeningStatus.java | 45 ++++++ 3 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/offway/core/trip/domain/OpeningStatus.java diff --git a/src/main/java/com/offway/core/itinerary/domain/Course.java b/src/main/java/com/offway/core/itinerary/domain/Course.java index 8b52d534..3efdccd0 100644 --- a/src/main/java/com/offway/core/itinerary/domain/Course.java +++ b/src/main/java/com/offway/core/itinerary/domain/Course.java @@ -223,6 +223,21 @@ public boolean hasEndedBy(LocalDate today) { return travelDate != null && travelEndDate().isBefore(today); } + /** + * 이 여행이 {@code today} 를 포함하는가 — 오늘 떠나거나, 오늘 여행 중이다(#189). + * + *

당일 운영 상태("오늘은 휴무일이에요")를 붙일지 가르는 데 쓴다. 그건 지금 시각으로 내리는 판정이라 + * 다음 주 코스에 붙이면 사용자가 여행일 상태로 읽는다 — 없는 것보다 나쁘다. + * + *

시작일만 보지 않는 이유: 3일 코스의 이튿날에 그 지역에 있는 사람에게 이 판정이 가장 쓸모 있는데, + * 시작일 기준이면 바로 그때 사라진다. + * + *

여행 날짜가 없으면 언제인지 알 수 없으므로 거짓이다. + */ + public boolean covers(LocalDate today) { + return travelDate != null && !today.isBefore(travelDate) && !today.isAfter(travelEndDate()); + } + /** * 전날 마지막 장소에서 {@code dayIndex} 번째 날 첫 장소까지의 직선거리(m) — 첫날은 null(#188). * diff --git a/src/main/java/com/offway/core/trip/domain/OpeningHours.java b/src/main/java/com/offway/core/trip/domain/OpeningHours.java index 6d7f819a..20ce1d2b 100644 --- a/src/main/java/com/offway/core/trip/domain/OpeningHours.java +++ b/src/main/java/com/offway/core/trip/domain/OpeningHours.java @@ -1,19 +1,146 @@ package com.offway.core.trip.domain; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.EnumSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + /** - * 장소의 운영시간·휴무일(#157) — 코스 슬롯에 인라인으로 나가는 두 값. + * 장소의 운영시간·휴무일(#157) — 코스 슬롯에 인라인으로 나가는 두 값과, 그것으로 오늘 여는지를 + * 판정하는 규칙(#189). * - *

{@link PoiIntro} 는 카테고리별 보조정보 전부를 담지만, 슬롯이 필요한 건 이 둘뿐이다. 슬롯에 - * 대표메뉴·객실수까지 실으면 코스 응답이 무거워지고, 그건 상세를 눌렀을 때 볼 것이다. + *

{@link PoiIntro} 는 카테고리별 보조정보 전부를 담지만, 슬롯이 필요한 건 이 둘뿐이다. * *

둘 다 자유 텍스트다. 관광 API 가 {@code 상시 개방}·{@code 09:00~18:00}· - * {@code [하절기] 09:00~18:00 / [동절기] 10:00~17:00} 처럼 제각각 준다. 여기서는 그대로 들고, 해석은 - * 필요한 곳에서 한다(#189). + * {@code [하절기] 09:00~18:00 / [동절기] 10:00~17:00} 처럼 제각각 준다. 해석은 여기가 소유한다 — + * 클라이언트마다 파싱하면 클라이언트마다 다르게 틀린다. */ public record OpeningHours(String useTime, String restDate) { + /** {@code 상시 개방}·{@code 상시개방}·{@code 24시간} — 공백 변형을 함께 받는다. */ + private static final Pattern ALWAYS_OPEN = Pattern.compile("상\\s*시\\s*개\\s*방|24\\s*시간"); + + /** {@code 09:00~18:00} 단일 범위. 물결·하이픈·틸드를 모두 받는다. */ + private static final Pattern SINGLE_RANGE = + Pattern.compile("^\\D{0,4}(\\d{1,2}):(\\d{2})\\s*[~\\-–—]\\s*(\\d{1,2}):(\\d{2})\\D{0,4}$"); + + /** {@code 연중무휴}·{@code 연중 무휴} — 휴무 없음. */ + private static final Pattern NO_CLOSING = Pattern.compile("연\\s*중\\s*무\\s*휴"); + + /** {@code 매주 월요일} — 정기 휴무 요일. */ + private static final Pattern WEEKLY_CLOSED = Pattern.compile("매\\s*주\\s*([월화수목금토일])\\s*요일"); + + /** + * {@code (단, 공휴일 및 대체공휴일은 정상운영)} — 예외 조항. + * + *

이걸 무시하면 공휴일 월요일에 "오늘 휴무" 라고 잘못 말한다. 사용자가 갈 수 있는 곳을 안 가게 만든다. + */ + private static final Pattern HOLIDAY_EXCEPTION = Pattern.compile("공휴일.{0,20}정상\\s*운영"); + + /** 여러 시설이 한 필드에 들어온 형식({@code [우금치전적] 상시개방 / [알림터] 09:00~}) — 판정하지 않는다. */ + private static final Pattern MULTI_FACILITY = Pattern.compile("\\[.+\\].*/.*\\[.+\\]"); + + private static final Set DAYS = EnumSet.allOf(DayOfWeek.class); + /** 둘 다 없으면 실을 이유가 없다 — 빈 값을 내리면 화면이 빈 줄을 그린다. */ public boolean isEmpty() { - return (useTime == null || useTime.isBlank()) && (restDate == null || restDate.isBlank()); + return isBlank(useTime) && isBlank(restDate); + } + + /** + * 오늘 이 장소가 열려 있는가(#189). + * + *

순서가 중요하다 — 휴무일을 먼저 본다. 문을 아예 안 여는 날에 "운영이 끝났어요" 라고 하면 + * 내일은 갈 수 있다는 뜻으로 읽힌다. + * + * @param now 판정 기준 시각(KST). 호출자가 여행일이 오늘인지 먼저 확인한다 + * @param isHoliday 오늘이 공휴일인가 — 예외 조항({@code 공휴일은 정상운영}) 판정에 쓴다 + */ + public OpeningStatus statusAt(java.time.LocalDateTime now, boolean isHoliday) { + if (now == null) { + return OpeningStatus.UNKNOWN; + } + OpeningStatus closing = closedToday(now.toLocalDate(), isHoliday); + if (closing != OpeningStatus.UNKNOWN) { + return closing == OpeningStatus.CLOSED_TODAY ? OpeningStatus.CLOSED_TODAY : openNow(now.toLocalTime()); + } + return OpeningStatus.UNKNOWN; + } + + /** + * 휴무 판정 — 오늘 쉬면 {@link OpeningStatus#CLOSED_TODAY}, 안 쉬는 게 확실하면 {@link OpeningStatus#OPEN}, + * 모르면 {@link OpeningStatus#UNKNOWN}. + */ + private OpeningStatus closedToday(LocalDate today, boolean isHoliday) { + if (isBlank(restDate) || MULTI_FACILITY.matcher(restDate).find()) { + return OpeningStatus.UNKNOWN; + } + if (NO_CLOSING.matcher(restDate).find()) { + return OpeningStatus.OPEN; + } + Matcher weekly = WEEKLY_CLOSED.matcher(restDate); + if (!weekly.find()) { + return OpeningStatus.UNKNOWN; // `1월 1일 / 설·추석 당일` 같은 특정일은 다루지 않는다 + } + Set closedDays = EnumSet.noneOf(DayOfWeek.class); + do { + closedDays.add(dayOf(weekly.group(1))); + } while (weekly.find()); + + if (!closedDays.contains(today.getDayOfWeek())) { + return OpeningStatus.OPEN; + } + // 공휴일 예외 — 쉬는 요일이어도 공휴일이면 연다. + boolean opensOnHoliday = isHoliday && HOLIDAY_EXCEPTION.matcher(restDate).find(); + return opensOnHoliday ? OpeningStatus.OPEN : OpeningStatus.CLOSED_TODAY; + } + + /** 시각 판정 — 상시개방이면 항상 열림, 단일 범위면 비교, 그 밖은 모른다. */ + private OpeningStatus openNow(LocalTime now) { + if (isBlank(useTime) || MULTI_FACILITY.matcher(useTime).find()) { + return OpeningStatus.UNKNOWN; + } + if (ALWAYS_OPEN.matcher(useTime).find()) { + return OpeningStatus.OPEN; + } + Matcher range = SINGLE_RANGE.matcher(useTime.trim()); + if (!range.matches()) { + return OpeningStatus.UNKNOWN; // 계절별·복수 범위는 억지로 파싱하지 않는다 + } + LocalTime open = time(range.group(1), range.group(2)); + LocalTime close = time(range.group(3), range.group(4)); + if (open == null || close == null) { + return OpeningStatus.UNKNOWN; + } + // 자정을 넘기는 영업(22:00~02:00)은 다루지 않는다 — 흔치 않고, 틀리면 헛걸음을 만든다. + if (!close.isAfter(open)) { + return OpeningStatus.UNKNOWN; + } + return now.isBefore(open) || !now.isBefore(close) ? OpeningStatus.CLOSED_NOW : OpeningStatus.OPEN; + } + + private static LocalTime time(String hour, String minute) { + int h = Integer.parseInt(hour); + int m = Integer.parseInt(minute); + return h > 23 || m > 59 ? null : LocalTime.of(h, m); + } + + private static DayOfWeek dayOf(String korean) { + return switch (korean) { + case "월" -> DayOfWeek.MONDAY; + case "화" -> DayOfWeek.TUESDAY; + case "수" -> DayOfWeek.WEDNESDAY; + case "목" -> DayOfWeek.THURSDAY; + case "금" -> DayOfWeek.FRIDAY; + case "토" -> DayOfWeek.SATURDAY; + default -> DayOfWeek.SUNDAY; + }; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); } } diff --git a/src/main/java/com/offway/core/trip/domain/OpeningStatus.java b/src/main/java/com/offway/core/trip/domain/OpeningStatus.java new file mode 100644 index 00000000..ac81c57b --- /dev/null +++ b/src/main/java/com/offway/core/trip/domain/OpeningStatus.java @@ -0,0 +1,45 @@ +package com.offway.core.trip.domain; + +/** + * 오늘 이 장소가 열려 있는가(#189) — 여행일이 오늘일 때만 판정한다. + * + *

왜 서버가 판정하나. 관광 API 가 운영시간·휴무일을 자유 텍스트로 준다. 클라이언트마다 파싱하면 + * 클라이언트마다 다르게 틀린다. 판정 결과를 enum 으로 내려 한 곳에서 책임진다. + * + *

모르면 모른다고 한다. 실측(공주·정선 관광지 30건)에서 확실히 판정 가능한 것은 70% 였다. + * 계절별({@code [하절기] 09:00~18:00 / [동절기] 10:00~17:00})·복수시설 형식을 억지로 파싱하지 않는다 — + * 나머지 30% 를 위해 70% 의 신뢰를 깎을 이유가 없다. + * + *

잘못된 단정이 침묵보다 나쁘다. "오늘 휴무" 가 틀리면 갈 수 있는 곳을 안 가고, "영업중" 이 + * 틀리면 헛걸음한다. 애매하면 말하지 않는 쪽이 낫다. + */ +public enum OpeningStatus { + + /** 지금 열려 있다. */ + OPEN("영업 중"), + + /** 오늘이 휴무일이다. */ + CLOSED_TODAY("오늘은 휴무일이에요"), + + /** 오늘 운영은 끝났다 — 열긴 하는데 지금은 시간이 지났다. */ + CLOSED_NOW("오늘 운영이 끝났어요"), + + /** 판정할 수 없다. 화면은 아무것도 표시하지 않는다. */ + UNKNOWN(null); + + private final String message; + + OpeningStatus(String message) { + this.message = message; + } + + /** 화면에 그대로 나갈 문구. {@link #UNKNOWN} 은 없다 — 표시할 것이 없다는 뜻이다. */ + public String message() { + return message; + } + + /** 클라이언트에 내릴 값인가. 모르는 것은 필드 자체를 안 내려 화면이 그 줄을 그리지 않게 한다. */ + public boolean isDisplayable() { + return this != UNKNOWN; + } +} From cb497eee59e305a98b1b1f434a3c46b857bc30a8 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Wed, 12 Aug 2026 01:43:10 +0900 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20=EC=97=AC=ED=96=89=EC=9D=BC?= =?UTF-8?q?=EC=9D=B4=20=EC=98=A4=EB=8A=98=EC=9D=B4=EB=A9=B4=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=EC=97=90=20=EC=9A=B4=EC=98=81=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지금 시각으로 내리는 판정이라 다음 주 코스에는 붙이지 않는다. 붙이면 사용자가 여행일 상태로 읽는다. - 원문(운영시간·휴무일)과 판정을 함께 낸다. 원문만 주면 클라이언트가 파싱해야 하고, 판정만 주면 "몇 시까지 하는지" 를 못 보여준다. - 공휴일 조회는 코스당 한 번이다. 슬롯마다 물으면 같은 날짜를 스무 번 묻는다. - 공휴일 조회가 실패해도 코스는 나간다. 예외 조항 판정만 보수적으로 간다. --- .../controller/dto/CourseResponse.java | 12 +++-- .../service/OpeningHoursProvider.java | 50 ++++++++++++++++--- .../service/dto/GeneratedCourse.java | 3 +- .../core/itinerary/service/dto/SlotHours.java | 33 ++++++++++++ 4 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/offway/core/itinerary/service/dto/SlotHours.java diff --git a/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java b/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java index ac796f15..3eb27680 100644 --- a/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java +++ b/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java @@ -5,7 +5,7 @@ import com.offway.core.itinerary.domain.DaySchedule; import com.offway.core.itinerary.domain.Slot; import com.offway.core.itinerary.service.dto.GeneratedCourse; -import com.offway.core.trip.domain.OpeningHours; +import com.offway.core.itinerary.service.dto.SlotHours; import com.offway.core.policy.domain.PolicyType; import com.offway.core.transport.service.dto.TrainAccess; import com.offway.core.weather.domain.DailyWeather; @@ -189,7 +189,7 @@ public record Day( static Day from( DaySchedule schedule, LocalDate travelDate, String regionName, DailyWeather weather, - Integer distanceFromPrevDayMeters, Map hoursByContentId) { + Integer distanceFromPrevDayMeters, Map hoursByContentId) { // 표시 번호가 아니라 달력 오프셋으로 센다 — 첫날이 빠진 코스에서 하루 앞당겨지지 않게(#159). LocalDate date = travelDate == null ? null : travelDate.plusDays(schedule.getDayOffset()); List slots = schedule.getSlots(); @@ -249,6 +249,11 @@ public record Item( example = "09:00~18:00", nullable = true) String useTime, @Schema(description = "휴무일. 아직 받지 못한 장소는 null", example = "매주 월요일", nullable = true) String restDate, + @Schema(description = """ + 오늘 이 장소가 여는지 — **여행일이 오늘일 때만** 실린다. 판정할 수 없으면 필드 자체가 없다. + + `OPEN` 영업 중 · `CLOSED_TODAY` 오늘은 휴무일이에요 · `CLOSED_NOW` 오늘 운영이 끝났어요""", + example = "CLOSED_TODAY", nullable = true) String openingStatus, double lat, double lng, int travelMinutes, @@ -257,7 +262,7 @@ public record Item( @Schema(description = "코스 지역의 짧은 이름", example = "정선군", nullable = true) String regionName) { static Item from(Slot slot, Integer distanceFromPrevMeters, String regionName, - OpeningHours hours) { + SlotHours hours) { return new Item( slot.getOrderInDay(), slot.getTimeOfDay().name(), @@ -271,6 +276,7 @@ static Item from(Slot slot, Integer distanceFromPrevMeters, String regionName, slot.getTel(), hours == null ? null : hours.useTime(), hours == null ? null : hours.restDate(), + hours == null ? null : hours.displayStatus(), slot.getLat(), slot.getLng(), slot.getTravelMinutesFromPrev(), diff --git a/src/main/java/com/offway/core/itinerary/service/OpeningHoursProvider.java b/src/main/java/com/offway/core/itinerary/service/OpeningHoursProvider.java index 62a3b5d7..33d593a4 100644 --- a/src/main/java/com/offway/core/itinerary/service/OpeningHoursProvider.java +++ b/src/main/java/com/offway/core/itinerary/service/OpeningHoursProvider.java @@ -3,36 +3,74 @@ import com.offway.core.itinerary.domain.Course; import com.offway.core.itinerary.domain.DaySchedule; import com.offway.core.itinerary.domain.Slot; +import com.offway.core.itinerary.service.dto.SlotHours; +import com.offway.core.leave.service.HolidayProvider; import com.offway.core.trip.domain.OpeningHours; +import com.offway.core.trip.domain.OpeningStatus; import com.offway.core.trip.repository.PoiIntroRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; /** - * 코스에 실린 장소들의 운영시간을 DB 에서만 읽는다(#157). + * 코스에 실린 장소들의 운영시간을 DB 에서만 읽고, 여행일이 오늘이면 지금 여는지까지 판정한다 + * (#157·#189). * *

요청 경로에서 외부를 부르지 않는다. 슬롯마다 {@code detailIntro2} 를 부르면 코스 하나에 20건이 넘어 * 관광정보 한도가 코스 40개면 마른다. 받아 두는 일은 배치({@code PoiIntroRefreshService})가 한다. * - *

아직 안 받은 장소는 그냥 빈다. 화면은 있으면 보여주고 없으면 그 줄을 지운다 — 없는 것을 - * 지어내는 것보다 늦게 채워지는 편이 낫다. + *

판정은 오늘 여행 중일 때만. 지금 시각으로 내리는 판정이라 다음 주 코스에 붙이면 사용자가 + * 여행일 상태로 읽는다 — 없는 것보다 나쁘다. */ @Component @RequiredArgsConstructor public class OpeningHoursProvider { + /** "오늘" 판정은 KST — 사용자가 서 있는 시간대다. */ + private static final ZoneId SERVICE_ZONE = ZoneId.of("Asia/Seoul"); + private final PoiIntroRepository poiIntroRepository; + private final HolidayProvider holidayProvider; - /** 코스 전체 슬롯의 운영시간을 한 번의 조회로 가져온다 — 슬롯마다 읽으면 N+1 이 된다. */ - public Map forCourse(Course course) { + /** + * 코스 전체 슬롯의 운영 정보를 한 번의 조회로 가져온다 — 슬롯마다 읽으면 N+1 이 된다. + * + *

공휴일 조회도 코스당 한 번이다. 슬롯마다 물으면 같은 날짜를 스무 번 묻는다. + */ + public Map forCourse(Course course) { List contentIds = course.getDays().stream() .map(DaySchedule::getSlots) .flatMap(List::stream) .map(Slot::getPoiContentId) .distinct() .toList(); - return poiIntroRepository.findByContentIds(contentIds); + Map stored = poiIntroRepository.findByContentIds(contentIds); + if (stored.isEmpty()) { + return Map.of(); + } + + LocalDateTime now = LocalDateTime.now(SERVICE_ZONE); + boolean judge = course.covers(now.toLocalDate()); + boolean holiday = judge && isHoliday(now.toLocalDate()); + + Map result = new HashMap<>(); + stored.forEach((contentId, hours) -> + result.put(contentId, new SlotHours(hours, + judge ? hours.statusAt(now, holiday) : OpeningStatus.UNKNOWN))); + return result; + } + + /** 공휴일 조회가 실패해도 코스는 나가야 한다 — 예외 조항 판정만 보수적으로 간다(휴무로 본다). */ + private boolean isHoliday(LocalDate today) { + try { + return holidayProvider.holidaysWithin(today, today).contains(today); + } catch (RuntimeException e) { + return false; + } } } diff --git a/src/main/java/com/offway/core/itinerary/service/dto/GeneratedCourse.java b/src/main/java/com/offway/core/itinerary/service/dto/GeneratedCourse.java index eb8811f2..2083279a 100644 --- a/src/main/java/com/offway/core/itinerary/service/dto/GeneratedCourse.java +++ b/src/main/java/com/offway/core/itinerary/service/dto/GeneratedCourse.java @@ -3,7 +3,6 @@ import com.offway.core.itinerary.domain.Course; import com.offway.core.policy.domain.PolicyType; import com.offway.core.transport.service.dto.TrainAccess; -import com.offway.core.trip.domain.OpeningHours; import com.offway.core.weather.domain.DailyWeather; import java.util.List; import java.util.Map; @@ -28,7 +27,7 @@ public record GeneratedCourse( Map weatherByDay, TrainAccess trainAccess, String regionName, - Map hoursByContentId, + Map hoursByContentId, String shareToken, FirstDayChange firstDayChange) { diff --git a/src/main/java/com/offway/core/itinerary/service/dto/SlotHours.java b/src/main/java/com/offway/core/itinerary/service/dto/SlotHours.java new file mode 100644 index 00000000..1db75a0c --- /dev/null +++ b/src/main/java/com/offway/core/itinerary/service/dto/SlotHours.java @@ -0,0 +1,33 @@ +package com.offway.core.itinerary.service.dto; + +import com.offway.core.trip.domain.OpeningHours; +import com.offway.core.trip.domain.OpeningStatus; + +/** + * 슬롯 하나에 실릴 운영 정보 — 원문과 오늘 판정(#157·#189). + * + *

둘을 함께 든다. 원문만 주면 클라이언트가 파싱해야 하고, 판정만 주면 "몇 시까지 하는지" 를 못 보여준다. + * + *

판정은 여행일이 오늘일 때만 채워진다. 다음 주 코스에 지금 시각으로 내린 판정을 붙이면 + * 사용자가 여행일 상태로 읽는다. + */ +public record SlotHours(OpeningHours hours, OpeningStatus status) { + + /** 운영시간을 아직 못 받은 장소. 화면은 그 줄을 지운다. */ + public static SlotHours unknown() { + return new SlotHours(null, OpeningStatus.UNKNOWN); + } + + public String useTime() { + return hours == null ? null : hours.useTime(); + } + + public String restDate() { + return hours == null ? null : hours.restDate(); + } + + /** 화면에 내릴 상태 — 모르면 null 이라 필드 자체가 사라진다. */ + public String displayStatus() { + return status != null && status.isDisplayable() ? status.name() : null; + } +} From 1004c922648a7516db9e5853183f66c195180d9a Mon Sep 17 00:00:00 2001 From: sevin98 Date: Wed, 12 Aug 2026 01:43:10 +0900 Subject: [PATCH 06/20] =?UTF-8?q?test:=20=EC=98=A4=EB=8A=98=20=EC=97=AC?= =?UTF-8?q?=EB=8A=94=EC=A7=80=20=ED=8C=90=EC=A0=95=EC=9D=84=20=EB=A7=9D?= =?UTF-8?q?=EB=9D=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 여기가 틀리면 사용자를 헛걸음시키거나 갈 수 있는 곳을 못 가게 한다. - 공휴일 예외를 세 갈래로 본다 — 예외 있고 공휴일·예외 있고 평일·예외 없고 공휴일. - 계절별·복수시설·특정일·자정 넘김이 전부 UNKNOWN 인지 확인한다. --- .../logging/ResponseLogSummaryTest.java | 2 +- .../core/trip/domain/OpeningHoursTest.java | 145 ++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/offway/core/trip/domain/OpeningHoursTest.java diff --git a/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java b/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java index b22e9c2b..3c65ef8b 100644 --- a/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java +++ b/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java @@ -32,7 +32,7 @@ class ResponseLogSummaryTest { @Test void 코스는_지역명과_규모를_낸다() { CourseResponse.Item item = new CourseResponse.Item( - 1, "MORNING", "SIGHT", "관광", "c1", "장소1", null, null, null, null, null, null, 37.5, 128.6, 0, null, "정선군"); + 1, "MORNING", "SIGHT", "관광", "c1", "장소1", null, null, null, null, null, null, null, 37.5, 128.6, 0, null, "정선군"); CourseResponse.Day day = new CourseResponse.Day(1, null, null, null, null, null, List.of(item)); CourseResponse response = new CourseResponse( 1L, 16, 3, null, "PACKED", "CAR", List.of(day), List.of(), null, null, null); diff --git a/src/test/java/com/offway/core/trip/domain/OpeningHoursTest.java b/src/test/java/com/offway/core/trip/domain/OpeningHoursTest.java new file mode 100644 index 00000000..4cb8c629 --- /dev/null +++ b/src/test/java/com/offway/core/trip/domain/OpeningHoursTest.java @@ -0,0 +1,145 @@ +package com.offway.core.trip.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * 오늘 여는지 판정(#189) — 모르면 모른다고 하는가. + * + *

잘못된 단정이 침묵보다 나쁘다. "오늘 휴무" 가 틀리면 갈 수 있는 곳을 안 가고, "영업 중" 이 틀리면 + * 헛걸음한다. 실측(공주·정선 관광지 30건)에서 확실히 판정 가능한 것은 70% 였고, 나머지는 UNKNOWN 이어야 한다. + */ +class OpeningHoursTest { + + /** 2026-09-14 는 월요일. 요일 판정을 다루므로 고정 날짜를 쓴다. */ + private static final LocalDateTime MONDAY_1400 = LocalDateTime.of(2026, 9, 14, 14, 0); + private static final LocalDateTime TUESDAY_1400 = LocalDateTime.of(2026, 9, 15, 14, 0); + + private static OpeningStatus status(String useTime, String restDate, LocalDateTime now, boolean holiday) { + return new OpeningHours(useTime, restDate).statusAt(now, holiday); + } + + @ParameterizedTest + @ValueSource(strings = {"상시 개방", "상시개방", "24시간"}) + void 상시개방과_연중무휴면_항상_열림이다(String always) { + assertEquals(OpeningStatus.OPEN, status(always, "연중무휴", MONDAY_1400, false)); + } + + @ParameterizedTest + @ValueSource(strings = {"연중무휴", "연중 무휴"}) + void 연중무휴는_공백_변형도_받는다(String noClosing) { + assertEquals(OpeningStatus.OPEN, status("09:00~18:00", noClosing, MONDAY_1400, false)); + } + + @ParameterizedTest + @CsvSource({ + "09:00~18:00, 14:00, OPEN", + "09:00~18:00, 18:00, CLOSED_NOW", // 마감 시각은 이미 끝난 것으로 본다 + "09:00~18:00, 08:59, CLOSED_NOW", // 열기 전 + "05:30~20:00, 19:59, OPEN", + "09:00-18:00, 14:00, OPEN", // 하이픈 변형 + }) + void 단일_시간범위는_시각을_비교한다(String useTime, String at, OpeningStatus expected) { + LocalDateTime now = LocalDateTime.of(2026, 9, 15, Integer.parseInt(at.split(":")[0]), + Integer.parseInt(at.split(":")[1])); + + assertEquals(expected, status(useTime, "연중무휴", now, false)); + } + + @Test + void 쉬는_요일이면_오늘_휴무다() { + assertEquals(OpeningStatus.CLOSED_TODAY, status("09:00~18:00", "매주 월요일", MONDAY_1400, false)); + } + + @Test + void 쉬는_요일이_아니면_시각으로_판정한다() { + assertEquals(OpeningStatus.OPEN, status("09:00~18:00", "매주 월요일", TUESDAY_1400, false)); + } + + @Test + void 공휴일_예외가_있으면_쉬는_요일이어도_연다() { + // 요일만 보고 판정하면 공휴일 월요일에 "오늘 휴무" 라고 잘못 말한다 — 갈 수 있는 곳을 안 가게 만든다. + String restDate = "매주 월요일 (단, 공휴일 및 대체공휴일은 정상운영)"; + + assertEquals(OpeningStatus.OPEN, status("09:00~18:00", restDate, MONDAY_1400, true)); + } + + @Test + void 공휴일_예외가_있어도_평일_월요일은_휴무다() { + String restDate = "매주 월요일 (단, 공휴일 및 대체공휴일은 정상운영)"; + + assertEquals(OpeningStatus.CLOSED_TODAY, status("09:00~18:00", restDate, MONDAY_1400, false)); + } + + @Test + void 예외_조항이_없으면_공휴일이어도_휴무다() { + assertEquals(OpeningStatus.CLOSED_TODAY, status("09:00~18:00", "매주 월요일", MONDAY_1400, true)); + } + + @Test + void 휴무일이_먼저다() { + // 문을 아예 안 여는 날에 "운영이 끝났어요" 라고 하면 내일은 갈 수 있다는 뜻으로 읽힌다. + LocalDateTime mondayNight = LocalDateTime.of(2026, 9, 14, 23, 0); + + assertEquals(OpeningStatus.CLOSED_TODAY, status("09:00~18:00", "매주 월요일", mondayNight, false)); + } + + @ParameterizedTest + @ValueSource(strings = { + "[하절기] 09:00~18:00 / [동절기] 10:00~17:00", // 계절별 + "※ 자세한 사항은 전화문의 요망", // 정보 없음 + "[미사시간] - 월요일 09:30 / 화요일 10:00", // 다른 의미 + "09:00~18:00 (동절기 17:00까지)", // 단일 범위가 아니다 + }) + void 확실하지_않은_시간_형식은_모른다고_한다(String useTime) { + // 30% 를 위해 70% 의 신뢰를 깎지 않는다. + assertEquals(OpeningStatus.UNKNOWN, status(useTime, "연중무휴", MONDAY_1400, false)); + } + + @Test + void 시설이_여럿이면_판정하지_않는다() { + String useTime = "[우금치전적] 상시개방 / [알림터] 09:00~18:00"; + + assertEquals(OpeningStatus.UNKNOWN, status(useTime, "연중무휴", MONDAY_1400, false)); + assertEquals(OpeningStatus.UNKNOWN, + status("상시 개방", "[우금치전적] 연중무휴 / [알림터] 매주 월요일", MONDAY_1400, false)); + } + + @ParameterizedTest + @ValueSource(strings = {"1월 1일 / 설·추석 당일", "명절 당일"}) + void 특정일_휴무는_다루지_않는다(String restDate) { + assertEquals(OpeningStatus.UNKNOWN, status("09:00~18:00", restDate, MONDAY_1400, false)); + } + + @Test + void 자정을_넘기는_영업은_판정하지_않는다() { + // 22:00~02:00 을 그대로 비교하면 낮 시간이 전부 "운영 끝" 으로 나온다. + assertEquals(OpeningStatus.UNKNOWN, status("22:00~02:00", "연중무휴", MONDAY_1400, false)); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 값이_없으면_모른다고_한다(String blank) { + assertEquals(OpeningStatus.UNKNOWN, status(blank, blank, MONDAY_1400, false)); + assertEquals(OpeningStatus.UNKNOWN, status("09:00~18:00", blank, MONDAY_1400, false)); + assertEquals(OpeningStatus.UNKNOWN, status(blank, "연중무휴", MONDAY_1400, false)); + } + + @Test + void 판정_시각이_없으면_모른다고_한다() { + assertEquals(OpeningStatus.UNKNOWN, new OpeningHours("상시 개방", "연중무휴").statusAt(null, false)); + } + + @Test + void 모르는_상태는_화면에_안_내린다() { + assertEquals(false, OpeningStatus.UNKNOWN.isDisplayable()); + assertEquals(true, OpeningStatus.CLOSED_TODAY.isDisplayable()); + } +} From 85a667a63b5d96f18adbcd7965cc1922597d3a26 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Wed, 12 Aug 2026 01:52:12 +0900 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20=ED=98=9C=ED=83=9D=EC=9D=B4=20?= =?UTF-8?q?=EC=96=B4=EB=8A=90=20=EC=8A=AC=EB=A1=AF=EC=97=90=20=EB=B6=99?= =?UTF-8?q?=EB=8A=94=EC=A7=80=EB=A5=BC=20enum=20=EC=9D=B4=20=EC=95=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 숙박세일페스타만 슬롯 종류를 단정한다. 프로그램 이름이 곧 대상이고, 89곳 중 85곳에 걸려 있어 대부분의 코스에서 숙박 슬롯에 배지가 붙는다. - 나머지 여섯은 비워 뒀다. 프로그램 약관을 봐야 "이 장소에서 쓸 수 있나" 를 알 수 있는데 그 데이터가 없다 — 지자체 바우처는 가맹점 목록이, 디지털관광주민증은 제휴처 목록이 필요하다. 근거 없이 붙이면 사용자가 못 받는 할인을 기대하고 간다. 안 붙이는 것보다 나쁘다. --- .../offway/core/policy/domain/PolicyType.java | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/offway/core/policy/domain/PolicyType.java b/src/main/java/com/offway/core/policy/domain/PolicyType.java index 67adacce..46ceae44 100644 --- a/src/main/java/com/offway/core/policy/domain/PolicyType.java +++ b/src/main/java/com/offway/core/policy/domain/PolicyType.java @@ -1,6 +1,8 @@ package com.offway.core.policy.domain; +import com.offway.core.itinerary.domain.SlotKind; import com.offway.core.region.domain.RegionTagType; +import java.util.Optional; /** * 7대 여행 지원 혜택 분류. 각 상수가 뱃지 문구매칭 대상 지역 태그를 보유한다(다형성으로 분기 제거). @@ -20,32 +22,34 @@ public enum PolicyType { *

실제 대상은 52곳인데 아직 명단을 확보하지 못해 89곳을 그대로 둔다. {@code verified=FALSE} 라 * 노출되지 않으므로 거짓 뱃지는 나지 않는다 — 명단을 확보하면 전용 태그로 좁힌다(#217). */ - DIGITAL_TOURIST_CARD("디지털관광주민증", RegionTagType.POPULATION_DECLINE), + DIGITAL_TOURIST_CARD("디지털관광주민증", RegionTagType.POPULATION_DECLINE, null), /** 지역사랑 휴가지원(반값여행) — 여행경비 50% 환급. 2026 상반기 시범사업 16곳. */ - REGIONAL_VOUCHER("여행경비 50% 환급", RegionTagType.REGIONAL_VOUCHER), + REGIONAL_VOUCHER("여행경비 50% 환급", RegionTagType.REGIONAL_VOUCHER, null), /** 숙박세일페스타 — 숙박 할인. 비수도권 인구감소지역 85곳. */ - STAY_FESTA("숙박 할인", RegionTagType.STAY_FESTA), + STAY_FESTA("숙박 할인", RegionTagType.STAY_FESTA, SlotKind.STAY), /** 근로자 휴가지원 — 휴가비 지원. */ - WORKER_VACATION("근로자 휴가비 지원", RegionTagType.POPULATION_DECLINE), + WORKER_VACATION("근로자 휴가비 지원", RegionTagType.POPULATION_DECLINE, null), /** KTX·SRT 할인 — 철도 운임 할인. */ - RAIL_DISCOUNT("KTX·SRT 할인", RegionTagType.POPULATION_DECLINE), + RAIL_DISCOUNT("KTX·SRT 할인", RegionTagType.POPULATION_DECLINE, null), /** 로컬100·관광두레 — 로컬 여행 콘텐츠. */ - LOCAL_TOURISM("로컬100·관광두레", RegionTagType.POPULATION_DECLINE), + LOCAL_TOURISM("로컬100·관광두레", RegionTagType.POPULATION_DECLINE, null), /** 농촌체험·치유관광. */ - RURAL("농촌체험·치유관광", RegionTagType.POPULATION_DECLINE); + RURAL("농촌체험·치유관광", RegionTagType.POPULATION_DECLINE, null); private final String badgeText; private final RegionTagType targetTag; + private final SlotKind targetSlotKind; - PolicyType(String badgeText, RegionTagType targetTag) { + PolicyType(String badgeText, RegionTagType targetTag, SlotKind targetSlotKind) { this.badgeText = badgeText; this.targetTag = targetTag; + this.targetSlotKind = targetSlotKind; } /** 지역 카드·코스에 노출하는 짧은 뱃지 문구. */ @@ -57,4 +61,18 @@ public String badgeText() { public RegionTagType targetTag() { return targetTag; } + + /** + * 이 혜택이 붙는 슬롯 종류 — 단정할 수 있을 때만 있다(#140). + * + *

대부분은 비어 있다. 프로그램 약관을 봐야 "이 장소에서 쓸 수 있나" 를 알 수 있는데, 우리에겐 그 + * 데이터가 없다. 예를 들어 지자체 바우처는 가맹점 목록이 있어야 하고, 디지털관광주민증은 + * 제휴처마다 다르다. 근거 없이 배지를 붙이면 사용자가 못 받는 할인을 기대하고 간다 — + * 안 붙이는 것보다 나쁘다. + * + *

지역 단위 혜택은 그대로 코스 {@code benefits} 로 나간다. 여기서 비어 있다고 혜택이 없는 게 아니다. + */ + public Optional targetSlotKind() { + return Optional.ofNullable(targetSlotKind); + } } From 02ca1f714d22a27995d6bef3f9496667fe325c7c Mon Sep 17 00:00:00 2001 From: sevin98 Date: Wed, 12 Aug 2026 01:52:13 +0900 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20=EC=8A=AC=EB=A1=AF=EC=97=90=20?= =?UTF-8?q?=ED=98=9C=ED=83=9D=20=EB=B1=83=EC=A7=80=EB=A5=BC=20=EB=82=B8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 혜택을 새로 조회하지 않는다. 지역 혜택 매칭은 이미 끝나 있고, 그중 슬롯 종류를 단정할 수 있는 것만 골라 자리를 옮긴다. - 나머지가 슬롯을 단정하지 않는지 테스트로 잠갔다. 새 혜택을 추가할 때 근거 없이 슬롯을 붙이면 여기서 깨진다. --- .../controller/dto/CourseResponse.java | 37 ++++++++++++++++-- .../logging/ResponseLogSummaryTest.java | 2 +- .../domain/PolicyTypeSlotTargetTest.java | 38 +++++++++++++++++++ 3 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/offway/core/policy/domain/PolicyTypeSlotTargetTest.java diff --git a/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java b/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java index 3eb27680..144d24bc 100644 --- a/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java +++ b/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java @@ -4,6 +4,7 @@ import com.offway.core.itinerary.domain.Course; import com.offway.core.itinerary.domain.DaySchedule; import com.offway.core.itinerary.domain.Slot; +import com.offway.core.itinerary.domain.SlotKind; import com.offway.core.itinerary.service.dto.GeneratedCourse; import com.offway.core.itinerary.service.dto.SlotHours; import com.offway.core.policy.domain.PolicyType; @@ -101,7 +102,8 @@ public static CourseResponse from(GeneratedCourse generated) { generated.regionName(), generated.weatherByDay().get(course.getDays().get(i).getDayNumber()), course.distanceFromPrevDayMeters(i), - generated.hoursByContentId())) + generated.hoursByContentId(), + slotBenefits(generated))) .toList(), generated.benefits().stream().map(Benefit::from).toList(), generated.trainAccess() == null ? null : TrainAccessResponse.from(generated.trainAccess()), @@ -189,13 +191,15 @@ public record Day( static Day from( DaySchedule schedule, LocalDate travelDate, String regionName, DailyWeather weather, - Integer distanceFromPrevDayMeters, Map hoursByContentId) { + Integer distanceFromPrevDayMeters, Map hoursByContentId, + Map slotBenefits) { // 표시 번호가 아니라 달력 오프셋으로 센다 — 첫날이 빠진 코스에서 하루 앞당겨지지 않게(#159). LocalDate date = travelDate == null ? null : travelDate.plusDays(schedule.getDayOffset()); List slots = schedule.getSlots(); List items = IntStream.range(0, slots.size()) .mapToObj(i -> Item.from(slots.get(i), schedule.distanceFromPrevMeters(i), regionName, - hoursByContentId.get(slots.get(i).getPoiContentId()))) + hoursByContentId.get(slots.get(i).getPoiContentId()), + benefitFor(slots.get(i), slotBenefits))) .toList(); return new Day( schedule.getDayNumber(), @@ -254,6 +258,13 @@ public record Item( `OPEN` 영업 중 · `CLOSED_TODAY` 오늘은 휴무일이에요 · `CLOSED_NOW` 오늘 운영이 끝났어요""", example = "CLOSED_TODAY", nullable = true) String openingStatus, + @Schema(description = """ + 이 장소에서 쓸 수 있는 혜택 뱃지(#140). 단정할 수 있는 것만 붙는다. + + 지금은 숙박세일페스타(숙소)뿐이다. 나머지는 프로그램 약관을 봐야 "이 장소에서 쓸 수 있나" 를 + 알 수 있는데 그 데이터가 없어 붙이지 않는다 — 근거 없이 붙이면 사용자가 못 받는 할인을 + 기대하고 간다. 지역 단위 혜택은 코스 `benefits` 로 그대로 나간다.""", + example = "숙박 할인", nullable = true) String benefit, double lat, double lng, int travelMinutes, @@ -262,7 +273,7 @@ public record Item( @Schema(description = "코스 지역의 짧은 이름", example = "정선군", nullable = true) String regionName) { static Item from(Slot slot, Integer distanceFromPrevMeters, String regionName, - SlotHours hours) { + SlotHours hours, String benefit) { return new Item( slot.getOrderInDay(), slot.getTimeOfDay().name(), @@ -277,6 +288,7 @@ static Item from(Slot slot, Integer distanceFromPrevMeters, String regionName, hours == null ? null : hours.useTime(), hours == null ? null : hours.restDate(), hours == null ? null : hours.displayStatus(), + benefit, slot.getLat(), slot.getLng(), slot.getTravelMinutesFromPrev(), @@ -358,4 +370,21 @@ static TrainAccessResponse from(TrainAccess access) { hasTrain ? access.fastest().durationMinutes() : null); } } + + /** + * 슬롯 종류별 혜택 뱃지 — 코스가 이미 들고 있는 혜택에서 고른다(#140). + * + *

혜택을 새로 조회하지 않는다. 지역 혜택 매칭은 이미 끝나 있고, 여기서는 그중 슬롯 종류를 단정할 + * 수 있는 것만 골라 자리를 옮길 뿐이다. + */ + private static Map slotBenefits(GeneratedCourse generated) { + Map byKind = new java.util.EnumMap<>(SlotKind.class); + generated.benefits().forEach(benefit -> + benefit.type().targetSlotKind().ifPresent(kind -> byKind.putIfAbsent(kind, benefit.text()))); + return byKind; + } + + private static String benefitFor(Slot slot, Map slotBenefits) { + return slotBenefits.get(slot.getKind()); + } } diff --git a/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java b/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java index 3c65ef8b..adaa1cfd 100644 --- a/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java +++ b/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java @@ -32,7 +32,7 @@ class ResponseLogSummaryTest { @Test void 코스는_지역명과_규모를_낸다() { CourseResponse.Item item = new CourseResponse.Item( - 1, "MORNING", "SIGHT", "관광", "c1", "장소1", null, null, null, null, null, null, null, 37.5, 128.6, 0, null, "정선군"); + 1, "MORNING", "SIGHT", "관광", "c1", "장소1", null, null, null, null, null, null, null, null, 37.5, 128.6, 0, null, "정선군"); CourseResponse.Day day = new CourseResponse.Day(1, null, null, null, null, null, List.of(item)); CourseResponse response = new CourseResponse( 1L, 16, 3, null, "PACKED", "CAR", List.of(day), List.of(), null, null, null); diff --git a/src/test/java/com/offway/core/policy/domain/PolicyTypeSlotTargetTest.java b/src/test/java/com/offway/core/policy/domain/PolicyTypeSlotTargetTest.java new file mode 100644 index 00000000..d2558813 --- /dev/null +++ b/src/test/java/com/offway/core/policy/domain/PolicyTypeSlotTargetTest.java @@ -0,0 +1,38 @@ +package com.offway.core.policy.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.offway.core.itinerary.domain.SlotKind; +import java.util.Arrays; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * 혜택이 어느 슬롯에 붙는가(#140) — 단정할 수 있는 것만. + * + *

근거 없이 배지를 붙이면 사용자가 못 받는 할인을 기대하고 간다. 안 붙이는 것보다 나쁘다. + */ +class PolicyTypeSlotTargetTest { + + @Test + void 숙박세일페스타는_숙소에_붙는다() { + // 프로그램 이름이 곧 대상이라 단정할 수 있다. 89곳 중 85곳에 걸려 있어 대부분의 코스에 뜬다. + assertEquals(Optional.of(SlotKind.STAY), PolicyType.STAY_FESTA.targetSlotKind()); + } + + @ParameterizedTest + @EnumSource(value = PolicyType.class, names = "STAY_FESTA", mode = EnumSource.Mode.EXCLUDE) + void 나머지는_슬롯을_단정하지_않는다(PolicyType type) { + // 지자체 바우처는 가맹점 목록이, 디지털관광주민증은 제휴처 목록이 있어야 한다. 우리에겐 없다. + assertTrue(type.targetSlotKind().isEmpty(), type + " 가 근거 없이 슬롯을 단정한다"); + } + + @Test + void 슬롯을_단정하지_않아도_지역_혜택은_그대로다() { + // 여기서 비어 있다고 혜택이 없는 게 아니다 — 코스 benefits 로는 계속 나간다. + assertTrue(Arrays.stream(PolicyType.values()).allMatch(type -> type.targetTag() != null)); + } +} From 90e9c31c1d834e23021fa289a1068be6fcbecf03 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Wed, 12 Aug 2026 02:29:21 +0900 Subject: [PATCH 09/20] =?UTF-8?q?feat:=20=EC=A7=80=EC=97=AD=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84=20=EC=86=8C=EA=B0=9C=EB=A5=BC=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EC=97=90=EC=84=9C=20=EB=8F=84=EC=B6=9C=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 감성 카피를 주는 외부 출처가 없다. 구석구석은 장소(contentId) 단위라 지역 문구가 없고, 관광 API 에 지역 소개 엔드포인트가 없으며, RegionTag 는 정책 프로그램용뿐이다. 남는 선택은 89곳을 사람이 쓰는 것인데 그건 실재하는 지역에 대한 주장이라 틀리면 그대로 사용자에게 나간다. - 그래서 사실만 조합한다. 그 지역에 실제로 있는 국가유산·볼거리 이름을 쓴다 — 감성은 없지만 틀리지 않는다. - 종목 순서가 곧 대표성이다. 사적·명승을 보물보다 앞에 뒀다 — 보물에는 석탑·불상처럼 건물 안의 작은 것이 많은데 사적·명승은 자리 자체가 목적지다. - 조사를 받침으로 계산한다. `와(과)` 처럼 둘 다 적으면 화면이 어색해진다. - 카드에 안 맞는 이름(길거나 `제2로 직봉 - …` 같은 구분자 포함)은 뺀다. 뺄 것이 많아 재료가 없으면 소개를 안 만든다 — 어색한 문구보다 없는 편이 낫다. - 부팅 때 한 번 조립한다. 재료가 배포 파일에서 오는 레퍼런스라 요청마다 89곳을 다시 만들 이유가 없다. --- .../core/region/domain/RegionIntro.java | 98 +++++++++++++++++++ .../region/service/RegionIntroProvider.java | 78 +++++++++++++++ .../repository/RegionLandmarkRepository.java | 76 ++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 src/main/java/com/offway/core/region/domain/RegionIntro.java create mode 100644 src/main/java/com/offway/core/region/service/RegionIntroProvider.java create mode 100644 src/main/java/com/offway/core/trip/repository/RegionLandmarkRepository.java diff --git a/src/main/java/com/offway/core/region/domain/RegionIntro.java b/src/main/java/com/offway/core/region/domain/RegionIntro.java new file mode 100644 index 00000000..1698cd3e --- /dev/null +++ b/src/main/java/com/offway/core/region/domain/RegionIntro.java @@ -0,0 +1,98 @@ +package com.offway.core.region.domain; + +import java.util.List; + +/** + * 지역 한 줄 소개(#140) — 그 지역에 실제로 있는 것의 이름으로 만든다. + * + *

왜 이렇게 만드나. 화면 명세는 {@code 폐광촌에서 다시 태어난 마을} 같은 감성 문구를 그리는데, + * 그런 카피는 어디서도 오지 않는다 — 구석구석은 장소(contentId) 단위라 지역 문구가 없고, 관광 API 에 + * 지역 소개 엔드포인트가 없다. 남는 선택은 89곳을 사람이 쓰는 것뿐인데, 그건 실재하는 지역에 대한 주장이라 + * 틀리면 그대로 사용자에게 나간다. + * + *

그래서 사실만 조합한다. 감성은 없지만 틀리지 않는다. + */ +public record RegionIntro(String text) { + + /** 지역명 접두어 — 국가유산 이름이 {@code 의성 탑리리 오층석탑} 처럼 시군구로 시작한다. */ + private static final String NAME_SEPARATOR = " "; + + /** 카드 한 줄에 들어가는 길이. 넘으면 소개가 아니라 목록이 된다. */ + private static final int MAX_NAME_LENGTH = 14; + + /** 한글 음절 영역 — 조사를 고르려면 마지막 글자의 받침을 봐야 한다. */ + private static final char HANGUL_START = 0xAC00; + private static final char HANGUL_END = 0xD7A3; + private static final int JONGSEONG_COUNT = 28; + + /** + * 대표 볼거리 이름들로 소개를 만든다. 이름이 없으면 소개도 없다(빈 문자열이 아니라 {@code null} 텍스트). + * + * @param sigungu 지역명 — 볼거리 이름 앞에 붙은 중복을 떼는 데 쓴다 + */ + public static RegionIntro of(String sigungu, List landmarkNames) { + List names = landmarkNames.stream() + .map(name -> stripRegionPrefix(name, sigungu)) + .filter(RegionIntro::isCardFriendly) + .distinct() + .limit(2) + .toList(); + if (names.isEmpty()) { + return new RegionIntro(null); + } + String subject = names.size() >= 2 + ? names.get(0) + particle(names.get(0), "과", "와") + " " + names.get(1) + : names.get(0); + String last = names.size() >= 2 ? names.get(1) : names.get(0); + return new RegionIntro(subject + particle(last, "이", "가") + " 있는 곳"); + } + + /** + * {@code 의성 탑리리 오층석탑} → {@code 탑리리 오층석탑}. + * + *

지역명이 이미 카드에 있는데 문구에서 또 반복하면 {@code 의성군 · 경상북도 / 의성 탑리리 오층석탑이 + * 있는 곳} 이 된다. 시군구의 접미사(군·시·구)를 뗀 형태로도 맞춰 본다. + */ + private static String stripRegionPrefix(String name, String sigungu) { + if (name == null) { + return ""; + } + String trimmed = name.trim(); + for (String prefix : List.of(sigungu, sigungu.replaceAll("(시|군|구)$", ""))) { + if (!prefix.isBlank() && trimmed.startsWith(prefix + NAME_SEPARATOR)) { + return trimmed.substring(prefix.length() + NAME_SEPARATOR.length()).trim(); + } + } + return trimmed; + } + + /** + * 카드 한 줄에 넣을 만한 이름인가. + * + *

길거나 구분자가 섞인 이름({@code 제2로 직봉 - 의성 계란현 봉수 유적})은 뺀다. 소개는 한눈에 읽혀야 + * 하는데 그런 이름이 들어가면 목록처럼 보인다. 뺄 것이 많아 재료가 없으면 소개를 안 만든다 — + * 어색한 문구보다 없는 편이 낫다. + */ + private static boolean isCardFriendly(String name) { + return !name.isBlank() && name.length() <= MAX_NAME_LENGTH && !name.contains(" - "); + } + + /** + * 받침에 따라 조사를 고른다 — {@code 오대산사고가} · {@code 극락전이}. + * + *

{@code 와(과)} 처럼 둘 다 적으면 화면이 어색해진다. 한글은 마지막 글자의 종성으로 결정되므로 + * 계산할 수 있다. 한글이 아닌 글자로 끝나면(숫자·영문) 받침 없는 쪽을 쓴다 — 읽을 때 그쪽이 자연스럽다. + */ + private static String particle(String word, String withJongseong, String withoutJongseong) { + char last = word.charAt(word.length() - 1); + if (last < HANGUL_START || last > HANGUL_END) { + return withoutJongseong; + } + return (last - HANGUL_START) % JONGSEONG_COUNT != 0 ? withJongseong : withoutJongseong; + } + + /** 소개를 만들 재료가 없었나. */ + public boolean isEmpty() { + return text == null || text.isBlank(); + } +} diff --git a/src/main/java/com/offway/core/region/service/RegionIntroProvider.java b/src/main/java/com/offway/core/region/service/RegionIntroProvider.java new file mode 100644 index 00000000..7437d464 --- /dev/null +++ b/src/main/java/com/offway/core/region/service/RegionIntroProvider.java @@ -0,0 +1,78 @@ +package com.offway.core.region.service; + +import com.offway.core.region.domain.Region; +import com.offway.core.region.domain.RegionIntro; +import com.offway.core.trip.repository.RegionLandmarkRepository; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * 지역 한 줄 소개를 부팅 시 한 번 만들어 들고 있는다(#140). + * + *

재료(국가유산·인허가 볼거리)는 배포 파일에서 오는 레퍼런스라 프로세스가 사는 동안 바뀌지 않는다. + * 요청마다 89곳을 다시 조립할 이유가 없다 — 지역 마스터(#102)와 같은 이유다. + * + *

국가유산이 먼저다. 국보·사적·명승은 그 자체가 지역을 대표하는 자리다. 국가유산이 없는 지역 + * (실측 1곳 — 대구 서구)만 인허가 볼거리로 대신한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RegionIntroProvider { + + /** 소개에 쓸 이름 수. 둘이면 "A와 B가 있는 곳", 하나면 "A가 있는 곳". */ + private static final int NAMES_PER_REGION = 2; + + private final RegionMaster regionMaster; + private final RegionLandmarkRepository landmarkRepository; + + private volatile Map introById = Map.of(); + + /** 기동이 끝난 뒤 만든다. 적재(장소 풀·국가유산)가 끝난 뒤여야 재료가 있다. */ + @EventListener(ApplicationReadyEvent.class) + public void warm() { + rebuild(); + } + + /** 이 지역의 소개. 재료가 없으면 텍스트가 {@code null} 이라 응답에서 필드가 사라진다. */ + public RegionIntro of(long regionId) { + if (introById.isEmpty()) { + rebuild(); + } + return introById.getOrDefault(regionId, new RegionIntro(null)); + } + + private void rebuild() { + List regions = regionMaster.all(); + if (regions.isEmpty()) { + return; + } + Map> heritage = landmarkRepository.topHeritageNames(NAMES_PER_REGION); + Map> licensed = landmarkRepository.topLicensedSightNames(NAMES_PER_REGION); + + Map built = new HashMap<>(); + int empty = 0; + for (Region region : regions) { + List names = heritage.getOrDefault(region.getId(), + licensed.getOrDefault(region.getId(), List.of())); + RegionIntro intro = RegionIntro.of(region.getSigungu(), names); + built.put(region.getId(), intro); + if (intro.isEmpty()) { + empty++; + } + } + introById = Map.copyOf(built); + if (empty > 0) { + // 조용히 넘어가면 어느 지역 카드가 비는지 아무도 모른다. + log.warn("지역 소개 조립 완료 {}/{} — 재료가 없어 빈 지역 {}곳", regions.size() - empty, regions.size(), empty); + return; + } + log.info("지역 소개 조립 완료 {}곳", regions.size()); + } +} diff --git a/src/main/java/com/offway/core/trip/repository/RegionLandmarkRepository.java b/src/main/java/com/offway/core/trip/repository/RegionLandmarkRepository.java new file mode 100644 index 00000000..52415055 --- /dev/null +++ b/src/main/java/com/offway/core/trip/repository/RegionLandmarkRepository.java @@ -0,0 +1,76 @@ +package com.offway.core.trip.repository; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * 지역의 대표 볼거리 이름을 뽑는다(#140) — 지역 한 줄 소개의 재료. + * + *

지어내지 않는다. 소개 문구를 사람이 쓰면 89곳 카피를 만들어야 하고, 그건 실재하는 지역에 대한 + * 주장이라 틀리면 그대로 사용자에게 나간다. 대신 우리가 이미 가진 사실 — 그 지역에 실제로 있는 국가유산과 + * 볼거리 이름 — 을 조합한다. + * + *

종목 순서가 곧 대표성이다. 국보·사적·명승은 그 자체가 지역을 대표하는 자리이고, 시도지정은 + * 그보다 좁다. 같은 지역에서 국보가 있으면 그것이 먼저다. + */ +@Repository +@RequiredArgsConstructor +public class RegionLandmarkRepository { + + /** + * 대표성이 높은 순서. 앞에 있을수록 그 지역을 대표한다. + * + *

사적·명승을 보물보다 앞에 둔다 — 보물에는 석탑·불상처럼 건물 안의 작은 것이 많은데, + * 사적·명승은 자리 자체가 목적지다. + */ + private static final List KIND_RANK = List.of( + "국보", "사적", "명승", "천연기념물", "보물", "국가민속문화유산", "국가등록문화유산"); + + private final JdbcTemplate jdbcTemplate; + + /** + * 지역별 대표 볼거리 이름 — 지역당 최대 {@code limit} 개. + * + *

한 번의 질의로 전 지역을 가져온다. 89번 물으면 부팅이 그만큼 느려진다. + */ + public Map> topHeritageNames(int limit) { + String ranked = String.join(",", KIND_RANK.stream().map(kind -> "'" + kind + "'").toList()); + Map> byRegion = new HashMap<>(); + jdbcTemplate.query(""" + SELECT region_id, name FROM heritage_place + WHERE kind IN (%s) + ORDER BY region_id, FIELD(kind, %s), id + """.formatted(ranked, ranked), rs -> { + List names = byRegion.computeIfAbsent(rs.getLong("region_id"), key -> new ArrayList<>()); + if (names.size() < limit) { + names.add(rs.getString("name")); + } + }); + return byRegion; + } + + /** + * 국가유산이 없는 지역의 대체 — 관광 콘텐츠성이 높은 인허가 볼거리(전통사찰·박물관 등). + * + *

실제로 한 곳(대구 서구)이 여기 해당한다. 그 지역의 국가유산이 무형유산뿐이라 방문 대상이 없다. + */ + public Map> topLicensedSightNames(int limit) { + Map> byRegion = new HashMap<>(); + jdbcTemplate.query(""" + SELECT region_id, name FROM licensed_place + WHERE kind = 'SIGHT' + ORDER BY region_id, fitness_rank, name + """, rs -> { + List names = byRegion.computeIfAbsent(rs.getLong("region_id"), key -> new ArrayList<>()); + if (names.size() < limit) { + names.add(rs.getString("name")); + } + }); + return byRegion; + } +} From c11d9b8c94af1b9b1366f61095e6d365fd6c2f77 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Wed, 12 Aug 2026 02:29:21 +0900 Subject: [PATCH 10/20] =?UTF-8?q?feat:=20=EC=B6=94=EC=B2=9C=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EC=97=90=20=EC=A7=80=EC=97=AD=20=EC=86=8C=EA=B0=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서비스 dto 가 소개를 들고 온다. 응답 DTO 가 provider 를 직접 부르면 그 지식이 두 곳에 생긴다. - 재료가 없으면 필드 자체가 사라진다 — 화면이 빈 줄을 그리지 않는다. --- .../dto/RegionRecommendResponse.java | 7 ++ .../service/RegionRecommendationService.java | 7 +- .../trip/service/dto/RecommendedRegion.java | 3 + .../logging/ResponseLogSummaryTest.java | 2 +- .../core/region/domain/RegionIntroTest.java | 68 +++++++++++++++++++ .../service/dto/RecommendedRegionTest.java | 2 +- 6 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/offway/core/region/domain/RegionIntroTest.java diff --git a/src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java b/src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java index ba6b0652..89ad99f0 100644 --- a/src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java +++ b/src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java @@ -46,6 +46,12 @@ public record Item( @Schema(example = "38") int contentCount, List categories, @Schema(example = "false") boolean neighborIncluded, + @Schema(description = """ + 지역 한 줄 소개(#140). 그 지역에 실제로 있는 대표 볼거리 이름으로 만든다. + + 감성 카피가 아니라 사실이다 — 지역 소개를 주는 외부 출처가 없어, 지어내는 대신 + 우리가 가진 것(국가유산·볼거리)의 이름을 조합한다. 재료가 없으면 필드가 없다.""", + example = "탑리리 오층석탑와(과) 고운사 가운루가(이) 있는 곳", nullable = true) String intro, List benefits) { static Item from(RecommendedRegion region) { @@ -58,6 +64,7 @@ static Item from(RecommendedRegion region) { region.contentCount(), region.categories().stream().map(CategoryResponse.Item::from).toList(), region.neighborIncluded(), + region.intro(), region.benefits().stream().map(Benefit::from).toList()); } } diff --git a/src/main/java/com/offway/core/trip/service/RegionRecommendationService.java b/src/main/java/com/offway/core/trip/service/RegionRecommendationService.java index 463baab3..bc8cb8cf 100644 --- a/src/main/java/com/offway/core/trip/service/RegionRecommendationService.java +++ b/src/main/java/com/offway/core/trip/service/RegionRecommendationService.java @@ -3,6 +3,7 @@ import com.offway.core.policy.domain.Policy; import com.offway.core.policy.service.PolicyService; import com.offway.core.region.domain.Region; +import com.offway.core.region.service.RegionIntroProvider; import com.offway.core.region.service.RegionMaster; import com.offway.core.transport.domain.Coordinate; import com.offway.core.transport.service.TravelTimeProvider; @@ -37,6 +38,7 @@ public class RegionRecommendationService { private static final int CONTENT_LOOKUP_LIMIT = 20; private final RegionMaster regionMaster; + private final RegionIntroProvider regionIntroProvider; private final TravelTimeProvider travelTimeProvider; private final RegionRankingService regionRankingService; private final RegionContentProvider regionContentProvider; @@ -99,7 +101,10 @@ public List recommend(RecommendRegions command) { result.add(RecommendedRegion.of( region.getId(), region.getSido(), region.getSigungu(), reachByRegion.get(region.getId()), score.crowdLevel(), content, - heroPhotos.get(region.getId()), benefits)); + heroPhotos.get(region.getId()), + // 지역 소개는 부팅 때 조립해 둔 값이다 — 요청마다 89곳을 다시 만들지 않는다(#140). + regionIntroProvider.of(region.getId()).text(), + benefits)); } // 4. 무드 필터 — 해당 카테고리 콘텐츠가 있는 지역을 앞세운다(재정렬). 매칭이 하나도 없으면 랭킹 순 유지(빈 결과 방지, F6) diff --git a/src/main/java/com/offway/core/trip/service/dto/RecommendedRegion.java b/src/main/java/com/offway/core/trip/service/dto/RecommendedRegion.java index 7948ad79..9b0bf123 100644 --- a/src/main/java/com/offway/core/trip/service/dto/RecommendedRegion.java +++ b/src/main/java/com/offway/core/trip/service/dto/RecommendedRegion.java @@ -30,6 +30,7 @@ public record RecommendedRegion( String imageUrl, List categories, boolean neighborIncluded, + String intro, List benefits) { /** @@ -48,6 +49,7 @@ public static RecommendedRegion of( CrowdLevel crowdLevel, RegionContent content, String heroPhotoUrl, + String intro, List benefits) { return new RecommendedRegion( regionId, sido, sigungu, reachMinutes, crowdLevel, @@ -55,6 +57,7 @@ public static RecommendedRegion of( heroPhotoUrl != null ? heroPhotoUrl : content.imageUrl(), content.categories(), content.neighborIncluded(), + intro, benefits); } diff --git a/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java b/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java index adaa1cfd..89b95cc8 100644 --- a/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java +++ b/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java @@ -17,7 +17,7 @@ class ResponseLogSummaryTest { void 여행지_추천은_건수만_낸다() { List items = IntStream.rangeClosed(1, 20) .mapToObj(i -> new RegionRecommendResponse.Item( - i, "지역" + i + " · 도", 100 + i, null, null, 10, List.of(), false, List.of())) + i, "지역" + i + " · 도", 100 + i, null, null, 10, List.of(), false, null, List.of())) .toList(); assertEquals("추천 20건", new RegionRecommendResponse(items).logSummary()); diff --git a/src/test/java/com/offway/core/region/domain/RegionIntroTest.java b/src/test/java/com/offway/core/region/domain/RegionIntroTest.java new file mode 100644 index 00000000..311a44a2 --- /dev/null +++ b/src/test/java/com/offway/core/region/domain/RegionIntroTest.java @@ -0,0 +1,68 @@ +package com.offway.core.region.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** + * 지역 한 줄 소개(#140) — 지어내지 않고 사실만 조합하는가. + * + *

감성 카피를 주는 외부 출처가 없어, 그 지역에 실제로 있는 것의 이름을 쓴다. 대신 문장이 어색하면 + * 화면이 망가지므로 조사·길이를 다듬는다. + */ +class RegionIntroTest { + + @Test + void 두_곳이면_둘을_잇는다() { + RegionIntro intro = RegionIntro.of("평창군", List.of("평창 월정사 팔각 구층석탑", "평창 오대산사고")); + + assertEquals("월정사 팔각 구층석탑과 오대산사고가 있는 곳", intro.text()); + } + + @Test + void 한_곳이면_그것만_쓴다() { + assertEquals("현등사 극락전이 있는 곳", RegionIntro.of("가평군", List.of("가평 현등사 극락전")).text()); + } + + @ParameterizedTest + @CsvSource({ + "오대산사고, 오대산사고가 있는 곳", + "극락전, 극락전이 있는 곳", + "화양구곡, 화양구곡이 있는 곳", + }) + void 조사를_받침으로_고른다(String name, String expected) { + // `와(과)` 처럼 둘 다 적으면 화면이 어색해진다. 한글은 종성으로 결정되므로 계산할 수 있다. + assertEquals(expected, RegionIntro.of("어디군", List.of(name)).text()); + } + + @Test + void 지역명_접두어를_뗀다() { + // 카드에 지역명이 이미 있는데 문구에서 또 반복하면 "의성군 · 경북 / 의성 탑리리…" 가 된다. + assertEquals("탑리리 오층석탑이 있는 곳", RegionIntro.of("의성군", List.of("의성 탑리리 오층석탑")).text()); + } + + @Test + void 카드에_안_맞는_이름은_뺀다() { + // `제2로 직봉 - 의성 계란현 봉수 유적` 이 들어가면 소개가 아니라 목록처럼 읽힌다. + RegionIntro intro = RegionIntro.of("의성군", + List.of("의성 탑리리 오층석탑", "제2로 직봉 - 의성 계란현 봉수 유적")); + + assertEquals("탑리리 오층석탑이 있는 곳", intro.text()); + } + + @Test + void 재료가_없으면_소개도_없다() { + // 어색한 문구보다 없는 편이 낫다 — 응답에서 필드 자체가 사라진다. + assertTrue(RegionIntro.of("어디군", List.of()).isEmpty()); + assertTrue(RegionIntro.of("어디군", List.of("제1로 직봉 - 아주 긴 이름의 어떤 유적지 이름")).isEmpty()); + } + + @Test + void 같은_이름은_한_번만_쓴다() { + assertEquals("같은절이 있는 곳", RegionIntro.of("어디군", List.of("같은절", "같은절")).text()); + } +} diff --git a/src/test/java/com/offway/core/trip/service/dto/RecommendedRegionTest.java b/src/test/java/com/offway/core/trip/service/dto/RecommendedRegionTest.java index e361f718..44291a55 100644 --- a/src/test/java/com/offway/core/trip/service/dto/RecommendedRegionTest.java +++ b/src/test/java/com/offway/core/trip/service/dto/RecommendedRegionTest.java @@ -14,7 +14,7 @@ class RecommendedRegionTest { private static RecommendedRegion region(long id, Category... categories) { return RecommendedRegion.of( id, "시도", "시군구" + id, 60, CrowdLevel.LOW, - new RegionContent(10, null, List.of(categories), false), null, List.of()); + new RegionContent(10, null, List.of(categories), false), null, null, List.of()); } private static List ids(List regions) { From 7e8286c72a1dfa70f61914b714db6dd74edf8028 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Wed, 12 Aug 2026 08:13:21 +0900 Subject: [PATCH 11/20] =?UTF-8?q?feat:=20=EC=82=AC=EC=A7=84=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=8A=AC=EB=A1=AF=EC=9D=84=20=EC=A7=80=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=EB=84=98=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 숙소는 89곳 중 45곳에서 사진 있는 후보가 2곳도 안 된다. 인허가 데이터에 사진이 없고, 공식 API 로 숙소 사진을 주는 곳은 유료뿐이라 이건 기술 선택이 아니라 비용 결정이었다. - 사진을 사는 대신 표현을 바꾼다(A안). 사진 없는 카드를 그대로 두지 않고 지도로 넘겨 위치· 사진·리뷰를 거기서 보게 한다. 비용 0 이고 89곳 전부에서 동작한다. - 사진이 있으면 링크를 안 붙인다. 카드가 이미 설 수 있어 군더더기다. - 지역 갤러리 사진으로 숙소 카드를 채우지 않는다. 그 숙소 사진이 아니므로, 규칙이 막으려던 "채운 척" 을 다른 방식으로 하는 것일 뿐이다. --- .../controller/dto/CourseResponse.java | 24 +++++++++++ .../logging/ResponseLogSummaryTest.java | 2 +- .../controller/dto/SlotMapLinkTest.java | 42 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/offway/core/itinerary/controller/dto/SlotMapLinkTest.java diff --git a/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java b/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java index 144d24bc..d6e434c0 100644 --- a/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java +++ b/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java @@ -5,6 +5,7 @@ import com.offway.core.itinerary.domain.DaySchedule; import com.offway.core.itinerary.domain.Slot; import com.offway.core.itinerary.domain.SlotKind; +import com.offway.core.trip.domain.MapSearchLink; import com.offway.core.itinerary.service.dto.GeneratedCourse; import com.offway.core.itinerary.service.dto.SlotHours; import com.offway.core.policy.domain.PolicyType; @@ -265,6 +266,14 @@ public record Item( 알 수 있는데 그 데이터가 없어 붙이지 않는다 — 근거 없이 붙이면 사용자가 못 받는 할인을 기대하고 간다. 지역 단위 혜택은 코스 `benefits` 로 그대로 나간다.""", example = "숙박 할인", nullable = true) String benefit, + @Schema(description = """ + 지도 검색 링크(#236). **사진이 없는 슬롯에만** 실린다. + + 숙소는 89곳 중 45곳에서 사진 있는 후보가 2곳도 안 된다 — 인허가 데이터에 사진이 없고, + 공식 API 로 숙소 사진을 주는 곳은 유료뿐이다. 사진 없는 카드를 그대로 두는 대신 + 지도로 넘겨 위치·사진·리뷰를 거기서 보게 한다.""", + example = "https://map.naver.com/p/search/%EC%9D%98%EC%84%B1%EA%B5%B0+%EC%98%AC%EC%9D%B8%EB%AA%A8%ED%85%94", + nullable = true) String mapSearchUrl, double lat, double lng, int travelMinutes, @@ -289,6 +298,7 @@ static Item from(Slot slot, Integer distanceFromPrevMeters, String regionName, hours == null ? null : hours.restDate(), hours == null ? null : hours.displayStatus(), benefit, + mapSearchUrlFor(slot), slot.getLat(), slot.getLng(), slot.getTravelMinutesFromPrev(), @@ -387,4 +397,18 @@ private static Map slotBenefits(GeneratedCourse generated) { private static String benefitFor(Slot slot, Map slotBenefits) { return slotBenefits.get(slot.getKind()); } + + /** + * 사진이 없는 슬롯에만 지도 링크를 준다(#236). + * + *

사진이 있으면 카드가 이미 설 수 있어 링크가 군더더기다. 없을 때만 "여기서 보세요" 가 값어치를 갖는다. + * + *

숙소가 이 경우의 대부분이다 — 89곳 중 45곳에서 사진 있는 숙소가 2곳도 안 된다. + */ + private static String mapSearchUrlFor(Slot slot) { + if (slot.getImageUrl() != null && !slot.getImageUrl().isBlank()) { + return null; + } + return MapSearchLink.of(slot.getTitle(), slot.getAddress()).orElse(null); + } } diff --git a/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java b/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java index 89b95cc8..9fcf2008 100644 --- a/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java +++ b/src/test/java/com/offway/core/common/logging/ResponseLogSummaryTest.java @@ -32,7 +32,7 @@ class ResponseLogSummaryTest { @Test void 코스는_지역명과_규모를_낸다() { CourseResponse.Item item = new CourseResponse.Item( - 1, "MORNING", "SIGHT", "관광", "c1", "장소1", null, null, null, null, null, null, null, null, 37.5, 128.6, 0, null, "정선군"); + 1, "MORNING", "SIGHT", "관광", "c1", "장소1", null, null, null, null, null, null, null, null, null, 37.5, 128.6, 0, null, "정선군"); CourseResponse.Day day = new CourseResponse.Day(1, null, null, null, null, null, List.of(item)); CourseResponse response = new CourseResponse( 1L, 16, 3, null, "PACKED", "CAR", List.of(day), List.of(), null, null, null); diff --git a/src/test/java/com/offway/core/itinerary/controller/dto/SlotMapLinkTest.java b/src/test/java/com/offway/core/itinerary/controller/dto/SlotMapLinkTest.java new file mode 100644 index 00000000..72ec0fda --- /dev/null +++ b/src/test/java/com/offway/core/itinerary/controller/dto/SlotMapLinkTest.java @@ -0,0 +1,42 @@ +package com.offway.core.itinerary.controller.dto; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.offway.core.itinerary.domain.Slot; +import com.offway.core.itinerary.domain.SlotDisplay; +import com.offway.core.itinerary.domain.SlotKind; +import com.offway.core.itinerary.domain.TimeOfDay; +import org.junit.jupiter.api.Test; + +/** + * 사진 없는 슬롯에 지도 링크(#236). + * + *

숙소는 89곳 중 45곳에서 사진 있는 후보가 2곳도 안 된다. 인허가 데이터에 사진이 없고, 공식 API 로 + * 숙소 사진을 주는 곳은 유료뿐이다. 사진 없는 카드를 그대로 두는 대신 지도로 넘긴다. + */ +class SlotMapLinkTest { + + private static Slot slot(String imageUrl) { + return Slot.of(1, TimeOfDay.DINNER, SlotKind.STAY, "LIC-1", "올인모텔", 36.35, 128.69, 0, + new SlotDisplay(imageUrl, "경상북도 의성군 의성읍 후죽리 1", null, "054-1")); + } + + @Test + void 사진이_없으면_지도로_넘긴다() { + CourseResponse.Item item = CourseResponse.Item.from(slot(null), null, "의성군", null, null); + + assertNotNull(item.mapSearchUrl()); + assertTrue(item.mapSearchUrl().startsWith("https://map.naver.com/p/search/")); + } + + @Test + void 사진이_있으면_링크를_안_붙인다() { + // 카드가 이미 설 수 있어 링크가 군더더기다. + CourseResponse.Item item = + CourseResponse.Item.from(slot("http://img/1.jpg"), null, "의성군", null, null); + + assertNull(item.mapSearchUrl()); + } +} From 3380cc721ded1d63b2a61d33f8349b8549780c6e Mon Sep 17 00:00:00 2001 From: sevin98 Date: Thu, 13 Aug 2026 16:31:20 +0900 Subject: [PATCH 12/20] =?UTF-8?q?refactor:=20=EC=9A=B4=EC=98=81=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=A0=80=EC=9E=A5=EC=86=8C=EB=A5=BC=20port=20?= =?UTF-8?q?=EC=99=80=20JDBC=20=EC=96=B4=EB=8C=91=ED=84=B0=EB=A1=9C=20?= =?UTF-8?q?=EB=82=98=EB=88=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PoiIntroRepository 를 인터페이스로 바꾸고 SQL·JdbcTemplate 을 PoiIntroRepositoryImpl 로 옮겼다. service·provider 는 타입 이름이 그대로라 손대지 않는다 - 레포에는 두 선례가 있다. HeritagePoolSourceRepository·ExternalApiCallRepository 는 엔티티 없는 JDBC 저장소인데 인터페이스가 없고, PlacePoolSourceRepository 는 같은 조건인데 port/adapter 로 나뉘어 있다. 가른 기준을 "읽는 쪽이 어느 도메인인가" 로 잡았다 — 앞의 둘은 자기 도메인 안에서만 쓰이지만 이것은 itinerary 의 OpeningHoursProvider 가 읽는다. 도메인 경계를 넘는 자리는 SQL 이 아니라 계약에 기대야 한다 - ContentRef 는 조회 계약의 일부라 인터페이스 안에 둔다. 호출부 표기(PoiIntroRepository.ContentRef)가 그대로라 부수 변경이 없다 --- .../trip/repository/PoiIntroRepository.java | 82 ++++--------------- .../repository/PoiIntroRepositoryImpl.java | 81 ++++++++++++++++++ 2 files changed, 95 insertions(+), 68 deletions(-) create mode 100644 src/main/java/com/offway/core/trip/repository/PoiIntroRepositoryImpl.java diff --git a/src/main/java/com/offway/core/trip/repository/PoiIntroRepository.java b/src/main/java/com/offway/core/trip/repository/PoiIntroRepository.java index 19143a57..37b6f09b 100644 --- a/src/main/java/com/offway/core/trip/repository/PoiIntroRepository.java +++ b/src/main/java/com/offway/core/trip/repository/PoiIntroRepository.java @@ -2,43 +2,24 @@ import com.offway.core.trip.domain.OpeningHours; import java.time.LocalDateTime; -import java.util.HashMap; import java.util.List; import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Repository; /** - * 장소 운영시간·휴무일 저장소(#157). + * 장소 운영시간·휴무일 저장소 port(#157). 구현은 {@link PoiIntroRepositoryImpl}. * *

엔티티를 두지 않는다 — 행이 콘텐츠 하나당 하나이고 도메인 규칙이 없다. 조회는 "이 코스의 콘텐츠들" * 처럼 항상 묶음이라 JPA 로 얻을 것도 없다. + * + *

그럼에도 port 를 두는 이유 — 읽는 쪽이 다른 도메인이다({@code itinerary} 의 + * {@code OpeningHoursProvider}). 코스 응답이 SQL 이 아니라 계약에 기대게 하려면 경계에 인터페이스가 + * 있어야 한다. 같은 도메인 안에서만 쓰이는 {@code HeritagePoolSourceRepository}· + * {@code ExternalApiCallRepository} 가 인터페이스 없이 사는 것과 갈리는 지점이다. */ -@Repository -@RequiredArgsConstructor -public class PoiIntroRepository { - - /** 한 번에 넣는 크기. 코스 하나가 20건 안팎이라 넉넉하다. */ - private static final int BATCH_SIZE = 500; - - private final JdbcTemplate jdbcTemplate; +public interface PoiIntroRepository { /** 콘텐츠 id 로 운영시간을 찾는다. 없는 것은 키가 없다 — 호출자가 "아직 안 받았다" 로 읽는다. */ - public Map findByContentIds(List contentIds) { - if (contentIds.isEmpty()) { - return Map.of(); - } - String placeholders = String.join(",", contentIds.stream().map(id -> "?").toList()); - Map found = new HashMap<>(); - jdbcTemplate.query("SELECT content_id, use_time, rest_date FROM poi_intro WHERE content_id IN (" + placeholders + ")", - rs -> { - found.put(rs.getString("content_id"), - new OpeningHours(rs.getString("use_time"), rs.getString("rest_date"))); - }, - contentIds.toArray()); - return found; - } + Map findByContentIds(List contentIds); /** * 아직 안 받은 콘텐츠를 코스 슬롯에서 찾는다 — 슬롯 테이블이 곧 일감 목록이다. @@ -48,49 +29,14 @@ public Map findByContentIds(List contentIds) { * *

타입이 없는 슬롯(이 기능 이전 코스·우리 DB 출처)은 제외한다 — 타입 없이는 detailIntro2 를 못 부른다. */ - public List findMissing(int limit) { - return jdbcTemplate.query(""" - SELECT DISTINCT s.poi_content_id, s.poi_content_type_id - FROM slot s - LEFT JOIN poi_intro p ON p.content_id = s.poi_content_id - WHERE p.content_id IS NULL - AND s.poi_content_type_id IS NOT NULL - LIMIT ? - """, (rs, rowNum) -> new ContentRef(rs.getString(1), rs.getInt(2)), limit); - } - - /** 아직 안 받은 콘텐츠 한 건 — 타입이 있어야 detailIntro2 를 부를 수 있다. */ - public record ContentRef(String contentId, int contentTypeId) { - } + List findMissing(int limit); /** 받은 것을 넣는다. 같은 콘텐츠를 다시 받으면 덮어쓴다. */ - public int upsertAll(Map hours, LocalDateTime fetchedAt) { - List> rows = List.copyOf(hours.entrySet()); - int saved = 0; - for (int start = 0; start < rows.size(); start += BATCH_SIZE) { - List> chunk = - rows.subList(start, Math.min(start + BATCH_SIZE, rows.size())); - int[] result = jdbcTemplate.batchUpdate(""" - INSERT INTO poi_intro (content_id, content_type_id, use_time, rest_date, fetched_at) - VALUES (?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE use_time = VALUES(use_time), rest_date = VALUES(rest_date), - fetched_at = VALUES(fetched_at) - """, chunk, chunk.size(), (ps, entry) -> { - ps.setString(1, entry.getKey().contentId()); - ps.setInt(2, entry.getKey().contentTypeId()); - ps.setString(3, entry.getValue().useTime()); - ps.setString(4, entry.getValue().restDate()); - ps.setObject(5, fetchedAt); - })[0]; - for (int count : result) { - saved += count < 0 ? 1 : count; - } - } - return saved; - } + int upsertAll(Map hours, LocalDateTime fetchedAt); - public long count() { - Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM poi_intro", Long.class); - return count == null ? 0 : count; + long count(); + + /** 아직 안 받은 콘텐츠 한 건 — 타입이 있어야 detailIntro2 를 부를 수 있다. */ + record ContentRef(String contentId, int contentTypeId) { } } diff --git a/src/main/java/com/offway/core/trip/repository/PoiIntroRepositoryImpl.java b/src/main/java/com/offway/core/trip/repository/PoiIntroRepositoryImpl.java new file mode 100644 index 00000000..2132dfc3 --- /dev/null +++ b/src/main/java/com/offway/core/trip/repository/PoiIntroRepositoryImpl.java @@ -0,0 +1,81 @@ +package com.offway.core.trip.repository; + +import com.offway.core.trip.domain.OpeningHours; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** port 구현(adapter). 엔티티가 없어 JDBC 로 직접 다룬다. */ +@Repository +@RequiredArgsConstructor +public class PoiIntroRepositoryImpl implements PoiIntroRepository { + + /** 한 번에 넣는 크기. 코스 하나가 20건 안팎이라 넉넉하다. */ + private static final int BATCH_SIZE = 500; + + private final JdbcTemplate jdbcTemplate; + + @Override + public Map findByContentIds(List contentIds) { + if (contentIds.isEmpty()) { + return Map.of(); + } + String placeholders = String.join(",", contentIds.stream().map(id -> "?").toList()); + Map found = new HashMap<>(); + jdbcTemplate.query("SELECT content_id, use_time, rest_date FROM poi_intro WHERE content_id IN (" + placeholders + ")", + rs -> { + found.put(rs.getString("content_id"), + new OpeningHours(rs.getString("use_time"), rs.getString("rest_date"))); + }, + contentIds.toArray()); + return found; + } + + @Override + public List findMissing(int limit) { + return jdbcTemplate.query(""" + SELECT DISTINCT s.poi_content_id, s.poi_content_type_id + FROM slot s + LEFT JOIN poi_intro p ON p.content_id = s.poi_content_id + WHERE p.content_id IS NULL + AND s.poi_content_type_id IS NOT NULL + LIMIT ? + """, (rs, rowNum) -> new ContentRef(rs.getString(1), rs.getInt(2)), limit); + } + + @Override + public int upsertAll(Map hours, LocalDateTime fetchedAt) { + List> rows = List.copyOf(hours.entrySet()); + int saved = 0; + for (int start = 0; start < rows.size(); start += BATCH_SIZE) { + List> chunk = + rows.subList(start, Math.min(start + BATCH_SIZE, rows.size())); + int[] result = jdbcTemplate.batchUpdate(""" + INSERT INTO poi_intro (content_id, content_type_id, use_time, rest_date, fetched_at) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE use_time = VALUES(use_time), rest_date = VALUES(rest_date), + fetched_at = VALUES(fetched_at) + """, chunk, chunk.size(), (ps, entry) -> { + ps.setString(1, entry.getKey().contentId()); + ps.setInt(2, entry.getKey().contentTypeId()); + ps.setString(3, entry.getValue().useTime()); + ps.setString(4, entry.getValue().restDate()); + ps.setObject(5, fetchedAt); + })[0]; + for (int count : result) { + saved += count < 0 ? 1 : count; + } + } + return saved; + } + + @Override + public long count() { + Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM poi_intro", Long.class); + return count == null ? 0 : count; + } +} From 8d0962043bda067c650280880d73e439b80063b9 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Thu, 13 Aug 2026 16:31:42 +0900 Subject: [PATCH 13/20] =?UTF-8?q?fix:=20=EB=B9=88=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=9D=91=EB=8B=B5=EC=9D=84=20=EC=98=81?= =?UTF-8?q?=EA=B5=AC=20=EC=BA=90=EC=8B=9C=EB=A1=9C=20=EA=B5=B3=ED=9E=88?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 빈 응답이 오면 poi_intro 에 빈 행이 남고, 일감 조회가 "행이 있으면 제외" 라 그 콘텐츠를 영영 다시 묻지 않았다. 원본이 나중에 운영시간을 채워도 화면은 계속 빈 채로 굳는다 - 행을 안 남기는 쪽은 택하지 않았다. 그러면 매 회차 같은 콘텐츠를 다시 물어 하루 예산(300)을 빈 것들이 다 먹는다. 이미 있는 fetched_at 으로 재시도 간격(7일)을 두는 쪽으로 풀었다 — 원본을 채우는 것은 지자체 정보 갱신이라 일 단위로 바뀌지 않고, 늦어도 일주일 안에는 반영된다 - 값없음을 warn 으로 올렸다. info 로 묻으면 "적재 성공" 으로 읽혀 화면의 운영시간이 왜 비는지 아무도 모른다 - 일감 조회에 순서를 넣었다. 재시도가 앞줄을 차지하면 아직 아무것도 없는 화면이 방치되므로 한 번도 안 받은 것 → 최근 슬롯 순으로 준다. 예산이 유한할 때 무엇을 먼저 채우는지가 화면을 가른다 - 같은 조회의 DISTINCT 를 콘텐츠 id GROUP BY 로 바꿨다. 같은 콘텐츠가 타입이 다른 슬롯 둘에 실리면 DISTINCT 는 두 줄을 주는데 poi_intro 는 콘텐츠당 한 행이라 예산만 두 번 썼다 - 다른 배치(GalleryPhotoRefresh·HubAttractionRefresh·RegionContentRefresh)도 훑었다. 그쪽은 빈 응답에 이전 값을 유지하고 warn 을 남기고 있어 같은 문제가 없다 --- .../trip/repository/PoiIntroRepository.java | 5 ++- .../repository/PoiIntroRepositoryImpl.java | 30 ++++++++++++++--- .../trip/service/PoiIntroRefreshService.java | 33 ++++++++++++++----- 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/offway/core/trip/repository/PoiIntroRepository.java b/src/main/java/com/offway/core/trip/repository/PoiIntroRepository.java index 37b6f09b..3cfc609d 100644 --- a/src/main/java/com/offway/core/trip/repository/PoiIntroRepository.java +++ b/src/main/java/com/offway/core/trip/repository/PoiIntroRepository.java @@ -28,8 +28,11 @@ public interface PoiIntroRepository { * 그건 이미 슬롯에 남아 있다. 큐를 만들면 슬롯과 두 곳이 되어 어긋난다. * *

타입이 없는 슬롯(이 기능 이전 코스·우리 DB 출처)은 제외한다 — 타입 없이는 detailIntro2 를 못 부른다. + * + * @param emptyRetryBefore 이 시각보다 오래된 빈 행은 다시 일감이 된다. 빈 응답은 실패와 결과가 + * 같으므로 영구 캐시로 굳히지 않는다(#157). 재시도 간격은 호출자(배치)가 정한다 */ - List findMissing(int limit); + List findMissing(int limit, LocalDateTime emptyRetryBefore); /** 받은 것을 넣는다. 같은 콘텐츠를 다시 받으면 덮어쓴다. */ int upsertAll(Map hours, LocalDateTime fetchedAt); diff --git a/src/main/java/com/offway/core/trip/repository/PoiIntroRepositoryImpl.java b/src/main/java/com/offway/core/trip/repository/PoiIntroRepositoryImpl.java index 2132dfc3..f9ddab27 100644 --- a/src/main/java/com/offway/core/trip/repository/PoiIntroRepositoryImpl.java +++ b/src/main/java/com/offway/core/trip/repository/PoiIntroRepositoryImpl.java @@ -35,16 +35,36 @@ public Map findByContentIds(List contentIds) { return found; } + /** + * 일감은 한 번도 안 받은 것빈 채로 오래된 것 둘이다. + * + *

빈 행을 영원히 제외하면 원본이 나중에 운영시간을 채워도 우리는 영영 모른다. 그렇다고 매 회차 다시 + * 물으면 예산을 태우므로 {@code fetched_at} 으로 간격을 둔다. + * + *

순서를 정한다 — 예산이 유한하면 무엇을 먼저 채우는지가 화면을 가른다. 한 번도 안 받은 것이 + * 앞이고(재시도가 앞줄을 차지하면 아직 아무것도 없는 화면이 방치된다), 그 안에서는 최근 슬롯이 + * 앞이다(방금 만든 코스가 지금 보고 있는 코스다). 순서를 안 정하면 DB 가 주는 대로라 같은 예산으로 + * 무엇이 채워질지 예측할 수 없다. + * + *

{@code DISTINCT} 가 아니라 콘텐츠 id 로 묶는다 — 같은 콘텐츠가 타입이 다른 슬롯 둘에 실리면 + * {@code DISTINCT} 는 두 줄을 주는데, {@code poi_intro} 는 콘텐츠당 한 행이라 예산만 두 번 쓴다. + */ @Override - public List findMissing(int limit) { + public List findMissing(int limit, LocalDateTime emptyRetryBefore) { return jdbcTemplate.query(""" - SELECT DISTINCT s.poi_content_id, s.poi_content_type_id + SELECT s.poi_content_id, + MAX(s.poi_content_type_id) AS content_type_id, + (MAX(p.content_id) IS NULL) AS never_fetched, + MAX(s.id) AS newest_slot_id FROM slot s LEFT JOIN poi_intro p ON p.content_id = s.poi_content_id - WHERE p.content_id IS NULL - AND s.poi_content_type_id IS NOT NULL + WHERE s.poi_content_type_id IS NOT NULL + AND (p.content_id IS NULL + OR (p.use_time IS NULL AND p.rest_date IS NULL AND p.fetched_at < ?)) + GROUP BY s.poi_content_id + ORDER BY never_fetched DESC, newest_slot_id DESC LIMIT ? - """, (rs, rowNum) -> new ContentRef(rs.getString(1), rs.getInt(2)), limit); + """, (rs, rowNum) -> new ContentRef(rs.getString(1), rs.getInt(2)), emptyRetryBefore, limit); } @Override diff --git a/src/main/java/com/offway/core/trip/service/PoiIntroRefreshService.java b/src/main/java/com/offway/core/trip/service/PoiIntroRefreshService.java index 578b6431..9b791e75 100644 --- a/src/main/java/com/offway/core/trip/service/PoiIntroRefreshService.java +++ b/src/main/java/com/offway/core/trip/service/PoiIntroRefreshService.java @@ -54,6 +54,18 @@ public class PoiIntroRefreshService { private static final Duration MIN_INTERVAL = Duration.ofDays(1); + /** + * 빈 응답을 다시 물어보기까지 기다리는 기간 — 빈 값은 캐시가 아니라 재시도 대기다. + * + *

"값이 없다" 는 실패와 결과가 같다. 영구 저장으로 굳히면 원본이 나중에 운영시간을 채워도 우리는 영영 + * 모른다. 그렇다고 매 회차 다시 물으면 하루 예산({@value #DAILY_BUDGET})을 빈 콘텐츠가 다 먹고, 새로 + * 생긴 코스의 장소가 차례를 못 받는다. + * + *

7일로 둔 근거 — 원본을 채우는 것은 지자체 정보 갱신이라 일 단위로 바뀌지 않는다. 빈 것 하나가 + * 쓰는 예산이 매일 재시도의 1/7 로 줄고, 원본이 채워지면 늦어도 일주일 안에 화면에 반영된다. + */ + static final Duration EMPTY_RETRY_INTERVAL = Duration.ofDays(7); + private final TourApiClient tourApiClient; private final PoiIntroRepository poiIntroRepository; private final BatchRunRepository batchRunRepository; @@ -83,7 +95,9 @@ public void refreshIfStale() { * 그건 사용자 요청까지 함께 막는다. 배치라 사용자를 기다리게 하지 않으므로 느려도 된다. */ public void refresh() { - List missing = poiIntroRepository.findMissing(DAILY_BUDGET); + LocalDateTime now = LocalDateTime.now(SERVICE_ZONE); + List missing = + poiIntroRepository.findMissing(DAILY_BUDGET, now.minus(EMPTY_RETRY_INTERVAL)); if (missing.isEmpty()) { log.info("장소 운영시간 — 받을 것이 없습니다(저장={}건)", poiIntroRepository.count()); return; @@ -103,7 +117,8 @@ public void refresh() { continue; // 다음 회차에 다시 시도한다 — 저장하지 않으면 여전히 "안 받은 것" 이다 } if (hours == null || hours.isEmpty()) { - // 값이 없다는 것도 사실이다. 안 넣으면 매 회차 같은 콘텐츠를 다시 물어 예산을 태운다. + // 빈 행으로 남긴다 — 매 회차 다시 물으면 예산을 태우기 때문이다. 다만 캐시가 아니라 + // 재시도 대기다: fetched_at 이 EMPTY_RETRY_INTERVAL 을 넘기면 다시 일감이 된다. empty++; fetched.put(ref, new OpeningHours(null, null)); continue; @@ -111,13 +126,15 @@ public void refresh() { fetched.put(ref, hours); } - int saved = poiIntroRepository.upsertAll(fetched, LocalDateTime.now(SERVICE_ZONE)); - if (failed > 0) { - log.warn("장소 운영시간 적재 {}건(대상 {}) — 실패 {}건·값없음 {}건, 저장 누계 {}건", - saved, missing.size(), failed, empty, poiIntroRepository.count()); + int saved = poiIntroRepository.upsertAll(fetched, now); + if (failed > 0 || empty > 0) { + // 빈 응답도 warn 이다. info 로 묻으면 "적재 성공" 처럼 보여, 화면의 운영시간이 왜 비는지 + // 아무도 모른 채 굳는다. + log.warn("장소 운영시간 적재 {}건(대상 {}) — 실패 {}건·값없음 {}건({} 뒤 재시도), 저장 누계 {}건", + saved, missing.size(), failed, empty, EMPTY_RETRY_INTERVAL, poiIntroRepository.count()); return; } - log.info("장소 운영시간 적재 {}건(대상 {}) — 값없음 {}건, 저장 누계 {}건", - saved, missing.size(), empty, poiIntroRepository.count()); + log.info("장소 운영시간 적재 {}건(대상 {}) — 저장 누계 {}건", + saved, missing.size(), poiIntroRepository.count()); } } From 82f1a5964c4ab47d6f1b435a616f9388169e23cd Mon Sep 17 00:00:00 2001 From: sevin98 Date: Thu, 13 Aug 2026 16:31:55 +0900 Subject: [PATCH 14/20] =?UTF-8?q?test:=20=EC=9A=B4=EC=98=81=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=B0=B0=EC=B9=98=EA=B0=80=20=EB=AC=B4=EC=97=87?= =?UTF-8?q?=EC=9D=84=20=EB=82=A8=EA=B8=B0=EB=8A=94=EC=A7=80=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=EB=A1=9C=20=EB=8B=A8=EC=96=B8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 빈 응답·외부 실패 테스트가 미수집 슬롯을 만들지 않아 refresh() 가 빈 목록에서 곧바로 돌아왔다. count() >= 0 과 assertTrue(true) 는 그 상태에서도 통과해, 사실상 아무것도 잠그지 않았다 - 코스에 슬롯을 실제로 저장한 뒤(배치의 일감 목록이 slot 테이블이다) 결과를 단언하게 고쳤다. 빈 응답은 null 운영시간 행이 남는지, 실패는 아무것도 안 남고 다음 회차가 같은 콘텐츠를 다시 집는지 확인한다 - 빈 행이 재시도 기간 뒤 다시 일감이 되고 값이 채워지면 빠지는지를 새 테스트로 잠갔다 - 일감을 확인할 때 하루 예산이 아니라 상한 없는 목록을 본다. 예산으로 자르면 다른 테스트가 남긴 슬롯이 앞자리를 차지했을 때 "일감에 없다" 가 참인지 잘려나간 것인지 구분되지 않는다 - 콘텐츠 id 는 UUID 로 매번 새로 만든다. 통합 테스트가 DB 를 공유해(클래스 @Transactional 미사용) 고정 id 를 쓰면 앞 테스트가 남긴 행이 시나리오를 바꾼다 --- .../PoiIntroRefreshIntegrationTest.java | 91 ++++++++++++++++++- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/offway/core/trip/service/PoiIntroRefreshIntegrationTest.java b/src/test/java/com/offway/core/trip/service/PoiIntroRefreshIntegrationTest.java index ead7a475..b19b7a1e 100644 --- a/src/test/java/com/offway/core/trip/service/PoiIntroRefreshIntegrationTest.java +++ b/src/test/java/com/offway/core/trip/service/PoiIntroRefreshIntegrationTest.java @@ -1,18 +1,31 @@ package com.offway.core.trip.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.offway.core.itinerary.domain.Course; +import com.offway.core.itinerary.domain.DaySchedule; +import com.offway.core.itinerary.domain.Density; +import com.offway.core.itinerary.domain.Slot; +import com.offway.core.itinerary.domain.SlotDisplay; +import com.offway.core.itinerary.domain.SlotKind; +import com.offway.core.itinerary.domain.TimeOfDay; +import com.offway.core.itinerary.repository.CourseRepository; +import com.offway.core.transport.domain.TransportMode; import com.offway.core.trip.domain.OpeningHours; import com.offway.core.trip.infrastructure.tour.StubTourApiClient; import com.offway.core.trip.infrastructure.tour.TourApiClient; import com.offway.core.trip.infrastructure.tour.dto.TourIntro; import com.offway.core.trip.repository.PoiIntroRepository; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -29,12 +42,18 @@ @SpringBootTest class PoiIntroRefreshIntegrationTest { + /** 일감 목록을 자르지 않고 통째로 보기 위한 상한 — 통합 테스트 DB 의 슬롯 수를 넉넉히 넘는다. */ + private static final int WHOLE_WORK_LIST = 10_000; + @Autowired private PoiIntroRefreshService refreshService; @Autowired private PoiIntroRepository poiIntroRepository; + @Autowired + private CourseRepository courseRepository; + @Autowired private StubTourApiClient tourApiClient; @@ -81,25 +100,57 @@ TourApiClient stubTourApiClient() { } @Test - void 값이_비어_와도_기록한다() { + void 값이_비어_와도_기록해_매_회차_다시_묻지_않는다() { // 안 넣으면 매 회차 같은 콘텐츠를 다시 물어 예산을 태운다. "값이 없다" 도 사실이다. - tourApiClient.respondIntro(() -> Optional.of(TourIntro.builder().contentId("x").build())); + String contentId = persistSlotNeedingHours(12); + tourApiClient.respondIntro(() -> Optional.of(TourIntro.builder().contentId(contentId).build())); refreshService.refresh(); - assertTrue(poiIntroRepository.count() >= 0, "예외 없이 지나가야 한다"); + OpeningHours stored = poiIntroRepository.findByContentIds(List.of(contentId)).get(contentId); + assertNotNull(stored, "빈 응답도 행으로 남아야 다음 회차가 같은 것을 다시 묻지 않는다"); + assertNull(stored.useTime()); + assertNull(stored.restDate()); + assertFalse(workListContentIds().contains(contentId), "방금 받은 빈 행은 곧바로 다시 일감이 되지 않는다"); + } + + @Test + void 빈_행은_재시도_기간이_지나면_다시_일감이_된다() { + // 빈 값을 영구 캐시로 굳히면 원본이 나중에 운영시간을 채워도 우리는 영영 모른다. + String contentId = persistSlotNeedingHours(12); + PoiIntroRepository.ContentRef ref = new PoiIntroRepository.ContentRef(contentId, 12); + poiIntroRepository.upsertAll(Map.of(ref, new OpeningHours(null, null)), + LocalDateTime.now().minus(PoiIntroRefreshService.EMPTY_RETRY_INTERVAL).minusDays(1)); + + assertTrue(workListContentIds().contains(contentId), "재시도 기간이 지난 빈 행은 다시 물어야 한다"); + + // 값이 채워지면 그때부터는 다시 묻지 않는다 — 재시도 대상은 어디까지나 "빈 행" 이다. + poiIntroRepository.upsertAll(Map.of(ref, new OpeningHours("09:00~18:00", "연중무휴")), + LocalDateTime.now().minusYears(1)); + + assertFalse(workListContentIds().contains(contentId), "채워진 행은 오래돼도 다시 묻지 않는다"); } @Test void 외부가_실패해도_다음_회차에_다시_시도한다() { // 저장하지 않으면 여전히 "안 받은 것" 으로 남아 다음 회차의 일감이 된다. + String contentId = persistSlotNeedingHours(12); tourApiClient.respondIntro(() -> { throw new IllegalStateException("upstream down"); }); refreshService.refresh(); - assertTrue(true, "실패가 배치를 죽이지 않는다"); + assertTrue(poiIntroRepository.findByContentIds(List.of(contentId)).isEmpty(), + "실패는 아무것도 남기지 않는다 — 남기면 다음 회차의 일감에서 빠진다"); + + tourApiClient.respondIntro(() -> Optional.of( + TourIntro.builder().contentId(contentId).useTime("10:00~17:00").build())); + refreshService.refresh(); + + OpeningHours retried = poiIntroRepository.findByContentIds(List.of(contentId)).get(contentId); + assertNotNull(retried, "다음 회차가 같은 콘텐츠를 다시 집어야 한다"); + assertEquals("10:00~17:00", retried.useTime()); } @Test @@ -112,4 +163,36 @@ TourApiClient stubTourApiClient() { }); refreshService.refreshIfStale(); } + + /** + * 운영시간이 아직 없는 슬롯 하나를 코스에 담아 남기고 그 콘텐츠 id 를 돌려준다. + * + *

배치의 일감 목록은 별도 큐가 아니라 {@code slot} 테이블이다. 슬롯 없이 {@code refresh()} 를 부르면 + * 빈 목록에서 곧바로 돌아와, 무엇을 단언하든 통과한다. + * + *

콘텐츠 id 는 매번 새로 만든다 — 통합 테스트가 DB 를 공유해 고정 id 를 쓰면 앞 테스트가 남긴 행이 + * 시나리오를 바꾼다. + */ + private String persistSlotNeedingHours(int contentTypeId) { + String contentId = "intro-test-" + UUID.randomUUID(); + Slot slot = Slot.of(1, TimeOfDay.MORNING, SlotKind.SIGHT, contentId, contentTypeId, + "운영시간 적재 테스트 장소", 37.5, 127.0, 0, SlotDisplay.none()); + courseRepository.save(Course.of(1L, Density.RELAXED, TransportMode.CAR, + List.of(DaySchedule.of(1, List.of(slot))), LocalDate.now(), 1)); + return contentId; + } + + /** + * 지금 배치가 물어볼 콘텐츠 전부. + * + *

하루 예산이 아니라 상한 없는 목록을 본다 — 예산으로 자르면 다른 테스트가 남긴 슬롯이 앞자리를 + * 차지했을 때 "일감에 없다" 가 참인지 잘려나간 것인지 구분되지 않는다. + */ + private List workListContentIds() { + return poiIntroRepository + .findMissing(WHOLE_WORK_LIST, LocalDateTime.now().minus(PoiIntroRefreshService.EMPTY_RETRY_INTERVAL)) + .stream() + .map(PoiIntroRepository.ContentRef::contentId) + .toList(); + } } From f87349f3b7effb4168faf190ff2035f44c361282 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Thu, 13 Aug 2026 16:55:17 +0900 Subject: [PATCH 15/20] =?UTF-8?q?refactor:=20=EC=A7=80=EC=97=AD=20?= =?UTF-8?q?=EB=8C=80=ED=91=9C=20=EB=B3=BC=EA=B1=B0=EB=A6=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20port=C2=B7adapter=20=EB=A1=9C=20=EB=82=98?= =?UTF-8?q?=EB=88=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RegionLandmarkRepository 를 인터페이스로 두고 JdbcTemplate 구현을 RegionLandmarkRepositoryImpl 로 옮겼다. region 의 RegionIntroProvider 가 trip 의 SQL 구현과 Spring JDBC 에 직접 묶여 있던 것을 계약으로 바꾼다 - 가른 기준은 #249 에서 정한 것과 같다 — 읽는 쪽이 어느 도메인인가. HeritagePoolSourceRepository 처럼 자기 도메인 안에서만 쓰는 JDBC 저장소는 구체 클래스로 두지만, 도메인 경계를 넘는 자리는 SQL 이 아니라 계약에 기댄다 - adapter 를 infrastructure 가 아니라 repository 에 둔다. 이 레포에서 infrastructure 는 외부 API 어댑터 자리이고(CLAUDE.md 도메인·외부 API 소유 표), 영속 어댑터는 레포 전체가 *RepositoryImpl 로 통일돼 있다 - 계약(무엇을 주는가)은 인터페이스에, 구현 선택(종목 순위·질의 한 번)은 Impl 에 나눠 적었다. 호출부는 표기가 그대로라 손대지 않았다 --- .../repository/RegionLandmarkRepository.java | 64 +++-------------- .../RegionLandmarkRepositoryImpl.java | 68 +++++++++++++++++++ 2 files changed, 76 insertions(+), 56 deletions(-) create mode 100644 src/main/java/com/offway/core/trip/repository/RegionLandmarkRepositoryImpl.java diff --git a/src/main/java/com/offway/core/trip/repository/RegionLandmarkRepository.java b/src/main/java/com/offway/core/trip/repository/RegionLandmarkRepository.java index 52415055..ab9362a1 100644 --- a/src/main/java/com/offway/core/trip/repository/RegionLandmarkRepository.java +++ b/src/main/java/com/offway/core/trip/repository/RegionLandmarkRepository.java @@ -1,76 +1,28 @@ package com.offway.core.trip.repository; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Repository; /** - * 지역의 대표 볼거리 이름을 뽑는다(#140) — 지역 한 줄 소개의 재료. + * 지역의 대표 볼거리 이름 조회 port(#140) — 지역 한 줄 소개의 재료. * *

지어내지 않는다. 소개 문구를 사람이 쓰면 89곳 카피를 만들어야 하고, 그건 실재하는 지역에 대한 * 주장이라 틀리면 그대로 사용자에게 나간다. 대신 우리가 이미 가진 사실 — 그 지역에 실제로 있는 국가유산과 * 볼거리 이름 — 을 조합한다. * - *

종목 순서가 곧 대표성이다. 국보·사적·명승은 그 자체가 지역을 대표하는 자리이고, 시도지정은 - * 그보다 좁다. 같은 지역에서 국보가 있으면 그것이 먼저다. + *

port 로 두는 이유는 읽는 쪽이 다른 도메인이기 때문이다. 장소 데이터는 {@code trip} 이 가지고 + * 있지만 소개를 만드는 것은 {@code region} 이다(#249 에서 가른 기준). 도메인 경계를 넘는 자리는 SQL 이 + * 아니라 계약에 기대야 한다. */ -@Repository -@RequiredArgsConstructor -public class RegionLandmarkRepository { +public interface RegionLandmarkRepository { - /** - * 대표성이 높은 순서. 앞에 있을수록 그 지역을 대표한다. - * - *

사적·명승을 보물보다 앞에 둔다 — 보물에는 석탑·불상처럼 건물 안의 작은 것이 많은데, - * 사적·명승은 자리 자체가 목적지다. - */ - private static final List KIND_RANK = List.of( - "국보", "사적", "명승", "천연기념물", "보물", "국가민속문화유산", "국가등록문화유산"); - - private final JdbcTemplate jdbcTemplate; - - /** - * 지역별 대표 볼거리 이름 — 지역당 최대 {@code limit} 개. - * - *

한 번의 질의로 전 지역을 가져온다. 89번 물으면 부팅이 그만큼 느려진다. - */ - public Map> topHeritageNames(int limit) { - String ranked = String.join(",", KIND_RANK.stream().map(kind -> "'" + kind + "'").toList()); - Map> byRegion = new HashMap<>(); - jdbcTemplate.query(""" - SELECT region_id, name FROM heritage_place - WHERE kind IN (%s) - ORDER BY region_id, FIELD(kind, %s), id - """.formatted(ranked, ranked), rs -> { - List names = byRegion.computeIfAbsent(rs.getLong("region_id"), key -> new ArrayList<>()); - if (names.size() < limit) { - names.add(rs.getString("name")); - } - }); - return byRegion; - } + /** 지역별 대표 볼거리 이름 — 지역당 최대 {@code limit} 개. 재료가 없는 지역은 키가 없다. */ + Map> topHeritageNames(int limit); /** * 국가유산이 없는 지역의 대체 — 관광 콘텐츠성이 높은 인허가 볼거리(전통사찰·박물관 등). * *

실제로 한 곳(대구 서구)이 여기 해당한다. 그 지역의 국가유산이 무형유산뿐이라 방문 대상이 없다. */ - public Map> topLicensedSightNames(int limit) { - Map> byRegion = new HashMap<>(); - jdbcTemplate.query(""" - SELECT region_id, name FROM licensed_place - WHERE kind = 'SIGHT' - ORDER BY region_id, fitness_rank, name - """, rs -> { - List names = byRegion.computeIfAbsent(rs.getLong("region_id"), key -> new ArrayList<>()); - if (names.size() < limit) { - names.add(rs.getString("name")); - } - }); - return byRegion; - } + Map> topLicensedSightNames(int limit); } diff --git a/src/main/java/com/offway/core/trip/repository/RegionLandmarkRepositoryImpl.java b/src/main/java/com/offway/core/trip/repository/RegionLandmarkRepositoryImpl.java new file mode 100644 index 00000000..62cc90f7 --- /dev/null +++ b/src/main/java/com/offway/core/trip/repository/RegionLandmarkRepositoryImpl.java @@ -0,0 +1,68 @@ +package com.offway.core.trip.repository; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * {@link RegionLandmarkRepository} 의 SQL 구현. + * + *

엔티티를 두지 않는다 — 뽑는 것이 이름 목록뿐이고 도메인 규칙이 없다. 지역별 상위 N 개는 SQL 이 + * 한 번에 답한다. + * + *

종목 순서가 곧 대표성이다. 국보·사적·명승은 그 자체가 지역을 대표하는 자리이고, 시도지정은 + * 그보다 좁다. 같은 지역에서 국보가 있으면 그것이 먼저다. + */ +@Repository +@RequiredArgsConstructor +public class RegionLandmarkRepositoryImpl implements RegionLandmarkRepository { + + /** + * 대표성이 높은 순서. 앞에 있을수록 그 지역을 대표한다. + * + *

사적·명승을 보물보다 앞에 둔다 — 보물에는 석탑·불상처럼 건물 안의 작은 것이 많은데, + * 사적·명승은 자리 자체가 목적지다. + */ + private static final List KIND_RANK = List.of( + "국보", "사적", "명승", "천연기념물", "보물", "국가민속문화유산", "국가등록문화유산"); + + private final JdbcTemplate jdbcTemplate; + + /** 한 번의 질의로 전 지역을 가져온다. 89번 물으면 부팅이 그만큼 느려진다. */ + @Override + public Map> topHeritageNames(int limit) { + String ranked = String.join(",", KIND_RANK.stream().map(kind -> "'" + kind + "'").toList()); + Map> byRegion = new HashMap<>(); + jdbcTemplate.query(""" + SELECT region_id, name FROM heritage_place + WHERE kind IN (%s) + ORDER BY region_id, FIELD(kind, %s), id + """.formatted(ranked, ranked), rs -> { + List names = byRegion.computeIfAbsent(rs.getLong("region_id"), key -> new ArrayList<>()); + if (names.size() < limit) { + names.add(rs.getString("name")); + } + }); + return byRegion; + } + + @Override + public Map> topLicensedSightNames(int limit) { + Map> byRegion = new HashMap<>(); + jdbcTemplate.query(""" + SELECT region_id, name FROM licensed_place + WHERE kind = 'SIGHT' + ORDER BY region_id, fitness_rank, name + """, rs -> { + List names = byRegion.computeIfAbsent(rs.getLong("region_id"), key -> new ArrayList<>()); + if (names.size() < limit) { + names.add(rs.getString("name")); + } + }); + return byRegion; + } +} From 02f6ebd9f113d7bb58fcceca099f077bb85c9ef4 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Thu, 13 Aug 2026 16:55:34 +0900 Subject: [PATCH 16/20] =?UTF-8?q?refactor:=20=ED=98=9C=ED=83=9D=EC=9D=B4?= =?UTF-8?q?=20=EB=B6=99=EB=8A=94=20=EC=9E=90=EB=A6=AC=EB=A5=BC=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=C2=B7=EC=BD=94=EC=8A=A4=EA=B0=80=20=EB=82=98=EB=88=A0?= =?UTF-8?q?=20=EC=95=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PolicyType 이 들고 있던 SlotKind(itinerary) 를 policy 소유의 BenefitScope 로 바꿨다. 정책이 코스 슬롯을 알면 policy → itinerary 의존이 생기는데, 코스는 이미 정책을 참조하므로(GeneratedCourse.Benefit·CourseResponse) 두 도메인이 서로를 가리키는 순환이 된다 - 대응(LODGING → STAY)은 SlotKind.covering 이 소유한다. "숙박세일페스타를 숙소에서 쓴다" 는 프로그램의 성질이라 정책이 알지만, 그게 코스의 어느 자리인지는 코스가 안다. CourseResponse 에 두지 않은 것은 다른 호출부(장소 상세)도 같은 대응이 필요하기 때문 — DTO 에 있으면 재사용할 수 없다 - switch 가 BenefitScope 상수를 전부 덮으므로 새 대상이 생기면 코스 쪽에서 컴파일이 깨진다. 자리를 정하지 않은 채로 넘어가지 않는다 - 상수 이름을 STAY 로 맞추지 않고 LODGING 으로 뒀다. 이름이 같으면 나중에 valueOf(scope.name()) 로 "간단히" 만들 여지가 생겨 경계가 도로 사라진다 - policy → region(RegionTagType) 의존은 그대로 뒀다. region 은 policy 를 참조하지 않아 순환이 아니고, 정책 매칭은 본래 지역 태그 위에서 도는 규칙이다 --- .../controller/dto/CourseResponse.java | 4 +-- .../core/itinerary/domain/SlotKind.java | 19 ++++++++++++++ .../core/policy/domain/BenefitScope.java | 17 ++++++++++++ .../offway/core/policy/domain/PolicyType.java | 15 +++++------ .../domain/SlotKindBenefitScopeTest.java | 26 +++++++++++++++++++ ...t.java => PolicyTypeBenefitScopeTest.java} | 15 +++++------ 6 files changed, 78 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/offway/core/policy/domain/BenefitScope.java create mode 100644 src/test/java/com/offway/core/itinerary/domain/SlotKindBenefitScopeTest.java rename src/test/java/com/offway/core/policy/domain/{PolicyTypeSlotTargetTest.java => PolicyTypeBenefitScopeTest.java} (66%) diff --git a/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java b/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java index 144d24bc..effe2797 100644 --- a/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java +++ b/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java @@ -379,8 +379,8 @@ static TrainAccessResponse from(TrainAccess access) { */ private static Map slotBenefits(GeneratedCourse generated) { Map byKind = new java.util.EnumMap<>(SlotKind.class); - generated.benefits().forEach(benefit -> - benefit.type().targetSlotKind().ifPresent(kind -> byKind.putIfAbsent(kind, benefit.text()))); + generated.benefits().forEach(benefit -> benefit.type().targetScope() + .ifPresent(scope -> byKind.putIfAbsent(SlotKind.covering(scope), benefit.text()))); return byKind; } diff --git a/src/main/java/com/offway/core/itinerary/domain/SlotKind.java b/src/main/java/com/offway/core/itinerary/domain/SlotKind.java index 26ba0734..93b51b7a 100644 --- a/src/main/java/com/offway/core/itinerary/domain/SlotKind.java +++ b/src/main/java/com/offway/core/itinerary/domain/SlotKind.java @@ -1,5 +1,7 @@ package com.offway.core.itinerary.domain; +import com.offway.core.policy.domain.BenefitScope; + /** * 코스 슬롯이 담는 장소 종류(course-logic ①의 볼거리풀·맛집풀·숙박풀). 관광/맛집이 번갈아 배치되고, 숙박은 멀티데이의 하루 끝에 온다. */ @@ -24,4 +26,21 @@ public enum SlotKind { public String label() { return label; } + + /** + * 그 혜택이 붙는 슬롯 — 정책이 말하는 대상을 코스의 언어로 옮긴다(#140). + * + *

대응을 코스 쪽이 소유한다. "숙박세일페스타는 숙소에서 쓴다" 는 프로그램의 성질이라 정책이 + * 알지만, 그게 코스의 어느 자리인지는 코스가 안다. 정책이 {@link SlotKind} 를 들면 + * {@code policy → itinerary} 의존이 생기는데, 코스는 이미 정책을 참조하므로 두 도메인이 서로를 + * 가리키게 된다. + * + *

{@code switch} 가 모든 상수를 덮으므로 {@link BenefitScope} 에 새 대상이 생기면 여기서 + * 컴파일이 깨진다 — 코스가 자리를 정하지 않은 채로 넘어가지 않는다. + */ + public static SlotKind covering(BenefitScope scope) { + return switch (scope) { + case LODGING -> STAY; + }; + } } diff --git a/src/main/java/com/offway/core/policy/domain/BenefitScope.java b/src/main/java/com/offway/core/policy/domain/BenefitScope.java new file mode 100644 index 00000000..f93a3c9c --- /dev/null +++ b/src/main/java/com/offway/core/policy/domain/BenefitScope.java @@ -0,0 +1,17 @@ +package com.offway.core.policy.domain; + +/** + * 혜택을 어디서 쓰는가 — 정책 도메인이 아는 만큼의 대상 분류(#140). + * + *

코스의 슬롯 종류가 아니라 프로그램의 성질이다. 숙박세일페스타가 숙소에서 쓰는 혜택이라는 건 + * 프로그램 약관이 정하는 사실이고, 그게 코스의 어느 자리에 붙는지는 코스가 정한다. 정책이 슬롯을 알면 + * {@code policy → itinerary} 역방향 의존이 생겨 두 도메인이 서로를 참조하게 된다. + * + *

단정할 수 있는 것만 넣는다. 지금은 하나뿐이다 — 나머지 혜택은 가맹점·제휴처 목록이 있어야 + * 쓸 수 있는 자리를 알 수 있는데 우리에겐 그 데이터가 없다. + */ +public enum BenefitScope { + + /** 숙소 — 숙박세일페스타처럼 잠자리 값에 붙는 혜택. */ + LODGING +} diff --git a/src/main/java/com/offway/core/policy/domain/PolicyType.java b/src/main/java/com/offway/core/policy/domain/PolicyType.java index 46ceae44..f4c8a608 100644 --- a/src/main/java/com/offway/core/policy/domain/PolicyType.java +++ b/src/main/java/com/offway/core/policy/domain/PolicyType.java @@ -1,6 +1,5 @@ package com.offway.core.policy.domain; -import com.offway.core.itinerary.domain.SlotKind; import com.offway.core.region.domain.RegionTagType; import java.util.Optional; @@ -28,7 +27,7 @@ public enum PolicyType { REGIONAL_VOUCHER("여행경비 50% 환급", RegionTagType.REGIONAL_VOUCHER, null), /** 숙박세일페스타 — 숙박 할인. 비수도권 인구감소지역 85곳. */ - STAY_FESTA("숙박 할인", RegionTagType.STAY_FESTA, SlotKind.STAY), + STAY_FESTA("숙박 할인", RegionTagType.STAY_FESTA, BenefitScope.LODGING), /** 근로자 휴가지원 — 휴가비 지원. */ WORKER_VACATION("근로자 휴가비 지원", RegionTagType.POPULATION_DECLINE, null), @@ -44,12 +43,12 @@ public enum PolicyType { private final String badgeText; private final RegionTagType targetTag; - private final SlotKind targetSlotKind; + private final BenefitScope targetScope; - PolicyType(String badgeText, RegionTagType targetTag, SlotKind targetSlotKind) { + PolicyType(String badgeText, RegionTagType targetTag, BenefitScope targetScope) { this.badgeText = badgeText; this.targetTag = targetTag; - this.targetSlotKind = targetSlotKind; + this.targetScope = targetScope; } /** 지역 카드·코스에 노출하는 짧은 뱃지 문구. */ @@ -63,7 +62,7 @@ public RegionTagType targetTag() { } /** - * 이 혜택이 붙는 슬롯 종류 — 단정할 수 있을 때만 있다(#140). + * 이 혜택을 쓸 수 있는 자리 — 단정할 수 있을 때만 있다(#140). * *

대부분은 비어 있다. 프로그램 약관을 봐야 "이 장소에서 쓸 수 있나" 를 알 수 있는데, 우리에겐 그 * 데이터가 없다. 예를 들어 지자체 바우처는 가맹점 목록이 있어야 하고, 디지털관광주민증은 @@ -72,7 +71,7 @@ public RegionTagType targetTag() { * *

지역 단위 혜택은 그대로 코스 {@code benefits} 로 나간다. 여기서 비어 있다고 혜택이 없는 게 아니다. */ - public Optional targetSlotKind() { - return Optional.ofNullable(targetSlotKind); + public Optional targetScope() { + return Optional.ofNullable(targetScope); } } diff --git a/src/test/java/com/offway/core/itinerary/domain/SlotKindBenefitScopeTest.java b/src/test/java/com/offway/core/itinerary/domain/SlotKindBenefitScopeTest.java new file mode 100644 index 00000000..f5d68238 --- /dev/null +++ b/src/test/java/com/offway/core/itinerary/domain/SlotKindBenefitScopeTest.java @@ -0,0 +1,26 @@ +package com.offway.core.itinerary.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.offway.core.policy.domain.BenefitScope; +import com.offway.core.policy.domain.PolicyType; +import org.junit.jupiter.api.Test; + +/** + * 혜택이 코스의 어느 자리에 붙는가(#140) — 정책의 대상 분류를 슬롯 종류로 옮기는 대응. + * + *

정책 도메인은 슬롯을 모른다. 대응이 여기 있어야 두 도메인이 서로를 참조하지 않는다. + */ +class SlotKindBenefitScopeTest { + + @Test + void 숙소_혜택은_숙박_슬롯에_붙는다() { + assertEquals(SlotKind.STAY, SlotKind.covering(BenefitScope.LODGING)); + } + + @Test + void 숙박세일페스타는_숙박_슬롯까지_이어진다() { + // 두 도메인을 갈라 놓아도 사용자가 보는 사실(숙소 카드에 "숙박 할인")은 그대로여야 한다. + assertEquals(SlotKind.STAY, SlotKind.covering(PolicyType.STAY_FESTA.targetScope().orElseThrow())); + } +} diff --git a/src/test/java/com/offway/core/policy/domain/PolicyTypeSlotTargetTest.java b/src/test/java/com/offway/core/policy/domain/PolicyTypeBenefitScopeTest.java similarity index 66% rename from src/test/java/com/offway/core/policy/domain/PolicyTypeSlotTargetTest.java rename to src/test/java/com/offway/core/policy/domain/PolicyTypeBenefitScopeTest.java index d2558813..fdfb4c34 100644 --- a/src/test/java/com/offway/core/policy/domain/PolicyTypeSlotTargetTest.java +++ b/src/test/java/com/offway/core/policy/domain/PolicyTypeBenefitScopeTest.java @@ -3,7 +3,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.offway.core.itinerary.domain.SlotKind; import java.util.Arrays; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -11,27 +10,27 @@ import org.junit.jupiter.params.provider.EnumSource; /** - * 혜택이 어느 슬롯에 붙는가(#140) — 단정할 수 있는 것만. + * 혜택을 어디서 쓰는가(#140) — 단정할 수 있는 것만. * *

근거 없이 배지를 붙이면 사용자가 못 받는 할인을 기대하고 간다. 안 붙이는 것보다 나쁘다. */ -class PolicyTypeSlotTargetTest { +class PolicyTypeBenefitScopeTest { @Test - void 숙박세일페스타는_숙소에_붙는다() { + void 숙박세일페스타는_숙소에서_쓴다() { // 프로그램 이름이 곧 대상이라 단정할 수 있다. 89곳 중 85곳에 걸려 있어 대부분의 코스에 뜬다. - assertEquals(Optional.of(SlotKind.STAY), PolicyType.STAY_FESTA.targetSlotKind()); + assertEquals(Optional.of(BenefitScope.LODGING), PolicyType.STAY_FESTA.targetScope()); } @ParameterizedTest @EnumSource(value = PolicyType.class, names = "STAY_FESTA", mode = EnumSource.Mode.EXCLUDE) - void 나머지는_슬롯을_단정하지_않는다(PolicyType type) { + void 나머지는_쓸_자리를_단정하지_않는다(PolicyType type) { // 지자체 바우처는 가맹점 목록이, 디지털관광주민증은 제휴처 목록이 있어야 한다. 우리에겐 없다. - assertTrue(type.targetSlotKind().isEmpty(), type + " 가 근거 없이 슬롯을 단정한다"); + assertTrue(type.targetScope().isEmpty(), type + " 가 근거 없이 쓸 자리를 단정한다"); } @Test - void 슬롯을_단정하지_않아도_지역_혜택은_그대로다() { + void 쓸_자리를_단정하지_않아도_지역_혜택은_그대로다() { // 여기서 비어 있다고 혜택이 없는 게 아니다 — 코스 benefits 로는 계속 나간다. assertTrue(Arrays.stream(PolicyType.values()).allMatch(type -> type.targetTag() != null)); } From 14b25baeb721679b83e32b5da525b678c506298f Mon Sep 17 00:00:00 2001 From: sevin98 Date: Thu, 13 Aug 2026 16:55:45 +0900 Subject: [PATCH 17/20] =?UTF-8?q?docs:=20=EC=A7=80=EC=97=AD=20=EC=86=8C?= =?UTF-8?q?=EA=B0=9C=20OpenAPI=20=EC=98=88=EC=8B=9C=EB=A5=BC=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EC=B6=9C=EB=A0=A5=EA=B3=BC=20=EB=A7=9E=EC=B6=98?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - intro 예시가 `탑리리 오층석탑와(과) ... 가운루가(이)` 였다. 조사를 받침으로 계산하도록 RegionIntro 를 고치기 전에 적어 둔 문구가 남아 있던 것 - RegionIntro 는 종성을 보고 하나만 고른다 — 오층석탑(받침 O) 뒤에 `과`, 가운루(받침 X) 뒤에 `가`. RegionIntroTest 가 잠그고 있는 형태와 같게 맞췄다 - 나머지 새 예시(useTime·restDate·openingStatus·benefit)도 함께 확인했다. CLOSED_TODAY 는 OpeningStatus 상수이고 "숙박 할인" 은 STAY_FESTA 뱃지 문구와 같아 손댈 것이 없었다 --- .../core/trip/controller/dto/RegionRecommendResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java b/src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java index 89ad99f0..1b071df6 100644 --- a/src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java +++ b/src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java @@ -51,7 +51,7 @@ public record Item( 감성 카피가 아니라 사실이다 — 지역 소개를 주는 외부 출처가 없어, 지어내는 대신 우리가 가진 것(국가유산·볼거리)의 이름을 조합한다. 재료가 없으면 필드가 없다.""", - example = "탑리리 오층석탑와(과) 고운사 가운루가(이) 있는 곳", nullable = true) String intro, + example = "탑리리 오층석탑과 고운사 가운루가 있는 곳", nullable = true) String intro, List benefits) { static Item from(RecommendedRegion region) { From 84950a3d51b18610a4062b11da3f889642bdda50 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Thu, 13 Aug 2026 16:57:11 +0900 Subject: [PATCH 18/20] =?UTF-8?q?perf:=20=EC=9A=B4=EC=98=81=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=B0=B0=EC=B9=98=EB=8F=84=20=EB=A1=9C=EC=BB=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8A=94=20=EC=A0=81=EA=B2=8C=20=EC=93=B4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #255 가 다른 배치에 세운 가드가 이 배치에는 없었다. 로컬과 운영이 같은 data.go.kr 키를 쓰는데 배치 건너뛰기는 자기 DB 안에서만 중복을 막아, 그대로 두면 두 곳이 각자 하루치를 태운다(#254). - 새로 만든 배치라 dev 에 있던 그 가드를 못 받았다. 머지하면서 드러났다. - offway.batch.regions-per-run 이 설정돼 있으면 그만큼으로 줄인다. 이 배치는 지역이 아니라 콘텐츠 단위로 도는데, 값의 뜻은 "로컬 한 회차 상한" 이라 그대로 쓴다. --- .../core/trip/service/PoiIntroRefreshService.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/offway/core/trip/service/PoiIntroRefreshService.java b/src/main/java/com/offway/core/trip/service/PoiIntroRefreshService.java index 9b791e75..0472dd89 100644 --- a/src/main/java/com/offway/core/trip/service/PoiIntroRefreshService.java +++ b/src/main/java/com/offway/core/trip/service/PoiIntroRefreshService.java @@ -1,6 +1,7 @@ package com.offway.core.trip.service; import com.offway.core.common.batch.repository.BatchRunRepository; +import com.offway.core.common.config.BatchBudgetProperties; import com.offway.core.trip.domain.OpeningHours; import com.offway.core.trip.infrastructure.tour.TourApiClient; import com.offway.core.trip.repository.PoiIntroRepository; @@ -46,6 +47,10 @@ public class PoiIntroRefreshService { * *

나머지는 사용자 요청(코스 생성 1건당 3회)과 장소 상세가 쓴다. 이 배치가 한도를 다 먹으면 * 정작 코스가 안 나온다 — 채우려던 값 때문에 채울 대상이 사라지는 셈이다. + * + *

로컬은 이보다 적게 쓴다. 로컬과 운영이 같은 키를 쓰는데 배치 건너뛰기는 자기 DB 안에서만 + * 중복을 막아, 그대로 두면 두 곳이 각자 하루치를 태운다(#254). {@code offway.batch.regions-per-run} 이 + * 설정돼 있으면 그만큼으로 줄인다. */ private static final int DAILY_BUDGET = 300; @@ -69,6 +74,7 @@ public class PoiIntroRefreshService { private final TourApiClient tourApiClient; private final PoiIntroRepository poiIntroRepository; private final BatchRunRepository batchRunRepository; + private final BatchBudgetProperties batchBudget; /** * 하루 한 번 — 그날 이미 돌았으면 외부를 아예 안 부른다. @@ -96,8 +102,10 @@ public void refreshIfStale() { */ public void refresh() { LocalDateTime now = LocalDateTime.now(SERVICE_ZONE); + // 로컬은 한 회차에 몇 건만 채운다(#254) — 자세한 이유는 BatchBudgetProperties. + int budget = batchBudget.limits(DAILY_BUDGET) ? batchBudget.regionsPerRun() : DAILY_BUDGET; List missing = - poiIntroRepository.findMissing(DAILY_BUDGET, now.minus(EMPTY_RETRY_INTERVAL)); + poiIntroRepository.findMissing(budget, now.minus(EMPTY_RETRY_INTERVAL)); if (missing.isEmpty()) { log.info("장소 운영시간 — 받을 것이 없습니다(저장={}건)", poiIntroRepository.count()); return; From 7a5a2cd04af62f1aeb77de4e8b7581329e36e9e3 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Thu, 13 Aug 2026 22:21:30 +0900 Subject: [PATCH 19/20] =?UTF-8?q?fix:=20=EB=AA=BB=20=EC=9D=BD=EC=9D=80=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EC=9D=B4=20=EB=82=A8=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EC=83=81=ED=83=9C=EB=A5=BC=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `연중무휴`·`상시개방` 을 find() 로 찾아 바로 확정하던 것이 문제였다. 실제로 `연중무휴 (단, 설·추석 당일 휴무)` 가 설 당일에 OPEN 이 됐고, `매주 월요일, 1월 1일, 설·추석 당일` 은 화요일에 OPEN, `매주 월요일(공휴일인 경우 다음 날 휴무)` 도 확정으로 나갔다. 테스트로 8건 재현했다 - `SINGLE_RANGE` 의 `^\D{0,4}...\D{0,4}$` 도 같은 원인이었다. `하절기 09:00~18:00` `동절기 09:00~17:00` 이 계절 한정인데 단일 범위로 읽혀 확정됐다. 범위가 둘 이상인 경우도 이제 UNKNOWN 이다 - **알아본 조각을 지우고 남은 글자가 없을 때만 확정한다**로 규칙을 바꿨다. 위험한 문구를 골라 막으면(blocklist) 처음 보는 표현이 통과해 틀린 단정이 되지만, 남기는 쪽을 나열하면(REST_OF_IT) 빠진 표현이 UNKNOWN 이 될 뿐이다 — 틀린 단정이 침묵보다 나쁘다는 이 기능의 원칙과 같은 방향이다 - 그 대가로 `매주 월요일 휴관` 같은 정상 형식이 UNKNOWN 이 되지 않도록, 뜻을 안 바꾸는 수식어·조사만 REST_OF_IT 에 열거했다. `단,` 은 공휴일 예외 패턴이 함께 먹게 했다 — 남겨 두면 예외 조항을 알아본 보람이 사라진다 - 개점 전을 CLOSED_NOW("오늘 운영이 끝났어요") 로 말하던 것을 BEFORE_OPEN 으로 갈랐다. 09시에 여는 곳을 08:59 에 보고 "운영이 끝났어요" 라고 하면 갈 수 있는 곳을 안 가게 만든다. UNKNOWN 으로 두지 않은 이유는 여는 시각을 읽어냈기 때문이다 — 모르는 게 아니라 알고도 안 말하는 것이 된다. 문구는 "아직 문을 열기 전이에요" - 쓰이지 않던 DAYS 상수를 지웠다 --- .../controller/dto/CourseResponse.java | 3 +- .../offway/core/trip/domain/OpeningHours.java | 95 +++++++++++++++---- .../core/trip/domain/OpeningStatus.java | 12 +++ .../core/trip/domain/OpeningHoursTest.java | 59 +++++++++++- 4 files changed, 148 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java b/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java index 3eb27680..7b3b686a 100644 --- a/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java +++ b/src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java @@ -252,7 +252,8 @@ public record Item( @Schema(description = """ 오늘 이 장소가 여는지 — **여행일이 오늘일 때만** 실린다. 판정할 수 없으면 필드 자체가 없다. - `OPEN` 영업 중 · `CLOSED_TODAY` 오늘은 휴무일이에요 · `CLOSED_NOW` 오늘 운영이 끝났어요""", + `OPEN` 영업 중 · `CLOSED_TODAY` 오늘은 휴무일이에요 · + `BEFORE_OPEN` 아직 문을 열기 전이에요 · `CLOSED_NOW` 오늘 운영이 끝났어요""", example = "CLOSED_TODAY", nullable = true) String openingStatus, double lat, double lng, diff --git a/src/main/java/com/offway/core/trip/domain/OpeningHours.java b/src/main/java/com/offway/core/trip/domain/OpeningHours.java index 20ce1d2b..4b7364e5 100644 --- a/src/main/java/com/offway/core/trip/domain/OpeningHours.java +++ b/src/main/java/com/offway/core/trip/domain/OpeningHours.java @@ -25,7 +25,7 @@ public record OpeningHours(String useTime, String restDate) { /** {@code 09:00~18:00} 단일 범위. 물결·하이픈·틸드를 모두 받는다. */ private static final Pattern SINGLE_RANGE = - Pattern.compile("^\\D{0,4}(\\d{1,2}):(\\d{2})\\s*[~\\-–—]\\s*(\\d{1,2}):(\\d{2})\\D{0,4}$"); + Pattern.compile("(\\d{1,2}):(\\d{2})\\s*[~\\-–—]\\s*(\\d{1,2}):(\\d{2})"); /** {@code 연중무휴}·{@code 연중 무휴} — 휴무 없음. */ private static final Pattern NO_CLOSING = Pattern.compile("연\\s*중\\s*무\\s*휴"); @@ -37,13 +37,31 @@ public record OpeningHours(String useTime, String restDate) { * {@code (단, 공휴일 및 대체공휴일은 정상운영)} — 예외 조항. * *

이걸 무시하면 공휴일 월요일에 "오늘 휴무" 라고 잘못 말한다. 사용자가 갈 수 있는 곳을 안 가게 만든다. + * + *

{@code 단,} 을 함께 먹는다 — 남겨 두면 {@link #REST_OF_IT} 가 "못 읽은 조건" 으로 세어 예외 조항을 + * 알아본 보람이 사라진다. */ - private static final Pattern HOLIDAY_EXCEPTION = Pattern.compile("공휴일.{0,20}정상\\s*운영"); + private static final Pattern HOLIDAY_EXCEPTION = + Pattern.compile("(?:단\\s*[,、]?\\s*)?공휴일.{0,20}정상\\s*운영"); /** 여러 시설이 한 필드에 들어온 형식({@code [우금치전적] 상시개방 / [알림터] 09:00~}) — 판정하지 않는다. */ private static final Pattern MULTI_FACILITY = Pattern.compile("\\[.+\\].*/.*\\[.+\\]"); - private static final Set DAYS = EnumSet.allOf(DayOfWeek.class); + /** + * 읽어도 뜻이 안 바뀌는 것 — 공백·구두점과, 판정을 흔들지 않는 수식어·조사뿐이다. + * + *

알아본 조각을 지운 뒤 이것까지 지워서 아무것도 안 남아야 확정한다({@link #understood}). + * 남은 글자는 곧 우리가 못 읽은 조건이다 — {@code 동절기 제외}·{@code 설·추석 당일 휴무}· + * {@code 공휴일인 경우 다음 날 휴무} 가 그렇게 걸린다. + * + *

지우는 쪽이 아니라 남기는 쪽을 나열한 이유. 위험한 문구를 골라 막으면(blocklist) 처음 보는 + * 표현이 그대로 통과해 틀린 단정이 된다. 반대로 여기 빠진 표현은 {@code UNKNOWN} 이 될 뿐이라 + * 화면이 침묵한다 — 실측으로 형식을 더 볼 때마다 이 목록을 늘리면 된다. + */ + private static final Pattern REST_OF_IT = Pattern.compile( + "[\\s\\p{Punct}~–—·ㆍ、]" + + "|매일|연중|상시|개방|운영|영업|관람|이용|입장|시간" + + "|부터|까지|단|정기|휴무|휴관|휴장|휴원|휴점"); /** 둘 다 없으면 실을 이유가 없다 — 빈 값을 내리면 화면이 빈 줄을 그린다. */ public boolean isEmpty() { @@ -73,23 +91,28 @@ public OpeningStatus statusAt(java.time.LocalDateTime now, boolean isHoliday) { /** * 휴무 판정 — 오늘 쉬면 {@link OpeningStatus#CLOSED_TODAY}, 안 쉬는 게 확실하면 {@link OpeningStatus#OPEN}, * 모르면 {@link OpeningStatus#UNKNOWN}. + * + *

알아본 조각을 지우고 아무것도 안 남을 때만 확정한다. {@code 연중무휴} 만 찾아 확정하면 + * {@code 연중무휴 (단, 설·추석 당일 휴무)} 가 설 당일에 "영업 중" 이 된다 — 헛걸음이다. */ private OpeningStatus closedToday(LocalDate today, boolean isHoliday) { if (isBlank(restDate) || MULTI_FACILITY.matcher(restDate).find()) { return OpeningStatus.UNKNOWN; } - if (NO_CLOSING.matcher(restDate).find()) { - return OpeningStatus.OPEN; + Set closedDays = weeklyClosedDays(); + boolean noClosing = NO_CLOSING.matcher(restDate).find(); + // `1월 1일 / 설·추석 당일` 같은 특정일도, 우리가 모르는 예외 조항도 전부 여기서 걸린다. + String rest = erase(erase(erase(restDate, NO_CLOSING), WEEKLY_CLOSED), HOLIDAY_EXCEPTION); + if (!understood(rest)) { + return OpeningStatus.UNKNOWN; } - Matcher weekly = WEEKLY_CLOSED.matcher(restDate); - if (!weekly.find()) { - return OpeningStatus.UNKNOWN; // `1월 1일 / 설·추석 당일` 같은 특정일은 다루지 않는다 + if (noClosing) { + // `연중무휴` 와 `매주 월요일` 이 함께 오면 서로 어긋난다 — 둘 중 어느 쪽인지 우리가 모른다. + return closedDays.isEmpty() ? OpeningStatus.OPEN : OpeningStatus.UNKNOWN; + } + if (closedDays.isEmpty()) { + return OpeningStatus.UNKNOWN; // 알아본 것이 하나도 없다 — 지워서 빈 것과 읽어서 빈 것은 다르다 } - Set closedDays = EnumSet.noneOf(DayOfWeek.class); - do { - closedDays.add(dayOf(weekly.group(1))); - } while (weekly.find()); - if (!closedDays.contains(today.getDayOfWeek())) { return OpeningStatus.OPEN; } @@ -98,20 +121,41 @@ private OpeningStatus closedToday(LocalDate today, boolean isHoliday) { return opensOnHoliday ? OpeningStatus.OPEN : OpeningStatus.CLOSED_TODAY; } - /** 시각 판정 — 상시개방이면 항상 열림, 단일 범위면 비교, 그 밖은 모른다. */ + /** {@code 매주 월요일·목요일} 처럼 여럿일 수 있다. */ + private Set weeklyClosedDays() { + Set closedDays = EnumSet.noneOf(DayOfWeek.class); + Matcher weekly = WEEKLY_CLOSED.matcher(restDate); + while (weekly.find()) { + closedDays.add(dayOf(weekly.group(1))); + } + return closedDays; + } + + /** + * 시각 판정 — 상시개방이면 항상 열림, 단일 범위면 비교, 그 밖은 모른다. + * + *

여기서도 알아본 조각을 지운 나머지가 비어야 확정한다. {@code 하절기 09:00~18:00} 은 범위가 + * 온전히 읽히지만 그 시간은 여름에만 유효하다 — 겨울에 그대로 쓰면 틀린다. + */ private OpeningStatus openNow(LocalTime now) { if (isBlank(useTime) || MULTI_FACILITY.matcher(useTime).find()) { return OpeningStatus.UNKNOWN; } if (ALWAYS_OPEN.matcher(useTime).find()) { - return OpeningStatus.OPEN; + return understood(erase(useTime, ALWAYS_OPEN)) ? OpeningStatus.OPEN : OpeningStatus.UNKNOWN; } - Matcher range = SINGLE_RANGE.matcher(useTime.trim()); - if (!range.matches()) { - return OpeningStatus.UNKNOWN; // 계절별·복수 범위는 억지로 파싱하지 않는다 + Matcher range = SINGLE_RANGE.matcher(useTime); + if (!range.find()) { + return OpeningStatus.UNKNOWN; } LocalTime open = time(range.group(1), range.group(2)); LocalTime close = time(range.group(3), range.group(4)); + if (range.find()) { + return OpeningStatus.UNKNOWN; // 계절별·오전오후 분리처럼 범위가 둘 이상이면 어느 쪽인지 모른다 + } + if (!understood(erase(useTime, SINGLE_RANGE))) { + return OpeningStatus.UNKNOWN; + } if (open == null || close == null) { return OpeningStatus.UNKNOWN; } @@ -119,7 +163,20 @@ private OpeningStatus openNow(LocalTime now) { if (!close.isAfter(open)) { return OpeningStatus.UNKNOWN; } - return now.isBefore(open) || !now.isBefore(close) ? OpeningStatus.CLOSED_NOW : OpeningStatus.OPEN; + if (now.isBefore(open)) { + return OpeningStatus.BEFORE_OPEN; + } + return now.isBefore(close) ? OpeningStatus.OPEN : OpeningStatus.CLOSED_NOW; + } + + /** 알아본 조각을 지운다 — 자리는 공백으로 남겨 앞뒤 글자가 붙어 새 단어가 되지 않게 한다. */ + private static String erase(String text, Pattern understood) { + return understood.matcher(text).replaceAll(" "); + } + + /** 남은 글자가 {@link #REST_OF_IT} 뿐인가 — 아니면 우리가 못 읽은 조건이 있다는 뜻이다. */ + private static boolean understood(String rest) { + return REST_OF_IT.matcher(rest).replaceAll("").isEmpty(); } private static LocalTime time(String hour, String minute) { diff --git a/src/main/java/com/offway/core/trip/domain/OpeningStatus.java b/src/main/java/com/offway/core/trip/domain/OpeningStatus.java index ac81c57b..e238e478 100644 --- a/src/main/java/com/offway/core/trip/domain/OpeningStatus.java +++ b/src/main/java/com/offway/core/trip/domain/OpeningStatus.java @@ -21,6 +21,18 @@ public enum OpeningStatus { /** 오늘이 휴무일이다. */ CLOSED_TODAY("오늘은 휴무일이에요"), + /** + * 아직 개점 전이다 — 오늘 열긴 하는데 지금 가면 이르다. + * + *

{@link #CLOSED_NOW} 와 합치지 않는다. 둘 다 "지금은 닫혀 있다" 지만 사용자가 할 일이 + * 정반대다 — 개점 전이면 기다렸다 가면 되고, 운영이 끝났으면 오늘은 못 간다. 09시에 여는 + * 곳을 08시에 보고 "오늘 운영이 끝났어요" 라고 하면 갈 수 있는 곳을 안 가게 만든다. + * + *

{@link #UNKNOWN} 으로 두지 않는 이유 — 여는 시각을 읽어냈는데 침묵하는 것이라 모르는 게 + * 아니라 알고도 안 말하는 것이다. 여는 시각은 {@code useTime} 원문이 함께 나가 화면이 채울 수 있다. + */ + BEFORE_OPEN("아직 문을 열기 전이에요"), + /** 오늘 운영은 끝났다 — 열긴 하는데 지금은 시간이 지났다. */ CLOSED_NOW("오늘 운영이 끝났어요"), diff --git a/src/test/java/com/offway/core/trip/domain/OpeningHoursTest.java b/src/test/java/com/offway/core/trip/domain/OpeningHoursTest.java index 4cb8c629..ae671812 100644 --- a/src/test/java/com/offway/core/trip/domain/OpeningHoursTest.java +++ b/src/test/java/com/offway/core/trip/domain/OpeningHoursTest.java @@ -41,9 +41,9 @@ private static OpeningStatus status(String useTime, String restDate, LocalDateTi @CsvSource({ "09:00~18:00, 14:00, OPEN", "09:00~18:00, 18:00, CLOSED_NOW", // 마감 시각은 이미 끝난 것으로 본다 - "09:00~18:00, 08:59, CLOSED_NOW", // 열기 전 "05:30~20:00, 19:59, OPEN", "09:00-18:00, 14:00, OPEN", // 하이픈 변형 + "매일 09:00~18:00, 14:00, OPEN", // 뜻을 안 바꾸는 수식어 }) void 단일_시간범위는_시각을_비교한다(String useTime, String at, OpeningStatus expected) { LocalDateTime now = LocalDateTime.of(2026, 9, 15, Integer.parseInt(at.split(":")[0]), @@ -52,6 +52,20 @@ private static OpeningStatus status(String useTime, String restDate, LocalDateTi assertEquals(expected, status(useTime, "연중무휴", now, false)); } + @ParameterizedTest + @CsvSource({ + "08:59, BEFORE_OPEN", + "09:00, OPEN", + "18:00, CLOSED_NOW", + }) + void 개점_전은_운영_종료와_구분한다(String at, OpeningStatus expected) { + // 09시에 여는 곳을 08:59 에 보고 "오늘 운영이 끝났어요" 라고 하면 갈 수 있는 곳을 안 가게 만든다. + LocalDateTime now = LocalDateTime.of(2026, 9, 15, Integer.parseInt(at.split(":")[0]), + Integer.parseInt(at.split(":")[1])); + + assertEquals(expected, status("09:00~18:00", "연중무휴", now, false)); + } + @Test void 쉬는_요일이면_오늘_휴무다() { assertEquals(OpeningStatus.CLOSED_TODAY, status("09:00~18:00", "매주 월요일", MONDAY_1400, false)); @@ -117,6 +131,48 @@ private static OpeningStatus status(String useTime, String restDate, LocalDateTi assertEquals(OpeningStatus.UNKNOWN, status("09:00~18:00", restDate, MONDAY_1400, false)); } + @ParameterizedTest + @ValueSource(strings = { + "연중무휴 (단, 설·추석 당일 휴무)", + "연중무휴(1월 1일, 설·추석 당일 휴무)", + "매주 월요일, 1월 1일, 설·추석 당일", // 요일은 읽히지만 특정일이 남는다 + "매주 월요일(공휴일인 경우 다음 날 휴무)", // 우리가 모르는 예외 조항 + }) + void 못_읽은_조건이_남으면_확정하지_않는다(String restDate) { + // `연중무휴` 만 보고 OPEN 을 확정하면 설 당일에 "영업 중" 이라고 말한다 — 헛걸음을 만든다. + assertEquals(OpeningStatus.UNKNOWN, status("09:00~18:00", restDate, TUESDAY_1400, false)); + } + + @ParameterizedTest + @ValueSource(strings = { + "상시개방 (동절기 제외)", + "24시간 (동절기 제외)", + "하절기 09:00~18:00", // 계절 한정인데 단일 범위로 읽힌다 + "동절기 09:00~17:00", + "09:00~12:00, 13:00~18:00", // 범위가 둘 + }) + void 운영시간에_못_읽은_조건이_남으면_확정하지_않는다(String useTime) { + assertEquals(OpeningStatus.UNKNOWN, status(useTime, "연중무휴", TUESDAY_1400, false)); + } + + @Test + void 연중무휴와_정기휴무가_함께_오면_모른다고_한다() { + // 서로 어긋난다 — 어느 쪽이 맞는지 우리가 모른다. + assertEquals(OpeningStatus.UNKNOWN, status("09:00~18:00", "연중무휴, 매주 월요일", MONDAY_1400, false)); + } + + @ParameterizedTest + @ValueSource(strings = {"매주 월요일", "매주 월요일 휴관", "매주 월요일 정기휴무"}) + void 뜻을_안_바꾸는_말이_붙어도_읽는다(String restDate) { + // 남기는 쪽을 나열하면 여기 빠진 표현이 UNKNOWN 이 될 뿐이라, 실측으로 볼 때마다 늘리면 된다. + assertEquals(OpeningStatus.CLOSED_TODAY, status("09:00~18:00", restDate, MONDAY_1400, false)); + } + + @Test + void 상시개방은_붙은_말을_읽어도_열림이다() { + assertEquals(OpeningStatus.OPEN, status("24시간 개방", "연중무휴", MONDAY_1400, false)); + } + @Test void 자정을_넘기는_영업은_판정하지_않는다() { // 22:00~02:00 을 그대로 비교하면 낮 시간이 전부 "운영 끝" 으로 나온다. @@ -141,5 +197,6 @@ private static OpeningStatus status(String useTime, String restDate, LocalDateTi void 모르는_상태는_화면에_안_내린다() { assertEquals(false, OpeningStatus.UNKNOWN.isDisplayable()); assertEquals(true, OpeningStatus.CLOSED_TODAY.isDisplayable()); + assertEquals(true, OpeningStatus.BEFORE_OPEN.isDisplayable()); } } From 8e6bd9209c00f4ab09ebc6415f172dca7e3cfef0 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Thu, 13 Aug 2026 22:21:39 +0900 Subject: [PATCH 20/20] =?UTF-8?q?fix:=20=EA=B3=B5=ED=9C=B4=EC=9D=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=EB=A1=9C=20degrade=20?= =?UTF-8?q?=ED=95=9C=20=EA=B2=83=EC=9D=84=20=EB=A1=9C=EA=B7=B8=EC=97=90=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isHoliday() 가 예외를 삼키고 false 를 돌려주는데 아무 흔적이 없었다. 그러면 공휴일 월요일에 "오늘은 휴무일이에요" 가 나가도 아무도 모른다 - 코스 조회마다 찍으면 장애 동안 로그가 요청 수만큼 불어난다. HolidayProvider 가 실패를 5분간 캐시하므로 그 창 안의 요청은 같은 실패를 즉시 돌려받는다 — 같은 간격으로 눌러 창당 한 줄만 남기고, 창이 지나면 다시 남겨 장애가 계속되는 것도 보이게 했다 - 예외는 타입만 적는다. 스택·메시지는 HolidayProvider 가 실패 지점에서 이미 남겼고 여기서 또 풀면 같은 장애가 두 번 쌓인다 --- .../service/OpeningHoursProvider.java | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/offway/core/itinerary/service/OpeningHoursProvider.java b/src/main/java/com/offway/core/itinerary/service/OpeningHoursProvider.java index 33d593a4..0ebbb50e 100644 --- a/src/main/java/com/offway/core/itinerary/service/OpeningHoursProvider.java +++ b/src/main/java/com/offway/core/itinerary/service/OpeningHoursProvider.java @@ -8,13 +8,17 @@ import com.offway.core.trip.domain.OpeningHours; import com.offway.core.trip.domain.OpeningStatus; import com.offway.core.trip.repository.PoiIntroRepository; +import java.time.Duration; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; /** @@ -27,6 +31,7 @@ *

판정은 오늘 여행 중일 때만. 지금 시각으로 내리는 판정이라 다음 주 코스에 붙이면 사용자가 * 여행일 상태로 읽는다 — 없는 것보다 나쁘다. */ +@Slf4j @Component @RequiredArgsConstructor public class OpeningHoursProvider { @@ -34,9 +39,20 @@ public class OpeningHoursProvider { /** "오늘" 판정은 KST — 사용자가 서 있는 시간대다. */ private static final ZoneId SERVICE_ZONE = ZoneId.of("Asia/Seoul"); + /** + * 공휴일 조회 실패를 다시 남기기까지의 간격. + * + *

{@code HolidayProvider} 가 실패를 5분간 캐시한다 — 그 창 안의 요청은 같은 실패를 즉시 + * 돌려받으므로 한 줄이면 사실을 다 담는다. 창이 지나면 외부를 다시 물으니 그때는 새 실패고, 다시 남긴다. + */ + private static final Duration HOLIDAY_WARN_INTERVAL = Duration.ofMinutes(5); + private final PoiIntroRepository poiIntroRepository; private final HolidayProvider holidayProvider; + /** 마지막으로 degrade 를 남긴 시각. 요청이 동시에 몰려도 한 줄만 나가게 CAS 로 집는다. */ + private final AtomicReference lastHolidayWarn = new AtomicReference<>(); + /** * 코스 전체 슬롯의 운영 정보를 한 번의 조회로 가져온다 — 슬롯마다 읽으면 N+1 이 된다. * @@ -65,12 +81,36 @@ public Map forCourse(Course course) { return result; } - /** 공휴일 조회가 실패해도 코스는 나가야 한다 — 예외 조항 판정만 보수적으로 간다(휴무로 본다). */ + /** + * 공휴일 조회가 실패해도 코스는 나가야 한다 — 예외 조항 판정만 보수적으로 간다(휴무로 본다). + * + *

degrade 했으면 왜 했는지 남긴다. 안 남기면 공휴일 월요일에 "오늘 휴무" 가 나가도 아무도 + * 모른다. 다만 코스 조회마다 찍으면 장애 동안 로그가 요청 수만큼 불어나므로 + * {@link #HOLIDAY_WARN_INTERVAL} 로 눌러 둔다. + * + *

예외는 타입만 적는다 — 스택·메시지는 이미 {@code HolidayProvider} 가 실패 지점에서 남겼고, + * 여기서 또 풀면 같은 장애가 두 번 쌓인다. + */ private boolean isHoliday(LocalDate today) { try { return holidayProvider.holidaysWithin(today, today).contains(today); } catch (RuntimeException e) { + warnDegraded(e); return false; } } + + /** 같은 실패 창에서 한 줄만 남긴다. 창이 지나면 다시 남겨 장애가 계속되는 것도 보이게 한다. */ + private void warnDegraded(RuntimeException cause) { + Instant now = Instant.now(); + Instant last = lastHolidayWarn.get(); + if (last != null && now.isBefore(last.plus(HOLIDAY_WARN_INTERVAL))) { + return; + } + if (!lastHolidayWarn.compareAndSet(last, now)) { + return; + } + log.warn("공휴일 조회 실패 — 공휴일 정상운영 예외를 적용하지 않고 판정합니다 cause={}", + cause.getClass().getSimpleName()); + } }