更新日历功能
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user