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 }