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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,13 @@ IMPORT_JOB_AI_BATCH_SIZE=50
# 상품·마트 조회수 Redis 캐싱 → DB 동기화
VIEW_COUNT_DEDUP_TTL=PT24H
VIEW_COUNT_FLUSH_INTERVAL=PT10S

# 공휴일 API (한국천문연구원 특일정보, data.go.kr)
# 인증키는 포털의 "Decoding" 키를 넣는다 (Encoding 키를 넣으면 이중 인코딩으로 인증 실패)
HOLIDAY_API_SERVICE_KEY=
HOLIDAY_API_CONNECT_TIMEOUT=2s
HOLIDAY_API_READ_TIMEOUT=3s

# 공휴일 Redis 캐시 (임시공휴일 반영 위해 TTL 1일, 장애 시 빈 값 짧은 TTL)
HOLIDAY_CACHE_TTL=P1D
HOLIDAY_CACHE_FALLBACK_TTL=PT10M
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package kr.dongchimi.api.owner.flyer

import kr.dongchimi.api.owner.flyer.response.FlyerDailyPreviewResponse
import kr.dongchimi.api.owner.flyer.response.FlyerPreviewResponse
import kr.dongchimi.core.holiday.HolidayService
import kr.dongchimi.core.market.MarketService
import kr.dongchimi.core.product.DealType
import kr.dongchimi.core.product.PreparedProductService
Expand All @@ -15,6 +16,7 @@ class FlyerPreviewQueryFacade(
private val marketService: MarketService,
private val productService: ProductService,
private val preparedProductService: PreparedProductService,
private val holidayService: HolidayService,
) {
@Transactional(readOnly = true)
fun getPeriodicPreview(
Expand All @@ -28,8 +30,9 @@ class FlyerPreviewQueryFacade(
val top3 = productService.getPopularActiveProducts(marketId, today, TOP_PRODUCTS_LIMIT)
val dailyProducts = productService.getAllActiveProducts(marketId, DealType.DAILY, today)
val preparedProducts = preparedProductService.getPreviewDrafts(ownerId, marketId)
val holidays = holidayService.getHolidays(today)

return FlyerPreviewResponse(market, now, top3, dailyProducts, preparedProducts)
return FlyerPreviewResponse(market, now, holidays, top3, dailyProducts, preparedProducts)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

@Transactional(readOnly = true)
Expand All @@ -43,8 +46,9 @@ class FlyerPreviewQueryFacade(

val top3 = productService.getPopularActiveProducts(marketId, today, TOP_PRODUCTS_LIMIT)
val dailyProducts = productService.getAllActiveProducts(marketId, DealType.DAILY, today)
val holidays = holidayService.getHolidays(today)

return FlyerDailyPreviewResponse(market, now, top3, dailyProducts)
return FlyerDailyPreviewResponse(market, now, holidays, top3, dailyProducts)
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package kr.dongchimi.api.owner.flyer.response
import io.swagger.v3.oas.annotations.media.Schema
import kr.dongchimi.core.market.Market
import kr.dongchimi.core.product.Product
import java.time.LocalDate
import java.time.LocalDateTime

data class FlyerDailyPreviewResponse(
Expand All @@ -18,6 +19,8 @@ data class FlyerDailyPreviewResponse(
val isOpenNow: Boolean,
@Schema(description = "영업시간 (요일 묶음 배열)")
val businessHours: List<FlyerPreviewBusinessHourResponse>,
@Schema(description = "공휴일 휴무 여부")
val isHolidayClosed: Boolean,
@Schema(description = "마트 대표 전화번호 1")
val marketPhone1: String,
@Schema(description = "마트 전화번호 2 (없으면 null)")
Expand All @@ -32,15 +35,17 @@ data class FlyerDailyPreviewResponse(
constructor(
market: Market,
now: LocalDateTime,
holidays: Set<LocalDate>,
top3: List<Product>,
dailyProducts: List<Product>,
) : this(
marketId = market.id,
name = market.info.name,
thumbnailUrl = market.info.thumbnailUrl,
address = market.info.address.substringBefore("|"),
isOpenNow = market.businessHours.isOpenAt(now),
isOpenNow = market.businessHours.isOpenAt(now, holidays),
businessHours = market.businessHours.slots.map { FlyerPreviewBusinessHourResponse(it) },
isHolidayClosed = market.businessHours.isHolidayClosed,
marketPhone1 = market.phoneNumber.marketPhone1,
marketPhone2 = market.phoneNumber.marketPhone2,
ownerPhone = market.phoneNumber.ownerPhone,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema
import kr.dongchimi.core.market.Market
import kr.dongchimi.core.product.PreparedProduct
import kr.dongchimi.core.product.Product
import java.time.LocalDate
import java.time.LocalDateTime

data class FlyerPreviewResponse(
Expand All @@ -19,6 +20,8 @@ data class FlyerPreviewResponse(
val isOpenNow: Boolean,
@Schema(description = "영업시간 (요일 묶음 배열)")
val businessHours: List<FlyerPreviewBusinessHourResponse>,
@Schema(description = "공휴일 휴무 여부")
val isHolidayClosed: Boolean,
@Schema(description = "마트 대표 전화번호 1")
val marketPhone1: String,
@Schema(description = "마트 전화번호 2 (없으면 null)")
Expand All @@ -35,6 +38,7 @@ data class FlyerPreviewResponse(
constructor(
market: Market,
now: LocalDateTime,
holidays: Set<LocalDate>,
top3: List<Product>,
dailyProducts: List<Product>,
preparedProducts: List<PreparedProduct>,
Expand All @@ -43,8 +47,9 @@ data class FlyerPreviewResponse(
name = market.info.name,
thumbnailUrl = market.info.thumbnailUrl,
address = market.info.address.substringBefore("|"),
isOpenNow = market.businessHours.isOpenAt(now),
isOpenNow = market.businessHours.isOpenAt(now, holidays),
businessHours = market.businessHours.slots.map { FlyerPreviewBusinessHourResponse(it) },
isHolidayClosed = market.businessHours.isHolidayClosed,
marketPhone1 = market.phoneNumber.marketPhone1,
marketPhone2 = market.phoneNumber.marketPhone2,
ownerPhone = market.phoneNumber.ownerPhone,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ data class MarketRegisterRequest(
val longitude: Double,
@Schema(description = "영업시간 (요일 묶음 배열)")
val businessHours: List<BusinessHourSlotRequest>,
@Schema(description = "공휴일 휴무 여부. 생략 시 false")
val isHolidayClosed: Boolean?,
@Schema(description = "마트 대표 전화번호 1")
val marketPhone1: String,
@Schema(description = "마트 전화번호 2 (추가 등록 시)")
Expand Down Expand Up @@ -49,7 +51,7 @@ data class MarketRegisterRequest(
return MarketRegisterCommand(
info = MarketInfo(name = name, address = mergeAddress(address, detailAddress), thumbnailUrl = thumbnailUrl),
location = LocationPoint(longitude = longitude, latitude = latitude),
businessHours = businessHours.toBusinessHours(),
businessHours = businessHours.toBusinessHours(isHolidayClosed),
phoneNumber = MarketPhoneNumber(marketPhone1, marketPhone2, marketPhonePrimary, ownerPhone),
brn = brn,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ internal fun mergeAddress(
detailAddress: String?,
): String = if (detailAddress.isNullOrBlank()) address else "$address|$detailAddress"

internal fun List<BusinessHourSlotRequest>?.toBusinessHours(): BusinessHours {
internal fun List<BusinessHourSlotRequest>?.toBusinessHours(isHolidayClosed: Boolean?): BusinessHours {
validate(!this.isNullOrEmpty()) { "영업시간을 입력해 주세요." }

val slots = this!!.map { it.toSlot() }
Expand All @@ -57,7 +57,7 @@ internal fun List<BusinessHourSlotRequest>?.toBusinessHours(): BusinessHours {
validate(allDays.isNotEmpty()) { "영업 요일을 하나 이상 선택해 주세요." }
validate(allDays.size == allDays.toSet().size) { "같은 요일을 여러 번 지정할 수 없습니다." }

return BusinessHours(slots)
return BusinessHours(slots = slots, isHolidayClosed = isHolidayClosed ?: false)
}

private fun BusinessHourSlotRequest.toSlot(): BusinessHourSlot {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ data class MarketUpdateRequest(
val longitude: Double,
@Schema(description = "영업시간 (요일 묶음 배열)")
val businessHours: List<BusinessHourSlotRequest>,
@Schema(description = "공휴일 휴무 여부. 생략 시 false")
val isHolidayClosed: Boolean?,
@Schema(description = "마트 대표 전화번호 1")
val marketPhone1: String,
@Schema(description = "마트 전화번호 2 (추가 등록 시)")
Expand Down Expand Up @@ -49,7 +51,7 @@ data class MarketUpdateRequest(
return MarketUpdateCommand(
info = MarketInfo(name = name, address = mergeAddress(address, detailAddress), thumbnailUrl = thumbnailUrl),
location = LocationPoint(longitude = longitude, latitude = latitude),
businessHours = businessHours.toBusinessHours(),
businessHours = businessHours.toBusinessHours(isHolidayClosed),
phoneNumber = MarketPhoneNumber(marketPhone1, marketPhone2, marketPhonePrimary, ownerPhone),
brn = brn,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ data class OwnerMarketDetailResponse(
val longitude: Double,
@Schema(description = "영업시간 (요일 묶음 배열)")
val businessHours: List<OwnerMarketBusinessHourResponse>,
@Schema(description = "공휴일 휴무 여부")
val isHolidayClosed: Boolean,
@Schema(description = "마트 대표 전화번호 1")
val marketPhone1: String,
@Schema(description = "마트 전화번호 2 (없으면 null)")
Expand All @@ -37,6 +39,7 @@ data class OwnerMarketDetailResponse(
latitude = market.location.latitude,
longitude = market.location.longitude,
businessHours = market.businessHours.slots.map { OwnerMarketBusinessHourResponse(it) },
isHolidayClosed = market.businessHours.isHolidayClosed,
marketPhone1 = market.phoneNumber.marketPhone1,
marketPhone2 = market.phoneNumber.marketPhone2,
marketPhonePrimary = market.phoneNumber.marketPhonePrimary,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package kr.dongchimi.api.user.market
import kr.dongchimi.api.user.market.response.BusinessHourResponse
import kr.dongchimi.api.user.market.response.MarketDetailResponse
import kr.dongchimi.api.user.market.response.PopularProductResponse
import kr.dongchimi.core.holiday.HolidayService
import kr.dongchimi.core.market.MarketService
import kr.dongchimi.core.product.ProductService
import kr.dongchimi.core.viewcount.EntityViewedEvent
Expand All @@ -16,6 +17,7 @@ import java.time.LocalDateTime
class MarketDetailQueryFacade(
private val marketService: MarketService,
private val productService: ProductService,
private val holidayService: HolidayService,
private val eventPublisher: ApplicationEventPublisher,
) {
@Transactional(readOnly = true)
Expand All @@ -26,6 +28,7 @@ class MarketDetailQueryFacade(
): MarketDetailResponse {
val market = marketService.getBySlug(slug)
val top3 = productService.getPopularActiveProducts(market.id, now.toLocalDate(), TOP_PRODUCTS_LIMIT)
val holidays = holidayService.getHolidays(now.toLocalDate())

eventPublisher.publishEvent(EntityViewedEvent(ViewTarget.MARKET, market.id, userId))

Expand All @@ -34,8 +37,9 @@ class MarketDetailQueryFacade(
name = market.info.name,
thumbnailUrl = market.info.thumbnailUrl,
address = market.info.address.substringBefore("|"),
isOpenNow = market.businessHours.isOpenAt(now),
isOpenNow = market.businessHours.isOpenAt(now, holidays),
businessHours = market.businessHours.slots.map { BusinessHourResponse(it) },
isHolidayClosed = market.businessHours.isHolidayClosed,
marketPhone1 = market.phoneNumber.marketPhone1,
marketPhone2 = market.phoneNumber.marketPhone2,
ownerPhone = market.phoneNumber.ownerPhone,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package kr.dongchimi.api.user.market

import kr.dongchimi.api.core.common.dto.CursorSliceResponse
import kr.dongchimi.api.user.market.response.NearbyMarketResponse
import kr.dongchimi.core.holiday.HolidayService
import kr.dongchimi.core.market.MarketService
import kr.dongchimi.core.market.NearbyMarketSearchCondition
import kr.dongchimi.core.product.ProductService
Expand All @@ -13,6 +14,7 @@ import java.time.LocalDateTime
class NearbyMarketQueryFacade(
private val marketService: MarketService,
private val productService: ProductService,
private val holidayService: HolidayService,
) {
@Transactional(readOnly = true)
fun getNearbyMarkets(
Expand All @@ -28,6 +30,7 @@ class NearbyMarketQueryFacade(
.getLatestActiveProducts(marketIds, today, PREVIEW_PRODUCT_SIZE)
.groupBy { it.marketId }
val productCounts = productService.countActiveProductsByMarketIds(marketIds, today)
val holidays = holidayService.getHolidays(today)

return CursorSliceResponse(
content =
Expand All @@ -37,6 +40,7 @@ class NearbyMarketQueryFacade(
productCount = productCounts[nearbyMarket.market.id] ?: 0,
previewProducts = previewProducts[nearbyMarket.market.id].orEmpty(),
now = now,
holidays = holidays,
)
},
hasNext = slice.hasNext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ data class MarketDetailResponse(
val isOpenNow: Boolean,
@Schema(description = "영업시간 (요일 묶음 배열)")
val businessHours: List<BusinessHourResponse>,
@Schema(description = "공휴일 휴무 여부")
val isHolidayClosed: Boolean,
@Schema(description = "마트 대표 전화번호 1")
val marketPhone1: String,
@Schema(description = "마트 전화번호 2 (없으면 null)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package kr.dongchimi.api.user.market.response
import io.swagger.v3.oas.annotations.media.Schema
import kr.dongchimi.core.market.NearbyMarket
import kr.dongchimi.core.product.Product
import java.time.LocalDate
import java.time.LocalDateTime

data class NearbyMarketResponse(
Expand Down Expand Up @@ -32,6 +33,7 @@ data class NearbyMarketResponse(
productCount: Int,
previewProducts: List<Product>,
now: LocalDateTime,
holidays: Set<LocalDate>,
) : this(
marketId = nearbyMarket.market.id,
name = nearbyMarket.market.info.name,
Expand All @@ -42,7 +44,7 @@ data class NearbyMarketResponse(
.substringBefore("|"),
latitude = nearbyMarket.market.location.latitude,
longitude = nearbyMarket.market.location.longitude,
isOpen = nearbyMarket.market.businessHours.isOpenAt(now),
isOpen = nearbyMarket.market.businessHours.isOpenAt(now, holidays),
productCount = productCount,
previewProducts = previewProducts.map { PreviewProductResponse(it) },
)
Expand Down
16 changes: 16 additions & 0 deletions core/src/main/kotlin/kr/dongchimi/core/holiday/HolidayCache.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package kr.dongchimi.core.holiday

import java.time.LocalDate

interface HolidayCache {
/** null이면 미캐싱, 빈 Set이면 "공휴일 없음"으로 캐싱된 상태 */
fun get(year: Int): Set<LocalDate>?

fun put(
year: Int,
holidays: Set<LocalDate>,
)

/** API 장애 시 짧은 TTL로 빈 값을 캐싱해 요청마다 외부 API를 재시도하지 않게 한다. */
fun putFallback(year: Int)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package kr.dongchimi.core.holiday

import java.time.LocalDate

interface HolidayClient {
/** 해당 연도의 공휴일 목록을 외부 API에서 조회한다. 실패 시 예외를 던진다. */
fun fetchHolidays(year: Int): Set<LocalDate>
}
31 changes: 31 additions & 0 deletions core/src/main/kotlin/kr/dongchimi/core/holiday/HolidayReader.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package kr.dongchimi.core.holiday

import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import java.time.LocalDate

private val logger = KotlinLogging.logger {}

@Component
class HolidayReader(
private val holidayCache: HolidayCache,
private val holidayClient: HolidayClient,
) {
/** 자정 넘김 영업 판별이 전날 공휴일 여부까지 보므로 전날이 걸치는 연도도 함께 조회한다. */
fun getHolidays(baseDate: LocalDate): Set<LocalDate> {
val years = setOf(baseDate.year, baseDate.minusDays(1).year)
return years.flatMap { holidaysOf(it) }.toSet()
}

private fun holidaysOf(year: Int): Set<LocalDate> = holidayCache.get(year) ?: fetchAndCache(year)

// 캐시·API 모두 실패하면 공휴일 없음으로 간주한다. isOpenNow는 부가 정보라 조회 자체를 깨지 않는다.
private fun fetchAndCache(year: Int): Set<LocalDate> =
runCatching { holidayClient.fetchHolidays(year) }
.onSuccess { holidayCache.put(year, it) }
.getOrElse { exception ->
logger.warn(exception) { "공휴일 조회 실패, 공휴일 없음으로 간주: year=$year" }
holidayCache.putFallback(year)
emptySet()
}
}
12 changes: 12 additions & 0 deletions core/src/main/kotlin/kr/dongchimi/core/holiday/HolidayService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kr.dongchimi.core.holiday

import org.springframework.stereotype.Service
import java.time.LocalDate

@Service
class HolidayService(
private val holidayReader: HolidayReader,
) {
/** baseDate 기준 영업중 판별에 필요한 공휴일 목록 (전날이 걸치는 연도까지 커버) */
fun getHolidays(baseDate: LocalDate): Set<LocalDate> = holidayReader.getHolidays(baseDate)
}
18 changes: 15 additions & 3 deletions core/src/main/kotlin/kr/dongchimi/core/market/BusinessHours.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
package kr.dongchimi.core.market

import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime

data class BusinessHours(
val slots: List<BusinessHourSlot>,
val isHolidayClosed: Boolean = false,
) {
fun isOpenAt(dateTime: LocalDateTime): Boolean {
fun isOpenAt(
dateTime: LocalDateTime,
holidays: Set<LocalDate>,
): Boolean {
val today = dateTime.toLocalDate()
val time = dateTime.toLocalTime()

return slotsOf(dateTime.dayOfWeek).any { it.contains(time) } ||
slotsOf(dateTime.dayOfWeek.minus(1)).any { it.containsOvernightTail(time) }
// 전날이 공휴일 휴무면 자정을 넘긴 꼬리 영업도 휴무 처리한다.
return (!closedOn(today, holidays) && slotsOf(today.dayOfWeek).any { it.contains(time) }) ||
(!closedOn(today.minusDays(1), holidays) && slotsOf(today.dayOfWeek.minus(1)).any { it.containsOvernightTail(time) })
}

private fun closedOn(
date: LocalDate,
holidays: Set<LocalDate>,
): Boolean = isHolidayClosed && date in holidays

private fun slotsOf(dayOfWeek: DayOfWeek): List<BusinessHourSlot> = slots.filter { dayOfWeek in it.days }
}
Loading
Loading