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() 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 { if (config.sources.isEmpty() && config.proxySources.isEmpty()) { return emptyList() } val urls = ArrayList() 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() val workdays = LinkedHashSet() 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 ) }