// Package holiday 提供中国法定节假日/调休日数据。 // // 数据获取优先级(从高到低): // 1. 内存缓存; // 2. 代码内置的 2024-2026 年数据(fallback.go,抄自国务院办公厅通知原文)—— // 命中即返回,不发起任何网络请求; // 3. 数据库缓存(yz_backend_oa_holiday,由控制器读写); // 4. 外网开源接口(holiday-cn,仅内置未收录的年份才会走到)。 // // 把内置数据放在联网之前是刻意的性能设计:日历每次翻月都会请求,而跨境 // 请求的 TLS 握手就要 0.4~0.9 秒;内置数据与网络数据同源等价,直接用即可。 // 本包只处理"休假日"与"调休上班日"两类特殊日期,普通周末由前端按星期判定。 package holiday import ( "time" ) // 日期类型。只有"休假日"与"调休上班日"两类需要落库—— // 普通周末可以直接由星期几推算(周六日即周末),无需存储。 const ( // TypeWorkday 调休上班日(周末补班),前端显示「班」角标 TypeWorkday int8 = 0 // TypeHoliday 法定节假日放假,前端显示「休」角标 TypeHoliday int8 = 1 ) // 数据来源标记,写入数据库 source 列,便于排查某年份数据是哪来的。 const ( SourceHolidayCN = "holiday-cn" // 外网源:cdn(fastly).jsdelivr.net/gh/NateScarlet/holiday-cn SourceBuiltin = "builtin" // 代码内置数据(2024-2026,官方通知抄录) ) // Day 某一天节假日信息的统一内部表示,也是写入缓存与返回前端的结构。 type Day struct { // Date "YYYY-MM-DD" Date string `json:"date"` // Name 节日名称,如「国庆节」「春节」;调休上班日固定为「调休上班」 Name string `json:"name"` // Type 0 调休上班 / 1 法定放假;普通周末不入库、不返回 Type int8 `json:"type"` // Year 所属年份 Year int `json:"year"` // Wage 薪资倍数:1 普通,2 双倍,3 三倍(仅法定节假日当天为 3) Wage int8 `json:"wage"` // Source 数据来源标记 Source string `json:"source"` } // IsOffDay 是否休假日(法定放假)。注意普通周末不在本结构范围内, // 调用方判断"这天是否休息"时必须把周末一起算上。 func (d Day) IsOffDay() bool { return d.Type == TypeHoliday } // dateLayout 统一的日期字符串格式。 const dateLayout = "2006-01-02" func parseDate(s string) (time.Time, error) { return time.ParseInLocation(dateLayout, s, time.Local) } // normalizeDate 把可能带时分秒的时间串统一裁剪成 "YYYY-MM-DD"。 func normalizeDate(s string) string { if len(s) > 10 { return s[:10] } return s } // expandRange 展开 [start, end] 闭区间为逐日列表;end 为空时视为仅 start 一天。 func expandRange(start, end string) []string { if end == "" { end = start } st, err := parseDate(normalizeDate(start)) if err != nil { return nil } et, err := parseDate(normalizeDate(end)) if err != nil { return nil } out := make([]string, 0, 16) // 防御:区间异常(结束早于开始)或跨度过大时直接放弃,避免死循环 if et.Before(st) { return nil } if et.Sub(st) > 60*24*time.Hour { return nil } for d := st; !d.After(et); d = d.AddDate(0, 0, 1) { out = append(out, d.Format(dateLayout)) } return out } // wageOfHolidayName 依据节日名与日期在假期中的位置推断倍数。 // 春节、国庆按"第几天"判断:前 3 天为三薪;其余节日首日为三薪。 func wageOfHolidayName(name string, indexInRange int) int8 { switch name { case "春节", "国庆节", "国庆节、中秋节": if indexInRange < 3 { return 3 } return 2 default: if indexInRange == 0 { return 3 } return 2 } } // Years 返回内置兜底数据覆盖的年份,用于告知调用方"网络与缓存都没有时 // 还能覆盖到哪一年",便于在接口响应里给出提示。 func Years() []int { seen := make(map[int]bool, len(yearNotices)) out := make([]int, 0, len(yearNotices)) for _, n := range yearNotices { if !seen[n.Year] { seen[n.Year] = true out = append(out, n.Year) } } return out } // builtinByYear 取某年份的内置数据;年份未收录时返回空切片。 func builtinByYear(year int) []Day { out := make([]Day, 0, 32) for _, d := range fallbackData { if d.Year == year { out = append(out, d) } } return out } // annotateWage 依据同一节日的连续区间,为每天补上正确的薪资倍数。 // 输入需按日期升序排列;同名的连续日期视为同一次放假。 func annotateWage(list []Day) []Day { if len(list) == 0 { return list } prevName := "" indexInRange := 0 for i := range list { if list[i].Type != TypeHoliday { // 调休上班日与普通日期不参与倍数计算,同时打断连续区间 prevName = "" indexInRange = 0 list[i].Wage = 1 continue } if list[i].Name != prevName { prevName = list[i].Name indexInRange = 0 } else { indexInRange++ } list[i].Wage = wageOfHolidayName(list[i].Name, indexInRange) } return list } // dedupSort 按日期去重并升序排列;同日重复时保留先出现的记录。 func dedupSort(list []Day) []Day { seen := make(map[string]int, len(list)) out := make([]Day, 0, len(list)) for _, d := range list { if d.Date == "" { continue } if idx, ok := seen[d.Date]; ok { // 已有同日记录:休假日优先于调休上班日(同一日期不会既休又班, // 但外网源偶有脏数据,这里做防御性取舍) if d.Type == TypeHoliday && out[idx].Type == TypeWorkday { out[idx] = d } continue } seen[d.Date] = len(out) out = append(out, d) } sortByDate(out) return out } func sortByDate(list []Day) { // 数据量极小(单年最多几十条),直接插入排序,避免引入 sort 包的比较器开销 for i := 1; i < len(list); i++ { cur := list[i] j := i - 1 for j >= 0 && list[j].Date > cur.Date { list[j+1] = list[j] j-- } list[j+1] = cur } }