258 lines
8.4 KiB
Go
258 lines
8.4 KiB
Go
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
|
||
}
|