更新日历功能
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
package holiday
|
||||
|
||||
// fallbackData 内置的 2024-2026 年中国法定节假日/调休数据,
|
||||
// 数据严格来自国务院办公厅历年《关于部分节假日安排的通知》原文。
|
||||
//
|
||||
// 用途:当外网接口不可达(内网部署、机房无外网出口、接口临时故障)且数据库
|
||||
// 中也没有对应年份数据时,用本表兜底,保证日历控件always能显示休/班标记。
|
||||
//
|
||||
// 维护约定:每年 11 月国务院办公厅发布次年通知后,按下述格式补齐新一年数据
|
||||
// (只补休假日与调休上班日两类,普通周末由前端按星期判定,不入表):
|
||||
//
|
||||
// notices = append(notices, yearNotice{Year: 2027, ...})
|
||||
//
|
||||
// 注意:这里只存"放假"与"补班"两类特殊日期;普通周六日不记录。
|
||||
var fallbackData = buildFallback()
|
||||
|
||||
// yearNotice 单一年度的放假安排,按通知原文逐条录入。
|
||||
type yearNotice struct {
|
||||
Year int
|
||||
// Holidays 放假日期区间:[开始日, 结束日],闭区间。
|
||||
// 结束日与开始日相同表示只放一天;空串表示结束日等于开始日。
|
||||
Holidays []dayRange
|
||||
// Workdays 调休上班日(周末补班)。
|
||||
Workdays []string
|
||||
}
|
||||
|
||||
type dayRange struct {
|
||||
Start string
|
||||
End string
|
||||
Name string
|
||||
}
|
||||
|
||||
// yearNotices 各界各类节日的原始安排。
|
||||
//
|
||||
// 数据来源:
|
||||
// - 2024:国办发明电〔2023〕7号
|
||||
// - 2025:国办发明电〔2024〕12号
|
||||
// - 2026:2025年11月4日国办通知
|
||||
var yearNotices = []yearNotice{
|
||||
{
|
||||
Year: 2024,
|
||||
Holidays: []dayRange{
|
||||
{Start: "2024-01-01", Name: "元旦"},
|
||||
{Start: "2024-02-10", End: "2024-02-17", Name: "春节"},
|
||||
{Start: "2024-04-04", End: "2024-04-06", Name: "清明节"},
|
||||
{Start: "2024-05-01", End: "2024-05-05", Name: "劳动节"},
|
||||
{Start: "2024-06-10", Name: "端午节"},
|
||||
{Start: "2024-09-15", End: "2024-09-17", Name: "中秋节"},
|
||||
{Start: "2024-10-01", End: "2024-10-07", Name: "国庆节"},
|
||||
},
|
||||
Workdays: []string{
|
||||
"2024-02-04", "2024-02-18",
|
||||
"2024-04-07",
|
||||
"2024-04-28", "2024-05-11",
|
||||
"2024-09-14",
|
||||
"2024-09-29", "2024-10-12",
|
||||
},
|
||||
},
|
||||
{
|
||||
Year: 2025,
|
||||
Holidays: []dayRange{
|
||||
{Start: "2025-01-01", Name: "元旦"},
|
||||
{Start: "2025-01-28", End: "2025-02-04", Name: "春节"},
|
||||
{Start: "2025-04-04", End: "2025-04-06", Name: "清明节"},
|
||||
{Start: "2025-05-01", End: "2025-05-05", Name: "劳动节"},
|
||||
{Start: "2025-05-31", End: "2025-06-02", Name: "端午节"},
|
||||
{Start: "2025-10-01", End: "2025-10-08", Name: "国庆节、中秋节"},
|
||||
},
|
||||
Workdays: []string{
|
||||
"2025-01-26", "2025-02-08",
|
||||
"2025-04-27",
|
||||
"2025-09-28", "2025-10-11",
|
||||
},
|
||||
},
|
||||
{
|
||||
Year: 2026,
|
||||
Holidays: []dayRange{
|
||||
{Start: "2026-01-01", End: "2026-01-03", Name: "元旦"},
|
||||
{Start: "2026-02-15", End: "2026-02-23", Name: "春节"},
|
||||
{Start: "2026-04-04", End: "2026-04-06", Name: "清明节"},
|
||||
{Start: "2026-05-01", End: "2026-05-05", Name: "劳动节"},
|
||||
{Start: "2026-06-19", End: "2026-06-21", Name: "端午节"},
|
||||
{Start: "2026-09-25", End: "2026-09-27", Name: "中秋节"},
|
||||
{Start: "2026-10-01", End: "2026-10-07", Name: "国庆节"},
|
||||
},
|
||||
Workdays: []string{
|
||||
"2026-01-04",
|
||||
"2026-02-14", "2026-02-28",
|
||||
"2026-05-09",
|
||||
"2026-09-20", "2026-10-10",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// buildFallback 把 yearNotices 展开成按日期索引的 Day 列表。
|
||||
func buildFallback() []Day {
|
||||
out := make([]Day, 0, 256)
|
||||
for _, n := range yearNotices {
|
||||
for _, r := range n.Holidays {
|
||||
for _, d := range expandRange(r.Start, r.End) {
|
||||
out = append(out, Day{
|
||||
Date: d,
|
||||
Name: r.Name,
|
||||
Type: TypeHoliday,
|
||||
Year: n.Year,
|
||||
Source: SourceBuiltin,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, d := range n.Workdays {
|
||||
out = append(out, Day{
|
||||
Date: d,
|
||||
Name: "调休上班",
|
||||
Type: TypeWorkday,
|
||||
Year: n.Year,
|
||||
Wage: 1,
|
||||
Source: SourceBuiltin,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package holiday
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestFallbackCovers2026 校验内置兜底数据与国务院办公厅 2026 年通知一致。
|
||||
// 这里的期望值直接抄自官方通知,属于"事实断言":一旦有人改错数据即失败。
|
||||
func TestFallbackCovers2026(t *testing.T) {
|
||||
got := builtinByYear(2026)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("2026 年内置数据为空")
|
||||
}
|
||||
|
||||
byDate := make(map[string]Day, len(got))
|
||||
for _, d := range got {
|
||||
byDate[d.Date] = d
|
||||
}
|
||||
|
||||
// 元旦 1/1-1/3 放假;1/4 上班
|
||||
for _, d := range []string{"2026-01-01", "2026-01-02", "2026-01-03"} {
|
||||
if v, ok := byDate[d]; !ok || v.Type != TypeHoliday {
|
||||
t.Errorf("%s 应为放假日,实际 %+v", d, v)
|
||||
}
|
||||
}
|
||||
if v, ok := byDate["2026-01-04"]; !ok || v.Type != TypeWorkday {
|
||||
t.Errorf("2026-01-04 应为调休上班日,实际 %+v", v)
|
||||
}
|
||||
|
||||
// 春节 2/15-2/23 放假,共 9 天;2/14、2/28 上班
|
||||
springCount := 0
|
||||
for _, d := range got {
|
||||
if d.Type == TypeHoliday && d.Name == "春节" {
|
||||
springCount++
|
||||
}
|
||||
}
|
||||
if springCount != 9 {
|
||||
t.Errorf("春节放假天数应为 9,实际 %d", springCount)
|
||||
}
|
||||
for _, d := range []string{"2026-02-14", "2026-02-28"} {
|
||||
if v, ok := byDate[d]; !ok || v.Type != TypeWorkday {
|
||||
t.Errorf("%s 应为春节调休上班日,实际 %+v", d, v)
|
||||
}
|
||||
}
|
||||
|
||||
// 国庆 10/1-10/7 放假;9/20、10/10 上班
|
||||
nationalCount := 0
|
||||
for _, d := range got {
|
||||
if d.Type == TypeHoliday && d.Name == "国庆节" {
|
||||
nationalCount++
|
||||
}
|
||||
}
|
||||
if nationalCount != 7 {
|
||||
t.Errorf("国庆放假天数应为 7,实际 %d", nationalCount)
|
||||
}
|
||||
for _, d := range []string{"2026-09-20", "2026-10-10"} {
|
||||
if v, ok := byDate[d]; !ok || v.Type != TypeWorkday {
|
||||
t.Errorf("%s 应为国庆调休上班日,实际 %+v", d, v)
|
||||
}
|
||||
}
|
||||
|
||||
// 中秋 9/25-9/27、端午 6/19-6/21、清明 4/4-4/6、劳动节 5/1-5/5
|
||||
cases := []struct {
|
||||
name string
|
||||
start string
|
||||
count int
|
||||
}{
|
||||
{"清明节", "2026-04-04", 3},
|
||||
{"劳动节", "2026-05-01", 5},
|
||||
{"端午节", "2026-06-19", 3},
|
||||
{"中秋节", "2026-09-25", 3},
|
||||
}
|
||||
for _, c := range cases {
|
||||
n := 0
|
||||
for _, d := range got {
|
||||
if d.Type == TypeHoliday && d.Name == c.name {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != c.count {
|
||||
t.Errorf("%s 放假天数应为 %d,实际 %d", c.name, c.count, n)
|
||||
}
|
||||
if v, ok := byDate[c.start]; !ok || v.Type != TypeHoliday {
|
||||
t.Errorf("%s 应为 %s 放假日,实际 %+v", c.start, c.name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFallbackAllYears 校验三年内置数据都能被索引到,且每年都有调休上班日。
|
||||
func TestFallbackAllYears(t *testing.T) {
|
||||
for _, y := range Years() {
|
||||
list := builtinByYear(y)
|
||||
if len(list) == 0 {
|
||||
t.Errorf("%d 年内置数据为空", y)
|
||||
continue
|
||||
}
|
||||
hasWork := false
|
||||
for _, d := range list {
|
||||
if d.Type == TypeWorkday {
|
||||
hasWork = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWork {
|
||||
t.Errorf("%d 年缺少调休上班日数据", y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnnotateWage 校验薪资倍数标注:春节前 3 天三薪,其余两薪。
|
||||
func TestAnnotateWage(t *testing.T) {
|
||||
list := []Day{
|
||||
{Date: "2026-02-15", Name: "春节", Type: TypeHoliday},
|
||||
{Date: "2026-02-16", Name: "春节", Type: TypeHoliday},
|
||||
{Date: "2026-02-17", Name: "春节", Type: TypeHoliday},
|
||||
{Date: "2026-02-18", Name: "春节", Type: TypeHoliday},
|
||||
{Date: "2026-02-19", Name: "春节", Type: TypeHoliday},
|
||||
}
|
||||
got := annotateWage(list)
|
||||
want := []int8{3, 3, 3, 2, 2}
|
||||
for i, w := range want {
|
||||
if got[i].Wage != w {
|
||||
t.Errorf("%s 薪资倍数应为 %d,实际 %d", got[i].Date, w, got[i].Wage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandRange 校验日期区间展开。
|
||||
func TestExpandRange(t *testing.T) {
|
||||
got := expandRange("2026-02-15", "2026-02-23")
|
||||
if len(got) != 9 {
|
||||
t.Fatalf("2/15-2/23 应展开 9 天,实际 %d", len(got))
|
||||
}
|
||||
if got[0] != "2026-02-15" || got[8] != "2026-02-23" {
|
||||
t.Errorf("区间首尾错误: %v", got)
|
||||
}
|
||||
// 单日
|
||||
if one := expandRange("2026-01-01", ""); len(one) != 1 || one[0] != "2026-01-01" {
|
||||
t.Errorf("单日区间展开错误: %v", one)
|
||||
}
|
||||
// 跨月
|
||||
if cross := expandRange("2026-02-28", "2026-03-02"); len(cross) != 3 {
|
||||
t.Errorf("跨月区间展开错误: %v", cross)
|
||||
}
|
||||
// 异常区间不 panic 且返回空
|
||||
if bad := expandRange("2026-03-01", "2026-02-01"); len(bad) != 0 {
|
||||
t.Errorf("结束早于开始应返回空,实际 %v", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupSort 校验同日去重时休假日优先于调休上班日。
|
||||
func TestDedupSort(t *testing.T) {
|
||||
list := []Day{
|
||||
{Date: "2026-05-09", Name: "调休上班", Type: TypeWorkday},
|
||||
{Date: "2026-05-01", Name: "劳动节", Type: TypeHoliday},
|
||||
{Date: "2026-05-09", Name: "劳动节", Type: TypeHoliday},
|
||||
}
|
||||
got := dedupSort(list)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("去重后应为 2 条,实际 %d", len(got))
|
||||
}
|
||||
if got[0].Date != "2026-05-01" {
|
||||
t.Errorf("排序错误,首条应为 05-01,实际 %s", got[0].Date)
|
||||
}
|
||||
if got[1].Type != TypeHoliday {
|
||||
t.Errorf("05-09 同日冲突时应保留休假日,实际 %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchYearLive 联网校验真实数据源可用性(仅测"未收录年份"的联网分支)。
|
||||
// 2024-2026 走内置数据不联网,因此这里用 2023 年验证外网拉取能力;
|
||||
// 若外网不可用则跳过,不把 CI 绑死在第三方接口可用性上。
|
||||
func TestFetchYearLive(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("跳过联网测试")
|
||||
}
|
||||
// 2023 未被内置数据收录,必须联网
|
||||
list, src, err := FetchYear(2023)
|
||||
if err != nil {
|
||||
t.Skipf("外网不可用,跳过联网测试: %v", err)
|
||||
}
|
||||
if src != SourceHolidayCN {
|
||||
t.Fatalf("2023 年应来自外网源,实际来源 %s", src)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
t.Fatal("2023 年数据为空")
|
||||
}
|
||||
t.Logf("2023 年数据来源: %s,共 %d 条", src, len(list))
|
||||
}
|
||||
|
||||
// TestBuiltinTakesPriority 校验已收录年份走内置数据、完全不联网。
|
||||
// 这是"日历翻月不卡顿"的核心保证:若哪天有人把网络查询挪到内置判断之前,
|
||||
// 本测试会立即失败。
|
||||
func TestBuiltinTakesPriority(t *testing.T) {
|
||||
for _, y := range []int{2024, 2025, 2026} {
|
||||
list, src, err := FetchYear(y)
|
||||
if err != nil {
|
||||
t.Fatalf("%d 年获取失败: %v", y, err)
|
||||
}
|
||||
if src != SourceBuiltin {
|
||||
t.Errorf("%d 年应命中内置数据(零网络请求),实际来源 %s", y, src)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
t.Errorf("%d 年数据为空", y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchYearFastForBuiltin 量化验证内置路径的耗时:
|
||||
// 已收录年份必须是纯内存操作(远低于 50ms),而跨境网络请求至少要 300ms。
|
||||
func TestFetchYearFastForBuiltin(t *testing.T) {
|
||||
// 预热,排除首次调用的初始化开销
|
||||
_, _, _ = FetchYear(2026)
|
||||
|
||||
start := time.Now()
|
||||
const rounds = 200
|
||||
for i := 0; i < rounds; i++ {
|
||||
if _, src, err := FetchYear(2026); err != nil || src != SourceBuiltin {
|
||||
t.Fatalf("第 %d 次调用异常: src=%s err=%v", i, src, err)
|
||||
}
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
perCall := elapsed / rounds
|
||||
if perCall > 50*time.Millisecond {
|
||||
t.Errorf("已收录年份单次耗时应远低于 50ms(不应联网),实际 %v", perCall)
|
||||
}
|
||||
t.Logf("2026 年(内置)单次平均耗时: %v,%d 次共 %v", perCall, rounds, elapsed)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package holiday
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 验证 2027 年(国务院通知尚未发布,各数据源为空数组)不会再被误判为失败
|
||||
// 而继续请求被墙的备用源。修复前实测单次约 6 秒,修复后应接近一次 CDN 往返。
|
||||
func TestFetchYear2027NoWasteRetry(t *testing.T) {
|
||||
// 清空内存缓存,确保走完整路径
|
||||
memMu.Lock()
|
||||
memCache = map[int][]Day{}
|
||||
memMu.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
list, src, err := FetchYear(2027)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
t.Logf("2027 首次拉取: %v, src=%q, err=%v, 条数=%d", elapsed, src, err, len(list))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("2027 不应返回错误(空年份是合法状态): %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Logf("提示: 2027 已有数据(上游发布了通知),条数=%d", len(list))
|
||||
}
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("2027 拉取耗时 %v,疑似仍在重试备用源", elapsed)
|
||||
}
|
||||
|
||||
// 第二次必须命中内存缓存,几乎零耗时
|
||||
start2 := time.Now()
|
||||
list2, _, err2 := FetchYear(2027)
|
||||
elapsed2 := time.Since(start2)
|
||||
t.Logf("2027 二次拉取(走缓存): %v, 条数=%d", elapsed2, len(list2))
|
||||
if err2 != nil {
|
||||
t.Fatalf("缓存命中不应报错: %v", err2)
|
||||
}
|
||||
if elapsed2 > 50*time.Millisecond {
|
||||
t.Fatalf("二次拉取应命中内存缓存,实测 %v", elapsed2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user