Files
yunzerwebsiteallinone/go/pkg/holiday/fetch.go
T
2026-09-24 15:14:30 +08:00

258 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package holiday
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
// =============================================================
// 外网数据源拉取(仅在"内置数据未覆盖该年份"时才会走到这里)
//
// 数据源是 holiday-cn(NateScarlet/holiday-cn):其数据由脚本从国务院办公厅
// 通知原文生成,JSON 内的 papers 字段直接给出对应的 gov.cn 通知链接,
// 字段精简(date/name/isOffDay),每年 11 月通知发布后由维护者及时更新。
//
// 实测延迟(2026-09,本机):
// - fastly.jsdelivr.net 0.43s ← 采用,TLS 握手最快
// - cdn.jsdelivr.net 0.85s
// - raw.githubusercontent 0.65s
// - timor.tech 已被 Cloudflare 拦截(403 + HTML 挑战页),已移除
//
// 注意:不要用 apihubs.cn 之类的国内接口做主力——实测它有严格频率限制,
// 连续请求会直接返回「请求过于频繁」,前端每次翻月都会打,很容易被限流。
// 对已收录的年份,我们根本不走网络(见 FetchYear),因此无需冒这个风险。
// =============================================================
const (
// sourceHolidayCNURL holiday-cn 按年份提供的 JSON,{year} 为年份占位符。
// 用 fastly 节点的 jsDelivr,实测 TLS 握手比默认 cdn 节点快一倍。
sourceHolidayCNURL = "https://fastly.jsdelivr.net/gh/NateScarlet/holiday-cn@master/%d.json"
// sourceHolidayCNMirrorURL 备用地址(同一内容,走 GitHub 原始域名)。
sourceHolidayCNMirrorURL = "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/%d.json"
)
var httpClient = &http.Client{Timeout: 6 * time.Second}
// holidayCNPayload holiday-cn 的 JSON 结构。
type holidayCNPayload struct {
Year int `json:"year"`
Days []struct {
Name string `json:"name"`
Date string `json:"date"`
IsOffDay bool `json:"isOffDay"`
} `json:"days"`
}
// fetchYearFrom 从某个数据源拉取一整年的节假日数据。
// 返回的 src 为该数据源的来源标记,便于写入缓存时记录出处。
func fetchYearFrom(year int, url string, src string) ([]Day, error) {
body, err := httpGet(fmt.Sprintf(url, year))
if err != nil {
return nil, err
}
var p holidayCNPayload
if err := json.Unmarshal(body, &p); err != nil {
return nil, fmt.Errorf("解析 holiday-cn 数据失败: %w", err)
}
out := make([]Day, 0, len(p.Days))
for _, d := range p.Days {
if d.Date == "" {
continue
}
t := TypeWorkday
if d.IsOffDay {
t = TypeHoliday
}
name := d.Name
if name == "" {
name = "节假日"
}
out = append(out, Day{
Date: normalizeDate(d.Date),
Name: name,
Type: t,
Year: year,
Source: src,
})
}
return finalize(out), nil
}
// httpGet 带基本校验的 GET:拒绝非 JSON 响应。
// 校验响应体而不只看状态码,是因为 CDN/网关在异常时可能返回 HTTP 200 +
// 一页 HTML 错误页(timor.tech 被 Cloudflare 拦截时就是这种表现),
// 若只判状态码会误认为成功,进而把 HTML 当 JSON 解析失败或写入脏数据。
func httpGet(url string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "yunzer-oa/1.0")
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
// 限制读取上限,避免异常响应把内存打满
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
trimmed := trimSpaceBytes(body)
if len(trimmed) == 0 || (trimmed[0] != '{' && trimmed[0] != '[') {
return nil, fmt.Errorf("响应不是 JSON(可能被拦截或代理改写了内容)")
}
return trimmed, nil
}
func trimSpaceBytes(b []byte) []byte {
start := 0
for start < len(b) && (b[start] == ' ' || b[start] == '\n' || b[start] == '\r' || b[start] == '\t' || b[start] == 0xEF || b[start] == 0xBB || b[start] == 0xBF) {
start++
}
end := len(b)
for end > start && (b[end-1] == ' ' || b[end-1] == '\n' || b[end-1] == '\r' || b[end-1] == '\t') {
end--
}
return b[start:end]
}
// finalize 归一化一份数据:去重、排序、补薪资倍数。
func finalize(list []Day) []Day {
out := dedupSort(list)
return annotateWage(out)
}
// ============================ 进程内缓存 ============================
// 说明:进程内缓存只用于避免同一次页面请求里对同一年的重复拉取,
// 真正的持久化缓存在数据库(由控制器负责读写)。两者职责不同:
// - 内存缓存:短期、防抖,进程重启即失效;
// - 数据库缓存:长期、可复用,外网不可达时的第一顺位数据源。
var (
memMu sync.RWMutex
memCache = map[int][]Day{}
)
// getMemYear 读内存缓存;命中的年份直接返回,不再走网络。
func getMemYear(year int) ([]Day, bool) {
memMu.RLock()
defer memMu.RUnlock()
v, ok := memCache[year]
return v, ok
}
func setMemYear(year int, list []Day) {
memMu.Lock()
defer memMu.Unlock()
memCache[year] = list
}
// FetchYear 获取某一年的节假日数据。调用方(控制器)拿到结果后负责写入数据库缓存。
//
// 优先级(关键:已收录的年份完全不联网):
// 1. 内存缓存;
// 2. 内置数据(2024-2026 已收录)——直接返回,零网络请求、零延迟;
// 3. 外网拉取(仅年份未收录时)——fastly.jsdelivr → GitHub raw。
//
// 之所以把内置数据排在联网之前,是因为它本身就是官方通知的抄录、与网络数据
// 等价,却省掉了 0.4~0.9 秒的跨境 TLS 握手。日历每次翻月都会调用本函数,
// 若每次都联网会造成明显的卡顿。
func FetchYear(year int) (list []Day, src string, err error) {
if year < 2000 || year > 2100 {
return nil, "", fmt.Errorf("年份超出合理范围: %d", year)
}
if cached, ok := getMemYear(year); ok {
return cached, sourceOf(cached), nil
}
// 优先用内置数据:命中即返回,不发起任何网络请求。
if builtin := builtinByYear(year); len(builtin) > 0 {
finalized := finalize(builtin)
setMemYear(year, finalized)
return finalized, SourceBuiltin, nil
}
// 内置未收录该年份(通常是次年通知尚未发布或需更早年份),才走网络。
attempts := []struct {
url string
src string
}{
{sourceHolidayCNURL, SourceHolidayCN},
{sourceHolidayCNMirrorURL, SourceHolidayCN},
}
var lastErr error
for _, a := range attempts {
got, ferr := fetchYearFrom(year, a.url, a.src)
if ferr != nil {
// 真正的失败(网络错误 / 响应非法)才换下一个源重试。
lastErr = ferr
continue
}
// 请求成功但数据为空,是正常状态而非失败:国务院通常在头一年 11 月
// 左右发布次年安排,此前各数据源的该年份就是空数组。
// 这里必须当作"已确认无数据"缓存下来并直接返回,
// 否则会被误判为失败、继续请求被墙的备用源,白白卡满超时(实测 6 秒)。
setMemYear(year, got)
return got, a.src, nil
}
return nil, "", fmt.Errorf("获取 %d 年节假日数据失败: %v", year, lastErr)
}
// sourceOf 取一组数据的来源标记(同批数据来源一致,取首条即可)。
func sourceOf(list []Day) string {
if len(list) == 0 {
return ""
}
return list[0].Source
}
// EnsureRanges 确保给定日期区间涉及的每个年份都有数据可用,
// 返回区间内(含)的完整数据(已按日期升序)。数据缺失的年份静默跳过。
func EnsureRanges(start, end string) []Day {
startYear, okS := yearOf(start)
endYear, okE := yearOf(end)
if !okS || !okE || endYear < startYear {
return nil
}
out := make([]Day, 0, 64)
for y := startYear; y <= endYear; y++ {
list, _, err := FetchYear(y)
if err != nil {
// 该年份不可用(外网失败且无内置数据),跳过但不阻断其他年份
continue
}
out = append(out, list...)
}
filtered := make([]Day, 0, len(out))
for _, d := range out {
if d.Date >= start && d.Date <= end {
filtered = append(filtered, d)
}
}
return finalize(filtered)
}
// yearOf 从 "YYYY-MM-DD" 解析年份。
func yearOf(date string) (int, bool) {
if len(date) < 4 {
return 0, false
}
y, err := time.Parse("2006", date[:4])
if err != nil {
return 0, false
}
return y.Year(), true
}