Files
hoperun-custom-sign/src/main/kotlin/com/pomelotea/hoperun/sign/holiday/HolidayService.kt
T
2026-03-19 14:53:40 +08:00

232 lines
8.0 KiB
Kotlin

package com.pomelotea.hoperun.sign.holiday
import com.alibaba.fastjson.JSON
import okhttp3.OkHttpClient
import okhttp3.Request
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.DayOfWeek
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
import javax.annotation.PostConstruct
@Service
class HolidayService(
private val config: HolidayConfig
) {
private val logger = LoggerFactory.getLogger(HolidayService::class.java)
private val cache = ConcurrentHashMap<Int, HolidayCalendar>()
private val dateFormatter = DateTimeFormatter.ISO_LOCAL_DATE
private val httpClient = OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(10))
.callTimeout(Duration.ofSeconds(10))
.build()
@PostConstruct
fun preload() {
if (!config.enabled) {
return
}
val now = LocalDate.now()
val endYear = now.year + config.yearsAhead
logger.info("节假日配置预加载开始: years={}..{}", now.year, endYear)
for (year in now.year..endYear) {
runCatching { getCalendar(year) }
.onFailure { logger.warn("节假日配置预加载失败: year={}, err={}", year, it.message) }
}
}
fun isWorkingDay(dateStr: String): Boolean {
val date = LocalDate.parse(dateStr, dateFormatter)
return isWorkingDay(date)
}
fun isWorkingDay(date: LocalDate): Boolean {
if (!config.enabled) {
return defaultIsWorkingDay(date)
}
val dateKey = date.format(dateFormatter)
val calendar = getCalendar(date.year)
if (calendar.workdays.contains(dateKey)) {
return true
}
if (calendar.holidays.contains(dateKey)) {
return false
}
return defaultIsWorkingDay(date)
}
private fun defaultIsWorkingDay(date: LocalDate): Boolean {
return date.dayOfWeek != DayOfWeek.SATURDAY && date.dayOfWeek != DayOfWeek.SUNDAY
}
private fun getCalendar(year: Int): HolidayCalendar {
val now = Instant.now()
val cached = cache[year]
if (cached != null && !isExpired(cached.updatedAt, now)) {
return cached
}
val remoteResult = fetchRemote(year)
if (remoteResult != null) {
val calendar = applyFallback(parse(remoteResult.body)).copy(updatedAt = now)
cache[year] = calendar
writeCache(year, remoteResult.body)
logger.info(
"节假日配置加载成功(远程): year={}, url={}, holidays={}, workdays={}",
year,
remoteResult.url,
calendar.holidays.size,
calendar.workdays.size
)
return calendar
}
if (cached != null) {
val refreshed = cached.copy(updatedAt = now)
cache[year] = refreshed
logger.warn(
"节假日远程刷新失败,继续使用内存缓存: year={}, holidays={}, workdays={}",
year,
refreshed.holidays.size,
refreshed.workdays.size
)
return refreshed
}
val cachedRaw = readCache(year)
if (cachedRaw != null) {
val calendar = applyFallback(parse(cachedRaw)).copy(updatedAt = now)
cache[year] = calendar
logger.info(
"节假日配置加载成功(本地缓存): year={}, path={}, holidays={}, workdays={}",
year,
cachePath(year).toString(),
calendar.holidays.size,
calendar.workdays.size
)
return calendar
}
logger.warn("节假日配置缺失,仅使用fallback: year={}", year)
val fallbackCalendar = applyFallback(HolidayCalendar(emptySet(), emptySet(), now))
cache[year] = fallbackCalendar
return fallbackCalendar
}
private fun isExpired(updatedAt: Instant, now: Instant): Boolean {
if (config.cacheTtlDays <= 0) {
return false
}
return Duration.between(updatedAt, now).toDays() >= config.cacheTtlDays
}
private fun fetchRemote(year: Int): FetchResult? {
val urls = buildUrls(year)
for (url in urls) {
try {
val request = Request.Builder().url(url).get().build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
logger.warn("节假日配置拉取失败: url={}, code={}", url, response.code)
return@use
}
val body = response.body?.string()
if (!body.isNullOrBlank()) {
return FetchResult(url = url, body = body)
}
logger.warn("节假日配置拉取为空: url={}", url)
}
} catch (ex: Exception) {
logger.warn("节假日配置拉取异常: url={}, err={}", url, ex.message)
}
}
return null
}
private fun buildUrls(year: Int): List<String> {
if (config.sources.isEmpty() && config.proxySources.isEmpty()) {
return emptyList()
}
val urls = ArrayList<String>()
config.sources.forEach { urls.add(it.replace("{year}", year.toString())) }
config.proxySources.forEach { urls.add(it.replace("{year}", year.toString())) }
return urls
}
private fun parse(raw: String): HolidayCalendar {
val entries = JSON.parseArray(raw, HolidayEntry::class.java) ?: emptyList()
val holidays = LinkedHashSet<String>()
val workdays = LinkedHashSet<String>()
for (entry in entries) {
val date = entry.date?.trim()
if (date.isNullOrEmpty()) {
continue
}
when (entry.isOffDay) {
true -> holidays.add(date)
false -> workdays.add(date)
else -> {}
}
}
return HolidayCalendar(holidays, workdays, Instant.now())
}
private fun applyFallback(calendar: HolidayCalendar): HolidayCalendar {
if (config.fallback.holidays.isEmpty() && config.fallback.workdays.isEmpty()) {
return calendar
}
val holidays = calendar.holidays.toMutableSet()
val workdays = calendar.workdays.toMutableSet()
for (date in config.fallback.holidays) {
holidays.add(date)
workdays.remove(date)
}
for (date in config.fallback.workdays) {
workdays.add(date)
holidays.remove(date)
}
return calendar.copy(holidays = holidays, workdays = workdays)
}
private fun cachePath(year: Int): Path {
return Paths.get(config.cacheDir).resolve("$year.json")
}
private fun writeCache(year: Int, raw: String) {
try {
val path = cachePath(year)
Files.createDirectories(path.parent)
Files.write(path, raw.toByteArray(StandardCharsets.UTF_8))
logger.info("节假日缓存写入成功: year={}, path={}", year, path.toString())
} catch (ex: Exception) {
logger.warn("节假日缓存写入失败: year={}, err={}", year, ex.message)
}
}
private fun readCache(year: Int): String? {
return try {
val path = cachePath(year)
if (!Files.exists(path)) {
return null
}
String(Files.readAllBytes(path), StandardCharsets.UTF_8)
} catch (ex: Exception) {
logger.warn("节假日缓存读取失败: year={}, err={}", year, ex.message)
null
}
}
private data class FetchResult(
val url: String,
val body: String
)
}