232 lines
8.0 KiB
Kotlin
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
|
||
|
|
)
|
||
|
|
}
|