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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ android {
val postHogHost: String = p.getProperty("POSTHOG_HOST")
buildConfigField("String", "POSTHOG_HOST", "\"$postHogHost\"")

val holidayApiKey: String = p.getProperty("HOLIDAY_API_KEY") ?: ""
buildConfigField(
"String",
"HOLIDAY_API_KEY",
"\"$holidayApiKey\""
)

isShrinkResources = true
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
Expand Down Expand Up @@ -125,6 +132,13 @@ android {
val postHogHost: String = p.getProperty("POSTHOG_HOST")
buildConfigField("String", "POSTHOG_HOST", "\"$postHogHost\"")

val holidayApiKey: String = p.getProperty("HOLIDAY_API_KEY") ?: ""
buildConfigField(
"String",
"HOLIDAY_API_KEY",
"\"$holidayApiKey\""
)

isMinifyEnabled = false
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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)
/**
* 공휴일 API는 item이 1개일 때는 Object, 여러 개일 때는 Array로 내려주는 케이스가 있어
* 역직렬화 시 항상 List 형태로 정규화한다.
*/
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,99 @@
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 timber.log.Timber
import java.net.URLEncoder
import java.time.LocalDate
import java.time.YearMonth
import javax.inject.Inject
import javax.inject.Named
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"
}

/**
* 외부 공휴일 API를 호출해 해당 [YearMonth]의 공휴일 목록을 조회한다.
*
* - `HOLIDAY_API_KEY`가 비어있으면 네트워크 호출 없이 빈 리스트를 반환한다.
* - 네트워크/파싱 실패 또는 비정상 resultCode인 경우 빈 리스트를 반환한다.
* - `isHoliday == "Y"`만 필터링하고, 날짜 기준으로 중복 제거 후 오름차순 정렬한다.
*/
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)
if (normalizedKey.isBlank()) return emptyList()

try {
val response = publicHolidayService.getRestDeInfo(
serviceKey = normalizedKey,
solYear = yearMonth.year.toString(),
solMonth = yearMonth.monthValue.toString().padStart(2, '0'),
)

val resultCode = response.response?.header?.resultCode
if (resultCode != "00") {
Timber.w(
"PublicHoliday API returned non-normal resultCode=%s msg=%s",
resultCode,
response.response?.header?.resultMsg,
)

return emptyList()
} else {
return response.response
.body
?.items
?.item
.orEmpty()
.asSequence()
.filter { it.isHoliday.equals("Y", ignoreCase = true) }
.mapNotNull { item ->
val date = item.locdate?.let(::parseLocalDate)
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()
}
} catch (t: Throwable) {
Timber.w(t, "Failed to fetch public holidays")
return emptyList()
}
}

private fun parseLocalDate(localDate: Long): LocalDate? {
val s = localDate.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,26 @@
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 {

/**
* data.go.kr 공휴일(OpenAPI) 호출용 API.
*
* - 엔드포인트: SpcdeInfoService/getRestDeInfo
* - ServiceKey는 이미 URL 인코딩된 값으로 전달한다(`encoded = true`).
* - solYear/solMonth는 양력 기준 연/월이다.
* - `_type=json`으로 JSON 응답을 받는다.
*/
@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
}
7 changes: 7 additions & 0 deletions app/src/main/java/com/eatssu/android/di/DataModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.eatssu.android.data.remote.repository.MealRepositoryImpl
import com.eatssu.android.data.remote.repository.MenuRepositoryImpl
import com.eatssu.android.data.remote.repository.OauthRepositoryImpl
import com.eatssu.android.data.remote.repository.PartnershipRepositoryImpl
import com.eatssu.android.data.remote.repository.PublicHolidayRepositoryImpl
import com.eatssu.android.data.remote.repository.ReportRepositoryImpl
import com.eatssu.android.data.remote.repository.ReviewRepositoryImpl
import com.eatssu.android.data.remote.repository.UserRepositoryImpl
Expand All @@ -16,6 +17,7 @@ import com.eatssu.android.domain.repository.MealRepository
import com.eatssu.android.domain.repository.MenuRepository
import com.eatssu.android.domain.repository.OauthRepository
import com.eatssu.android.domain.repository.PartnershipRepository
import com.eatssu.android.domain.repository.PublicHolidayRepository
import com.eatssu.android.domain.repository.ReportRepository
import com.eatssu.android.domain.repository.ReviewRepository
import com.eatssu.android.domain.repository.UserRepository
Expand Down Expand Up @@ -72,4 +74,9 @@ abstract class DataModule {
internal abstract fun bindsFirebaseRemoteConfigRepository(
firebaseRemoteConfigRepositoryImpl: FirebaseRemoteConfigRepositoryImpl,
): FirebaseRemoteConfigRepository

@Binds
internal abstract fun bindsPublicHolidayRepository(
publicHolidayRepositoryImpl: PublicHolidayRepositoryImpl,
): PublicHolidayRepository
}
64 changes: 64 additions & 0 deletions app/src/main/java/com/eatssu/android/di/PublicHolidayModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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)
/** 공휴일 API 전용 Retrofit 구분자. */
annotation class PublicHolidayApi

/**
* 공휴일 API 전용 Retrofit/Service 제공 모듈.
*
* - 인증 토큰이 필요 없는 외부 API이므로 `@NoToken` OkHttpClient를 사용한다.
* - 키는 `BuildConfig.HOLIDAY_API_KEY`로 주입되며, 비어있을 수 있다(로컬 환경 등).
*/
@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,7 @@
package com.eatssu.android.domain.model

import com.eatssu.common.enums.Restaurant

data class MenuLoadResult(
val menuMap: Map<Restaurant, List<Menu>>,
)
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,9 @@
package com.eatssu.android.domain.repository

import com.eatssu.android.domain.model.PublicHoliday
import java.time.YearMonth

interface PublicHolidayRepository {

suspend fun getHolidays(yearMonth: YearMonth): List<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 }
}
}
Loading
Loading