-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] 공휴일 API를 받아와서 공휴일 점심에 스낵코너가 안뜨게 처리해요 #501
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
159a7fe
c1b4129
5aab37d
4e51d3d
e6d7bcf
6bb8a2e
874c580
cfea83a
7ba9941
548eeee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| package com.eatssu.android.data.remote.dto.response | ||
|
|
||
| import kotlinx.serialization.ExperimentalSerializationApi | ||
| import kotlinx.serialization.SerialName | ||
| import kotlinx.serialization.Serializable | ||
| import kotlinx.serialization.builtins.ListSerializer | ||
| import kotlinx.serialization.json.JsonArray | ||
| import kotlinx.serialization.json.JsonElement | ||
| import kotlinx.serialization.json.JsonObject | ||
| import kotlinx.serialization.json.JsonTransformingSerializer | ||
|
|
||
| @Serializable | ||
| data class PublicHolidayApiResponse( | ||
| @SerialName("response") val response: Response? = null, | ||
| ) { | ||
| @Serializable | ||
| data class Response( | ||
| @SerialName("header") val header: Header? = null, | ||
| @SerialName("body") val body: Body? = null, | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class Header( | ||
| @SerialName("resultCode") val resultCode: String? = null, | ||
| @SerialName("resultMsg") val resultMsg: String? = null, | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class Body( | ||
| @SerialName("items") val items: Items? = null, | ||
| @SerialName("numOfRows") val numOfRows: Int? = null, | ||
| @SerialName("pageNo") val pageNo: Int? = null, | ||
| @SerialName("totalCount") val totalCount: Int? = null, | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class Items( | ||
| @Serializable(with = PublicHolidayItemListSerializer::class) | ||
| @SerialName("item") val item: List<Item> = emptyList(), | ||
| ) | ||
|
|
||
| @OptIn(ExperimentalSerializationApi::class) | ||
| object PublicHolidayItemListSerializer : JsonTransformingSerializer<List<Item>>( | ||
| ListSerializer(Item.serializer()) | ||
| ) { | ||
| override fun transformDeserialize(element: JsonElement): JsonElement { | ||
| return when (element) { | ||
| is JsonObject -> JsonArray(listOf(element)) | ||
| else -> element | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Serializable | ||
| data class Item( | ||
| @SerialName("locdate") val locdate: Long? = null, | ||
| @SerialName("isHoliday") val isHoliday: String? = null, | ||
| @SerialName("dateName") val dateName: String? = null, | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| package com.eatssu.android.data.remote.repository | ||
|
|
||
| import com.eatssu.android.data.remote.service.PublicHolidayService | ||
| import com.eatssu.android.domain.model.PublicHoliday | ||
| import com.eatssu.android.domain.repository.PublicHolidayRepository | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.withContext | ||
| import timber.log.Timber | ||
| import java.net.URLEncoder | ||
| import java.time.LocalDate | ||
| import java.time.YearMonth | ||
| import javax.inject.Named | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
|
|
||
| @Singleton | ||
| class PublicHolidayRepositoryImpl @Inject constructor( | ||
| private val publicHolidayService: PublicHolidayService, | ||
| @Named(PUBLIC_HOLIDAY_SERVICE_KEY_NAME) private val serviceKey: String, | ||
| ) : PublicHolidayRepository { | ||
|
|
||
| companion object { | ||
| const val PUBLIC_HOLIDAY_SERVICE_KEY_NAME: String = "PublicHolidayServiceKey" | ||
| } | ||
|
|
||
| override suspend fun getHolidays(yearMonth: YearMonth): List<PublicHoliday> { | ||
| if (serviceKey.isBlank()) { | ||
| Timber.w("HOLIDAY_API_KEY is blank; skipping public holiday fetch") | ||
| return emptyList() | ||
| } | ||
|
|
||
| val normalizedKey = normalizeServiceKey(serviceKey) | ||
|
|
||
| val responseResult = withContext(Dispatchers.IO) { | ||
| runCatching { | ||
| publicHolidayService.getRestDeInfo( | ||
| serviceKey = normalizedKey, | ||
| solYear = yearMonth.year.toString(), | ||
| solMonth = yearMonth.monthValue.toString().padStart(2, '0'), | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| val response = responseResult.getOrNull() ?: run { | ||
| Timber.w(responseResult.exceptionOrNull(), "Failed to fetch public holidays") | ||
| return emptyList() | ||
| } | ||
|
|
||
| val resultCode = response.response?.header?.resultCode | ||
| if (resultCode != null && resultCode != "00") { | ||
| Timber.w( | ||
| "PublicHoliday API returned non-normal resultCode=%s msg=%s", | ||
| resultCode, | ||
| response.response?.header?.resultMsg, | ||
| ) | ||
| return emptyList() | ||
| } | ||
|
|
||
| val holidays = response.response | ||
| ?.body | ||
| ?.items | ||
| ?.item | ||
| .orEmpty() | ||
| .asSequence() | ||
| .filter { it.isHoliday.equals("Y", ignoreCase = true) } | ||
| .mapNotNull { item -> | ||
| val date = item.locdate?.let(::parseLocdate) | ||
| val name = item.dateName?.trim().orEmpty() | ||
|
|
||
| if (date == null || name.isBlank()) return@mapNotNull null | ||
| PublicHoliday(date = date, name = name) | ||
| } | ||
| .distinctBy { it.date } | ||
| .sortedBy { it.date } | ||
| .toList() | ||
|
|
||
| return holidays | ||
| } | ||
|
|
||
| override suspend fun getHoliday(date: LocalDate): PublicHoliday? { | ||
| val yearMonth = YearMonth.from(date) | ||
| val holidays = getHolidays(yearMonth) | ||
| return holidays.firstOrNull { it.date == date } | ||
| } | ||
|
HI-JIN2 marked this conversation as resolved.
Outdated
|
||
|
|
||
| private fun parseLocdate(locdate: Long): LocalDate? { | ||
| val s = locdate.toString() | ||
| if (s.length != 8) return null | ||
|
|
||
| return runCatching { | ||
| val year = s.substring(0, 4).toInt() | ||
| val month = s.substring(4, 6).toInt() | ||
| val day = s.substring(6, 8).toInt() | ||
| LocalDate.of(year, month, day) | ||
| }.getOrNull() | ||
| } | ||
|
|
||
| private fun normalizeServiceKey(value: String): String { | ||
| val trimmed = value.trim() | ||
| if (trimmed.isEmpty()) return "" | ||
|
|
||
| return if ('%' in trimmed) trimmed else URLEncoder.encode(trimmed, Charsets.UTF_8.name()) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package com.eatssu.android.data.remote.service | ||
|
|
||
| import com.eatssu.android.data.remote.dto.response.PublicHolidayApiResponse | ||
| import retrofit2.http.GET | ||
| import retrofit2.http.Query | ||
|
|
||
| interface PublicHolidayService { | ||
|
|
||
| @GET("B090041/openapi/service/SpcdeInfoService/getRestDeInfo") | ||
| suspend fun getRestDeInfo( | ||
| @Query(value = "ServiceKey", encoded = true) serviceKey: String, | ||
| @Query("solYear") solYear: String, | ||
| @Query("solMonth") solMonth: String, | ||
| @Query("numOfRows") numOfRows: Int = 50, | ||
| @Query("pageNo") pageNo: Int = 1, | ||
| @Query("_type") type: String = "json", | ||
| ): PublicHolidayApiResponse | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| package com.eatssu.android.di | ||
|
|
||
| import com.eatssu.android.BuildConfig | ||
| import com.eatssu.android.data.remote.repository.PublicHolidayRepositoryImpl | ||
| import com.eatssu.android.data.remote.service.PublicHolidayService | ||
| import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory | ||
| import dagger.Module | ||
| import dagger.Provides | ||
| import dagger.hilt.InstallIn | ||
| import dagger.hilt.components.SingletonComponent | ||
| import kotlinx.serialization.json.Json | ||
| import okhttp3.MediaType.Companion.toMediaType | ||
| import okhttp3.OkHttpClient | ||
| import retrofit2.Retrofit | ||
| import javax.inject.Named | ||
| import javax.inject.Qualifier | ||
| import javax.inject.Singleton | ||
|
|
||
| @Qualifier | ||
| @Retention(AnnotationRetention.BINARY) | ||
| annotation class PublicHolidayApi | ||
|
|
||
| @Module | ||
| @InstallIn(SingletonComponent::class) | ||
| object PublicHolidayModule { | ||
|
|
||
| private const val PUBLIC_HOLIDAY_BASE_URL = "https://apis.data.go.kr/" | ||
|
|
||
| @Provides | ||
| @Singleton | ||
| @Named(PublicHolidayRepositoryImpl.PUBLIC_HOLIDAY_SERVICE_KEY_NAME) | ||
| fun providePublicHolidayServiceKey(): String { | ||
| return BuildConfig.HOLIDAY_API_KEY | ||
| } | ||
|
|
||
| @Provides | ||
| @Singleton | ||
| @PublicHolidayApi | ||
| fun providePublicHolidayRetrofit( | ||
| @NoToken okHttpClient: OkHttpClient, | ||
| json: Json, | ||
| ): Retrofit { | ||
| return Retrofit.Builder() | ||
| .baseUrl(PUBLIC_HOLIDAY_BASE_URL) | ||
| .client(okHttpClient) | ||
| .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) | ||
| .build() | ||
| } | ||
|
|
||
| @Provides | ||
| @Singleton | ||
| fun providePublicHolidayService( | ||
| @PublicHolidayApi retrofit: Retrofit, | ||
| ): PublicHolidayService { | ||
| return retrofit.create(PublicHolidayService::class.java) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.eatssu.android.domain.model | ||
|
|
||
| import com.eatssu.common.enums.Restaurant | ||
|
|
||
| data class MenuLoadResult( | ||
| val menuMap: Map<Restaurant, List<Menu>>, | ||
| val publicHolidayName: String?, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.eatssu.android.domain.model | ||
|
|
||
| import java.time.LocalDate | ||
|
|
||
| data class PublicHoliday( | ||
| val date: LocalDate, | ||
| val name: String, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.eatssu.android.domain.repository | ||
|
|
||
| import com.eatssu.android.domain.model.PublicHoliday | ||
| import java.time.LocalDate | ||
| import java.time.YearMonth | ||
|
|
||
| interface PublicHolidayRepository { | ||
|
|
||
| suspend fun getHolidays(yearMonth: YearMonth): List<PublicHoliday> | ||
|
|
||
| suspend fun getHoliday(date: LocalDate): PublicHoliday? | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package com.eatssu.android.domain.usecase.holiday | ||
|
|
||
| import com.eatssu.android.domain.model.PublicHoliday | ||
| import java.time.LocalDate | ||
| import java.time.YearMonth | ||
| import javax.inject.Inject | ||
|
|
||
| /** | ||
| * 특정 날짜가 공휴일이면 해당 공휴일 정보를 반환한다. | ||
| */ | ||
| class GetPublicHolidayOfDateUseCase @Inject constructor( | ||
| private val getPublicHolidaysOfMonthUseCase: GetPublicHolidaysOfMonthUseCase, | ||
| ) { | ||
| suspend operator fun invoke(date: LocalDate): PublicHoliday? { | ||
| val holidays = getPublicHolidaysOfMonthUseCase(YearMonth.from(date)) | ||
| return holidays.firstOrNull { it.date == date } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| package com.eatssu.android.domain.usecase.holiday | ||
|
|
||
| import com.eatssu.android.domain.model.PublicHoliday | ||
| import com.eatssu.android.domain.repository.PublicHolidayRepository | ||
| import kotlinx.coroutines.sync.Mutex | ||
| import kotlinx.coroutines.sync.withLock | ||
| import java.time.YearMonth | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
|
|
||
| /** | ||
| * 지정한 [YearMonth]의 공휴일 목록을 조회한다. | ||
| * | ||
| * 캐싱 정책(월 단위 메모리 캐시)은 usecase가 소유하고, | ||
| * repository는 데이터 접근(원격/로컬)에만 집중한다. | ||
| */ | ||
| @Singleton | ||
| class GetPublicHolidaysOfMonthUseCase @Inject constructor( | ||
| private val publicHolidayRepository: PublicHolidayRepository, | ||
| ) { | ||
| private val mutex = Mutex() | ||
| private val cache: MutableMap<YearMonth, List<PublicHoliday>> = linkedMapOf() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 월 단위 캐시로 예시: import androidx.collection.LruCache // or android.util.LruCache
// ...
private val cache = LruCache<YearMonth, List<PublicHoliday>>(12) // 최근 12개월 데이터 캐시
suspend operator fun invoke(yearMonth: YearMonth): List<PublicHoliday> {
mutex.withLock {
cache.get(yearMonth)?.let { return it }
}
val result = publicHolidayRepository.getHolidays(yearMonth)
mutex.withLock {
cache.put(yearMonth, result)
}
return result
}
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 반영했습니다. 월 캐시가 무제한으로 커지지 않도록 access-order |
||
|
|
||
| suspend operator fun invoke(yearMonth: YearMonth): List<PublicHoliday> { | ||
| mutex.withLock { | ||
| cache[yearMonth]?.let { return it } | ||
| } | ||
|
|
||
| val result = publicHolidayRepository.getHolidays(yearMonth) | ||
|
|
||
| mutex.withLock { | ||
| cache[yearMonth] = result | ||
| } | ||
|
|
||
| return result | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.eatssu.android.domain.usecase.holiday | ||
|
|
||
| import java.time.YearMonth | ||
| import javax.inject.Inject | ||
|
|
||
| class PrefetchPublicHolidaysOfMonthUseCase @Inject constructor( | ||
| private val getPublicHolidaysOfMonthUseCase: GetPublicHolidaysOfMonthUseCase, | ||
| ) { | ||
| suspend operator fun invoke(yearMonth: YearMonth) { | ||
| getPublicHolidaysOfMonthUseCase(yearMonth) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.