更新日历功能

This commit is contained in:
2026-09-24 15:14:30 +08:00
parent c5ed596008
commit bc9e32d62a
13 changed files with 1446 additions and 6 deletions
+257
View File
@@ -0,0 +1,257 @@
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
}