From bc9e32d62a118dc146536943f144190a556cadc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com> Date: Thu, 24 Sep 2026 15:14:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=97=A5=E5=8E=86=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/oaSchedule.js | 12 + .../apps/oa/schedule/components/edit.vue | 7 +- backend/src/views/apps/oa/schedule/index.vue | 351 +++++++++++++++++- go/controllers/backend_oa_schedule.go | 157 ++++++++ go/docs/sql/create_oa_holiday.sql | 36 ++ go/models/init.go | 1 + go/models/oa_holiday.go | 33 ++ go/pkg/holiday/fallback.go | 122 ++++++ go/pkg/holiday/fetch.go | 257 +++++++++++++ go/pkg/holiday/holiday.go | 201 ++++++++++ go/pkg/holiday/holiday_test.go | 229 ++++++++++++ go/pkg/holiday/year2027_probe_test.go | 43 +++ go/routers/backend/backend.go | 3 + 13 files changed, 1446 insertions(+), 6 deletions(-) create mode 100644 go/docs/sql/create_oa_holiday.sql create mode 100644 go/models/oa_holiday.go create mode 100644 go/pkg/holiday/fallback.go create mode 100644 go/pkg/holiday/fetch.go create mode 100644 go/pkg/holiday/holiday.go create mode 100644 go/pkg/holiday/holiday_test.go create mode 100644 go/pkg/holiday/year2027_probe_test.go diff --git a/backend/src/api/oaSchedule.js b/backend/src/api/oaSchedule.js index 82f87f1..0f5fe4d 100644 --- a/backend/src/api/oaSchedule.js +++ b/backend/src/api/oaSchedule.js @@ -83,3 +83,15 @@ export function carryPendingSchedules(data) { data }) } + +// 中国法定节假日/调休日 +// 返回 { list: [{date, name, type, year, wage}], builtin_years: [..] } +// type: 0 调休上班(周末补班,显示「班」);1 法定放假(显示「休」) +// 传 start_date + end_date 查区间(日历按 42 格查询),或传 year 查整年。 +export function getScheduleHolidays(params) { + return request({ + url: '/backend/oa/holiday', + method: 'get', + params + }) +} diff --git a/backend/src/views/apps/oa/schedule/components/edit.vue b/backend/src/views/apps/oa/schedule/components/edit.vue index c265874..bbae3fd 100644 --- a/backend/src/views/apps/oa/schedule/components/edit.vue +++ b/backend/src/views/apps/oa/schedule/components/edit.vue @@ -176,6 +176,11 @@ const props = defineProps({ draft: { type: Object, default: null + }, + // 新建时预填的标题(如从节假日快捷新建,预填节日名称) + prefillTitle: { + type: String, + default: "" } }); @@ -367,7 +372,7 @@ function initForm() { } } else { editingId.value = 0; - form.title = ""; + form.title = props.prefillTitle || ""; form.schedule_date = props.defaultDate || ""; form.all_day = true; form.start_time = ""; diff --git a/backend/src/views/apps/oa/schedule/index.vue b/backend/src/views/apps/oa/schedule/index.vue index 19a1d86..2c4251d 100644 --- a/backend/src/views/apps/oa/schedule/index.vue +++ b/backend/src/views/apps/oa/schedule/index.vue @@ -92,18 +92,27 @@ 'other-month': !cell.inMonth, today: cell.isToday, selected: cell.date === selectedDate, - 'has-items': cell.items.length > 0 + 'has-items': cell.items.length > 0, + 'is-off': cell.tag === 'off', + 'is-work': cell.tag === 'work' }" @click="selectedDate = cell.date" @dblclick="openCreate(cell.date)" >
{{ cell.day }} + 休 + 班 今 {{ cell.month }}月
+
+ {{ cell.holidayName }} +
-
{{ selectedDateLabel }}
+
+ {{ selectedDateLabel }} + + {{ selectedHoliday.type === 1 ? "休" : "班" }} + {{ selectedHoliday.type === 1 ? selectedHoliday.name : "调休上班" }} + + 周末 +
{{ selectedItems.length }} 项日程
+ 节日日程 @@ -410,6 +441,7 @@ import { carrySchedule, deleteSchedule, finishSchedule, + getScheduleHolidays, getScheduleList, getScheduleStats } from "@/api/oaSchedule"; @@ -484,6 +516,120 @@ const calendarMonth = ref(now.getMonth() + 1); const calendarItems = ref([]); const selectedDate = ref(todayStr()); +// ---------- 中国法定节假日 / 调休 ---------- +// 后端返回 { 'YYYY-MM-DD': {name, type, wage} },type: 0 调休上班 / 1 法定放假。 +// 普通周末不在此表内,由 isWeekend 按星期判定,避免与节假日数据重复。 +const holidayMap = ref({}); + +// 已成功加载节假日的年份(只有真正拿到该年数据才记入),避免重复请求。 +// 用 ref 而非裸 Set:翻月时若命中缓存可跳过请求,但 UI 仍需感知变化。 +// 这里用普通 Set 即可——是否要发请求由 loadHolidays 判断,不参与模板渲染。 +const loadedHolidayYears = new Set(); +// 正在请求中的年份,防止同一年的并发重复请求(快速连点翻月时会出现) +const loadingHolidayYears = new Set(); + +// 观察日历当前可见月份,月份一变就确保该月所需年份的节假日已加载。 +// 这样即使某次请求失败、或用户先翻到别处再翻回来,也能自动补齐, +// 不依赖"上一次翻月恰好成功"这一脆弱前提。 +watch( + [calendarYear, calendarMonth], + () => { + const start = calendarRangeStart(); + const end = new Date( + start.getFullYear(), + start.getMonth(), + start.getDate() + 41 + ); + loadHolidays(fmtDate(start), fmtDate(end)); + }, + { immediate: true } +); + +async function loadHolidays(start, end) { + // 42 格窗口最多横跨两个年份,取首尾年份去重即可 + const years = [start.slice(0, 4), end.slice(0, 4)].filter( + (y, i, arr) => arr.indexOf(y) === i + ); + // 整年拉取而不是只拉 42 格窗口:一次请求即可覆盖该年 12 个月的翻页。 + const pending = years.filter( + y => !loadedHolidayYears.has(y) && !loadingHolidayYears.has(y) + ); + if (pending.length === 0) { + return; + } + pending.forEach(y => loadingHolidayYears.add(y)); + try { + const results = await Promise.all( + pending.map(y => getScheduleHolidays({ year: y })) + ); + // 注意:即使接口返回空 list,只要 code===200 也视为该年"已加载"。 + // 否则未收录的年份(后端也查不到)会被反复请求,且空年份永远无法标记。 + const map = { ...holidayMap.value }; + const okYears = []; + for (let i = 0; i < results.length; i++) { + const res = results[i]; + if (res?.code !== 200) { + continue; + } + okYears.push(pending[i]); + for (const h of res.data?.list || []) { + map[h.date] = { name: h.name, type: h.type, wage: h.wage }; + } + } + // 只要有任一年成功就落一次地图,保证 UI 拿到最新数据 + if (okYears.length > 0) { + holidayMap.value = map; + okYears.forEach(y => loadedHolidayYears.add(y)); + } + } catch { + // 节假日属于辅助信息,接口失败时静默降级:日历仍可用,只是不显示休/班标记 + } finally { + // 无论成败都解除加载中标记:失败时下次翻月会重试,避免永久卡住不再请求 + pending.forEach(y => loadingHolidayYears.delete(y)); + } +} + +// 是否周末(周六/周日)。节假日数据里不含普通周末,故需单独判断。 +function isWeekend(date) { + const parts = String(date || "") + .slice(0, 10) + .split("-") + .map(Number); + if (parts.length !== 3 || parts.some(Number.isNaN)) { + return false; + } + const day = new Date(parts[0], parts[1] - 1, parts[2]).getDay(); + return day === 0 || day === 6; +} + +// 某天的日期类型标记:off(放假)/ work(调休上班)/ weekend(普通周末)/ '' +// 调休上班优先于周末——补班的周六实际是工作日。 +function dayTag(date) { + const h = holidayMap.value[date]; + if (h) { + return h.type === 1 ? "off" : "work"; + } + return isWeekend(date) ? "weekend" : ""; +} + +// 某天的节日名称:调休上班日不显示名称(否则整月周末补班都挂"调休上班",过于噪音) +function holidayName(date) { + const h = holidayMap.value[date]; + if (!h || h.type !== 1) { + return ""; + } + return h.name || ""; +} + +// 选中日期的节日信息,供右侧面板展示 +const selectedHoliday = computed(() => holidayMap.value[selectedDate.value] || null); + +// 选中日期是否休息日(法定放假或普通周末),用于侧栏文案与配色 +const selectedIsRestDay = computed(() => { + const tag = dayTag(selectedDate.value); + return tag === "off" || tag === "weekend"; +}); + // 日历 42 格(6 行 x 7 列,周一起始) const calendarCells = computed(() => { const map = calendarMap.value; @@ -503,7 +649,10 @@ const calendarCells = computed(() => { month: d.getMonth() + 1, inMonth: d.getMonth() + 1 === calendarMonth.value, isToday: date === today, - items: map[date] || [] + items: map[date] || [], + // 节假日相关:tag 用于格内角标与整格底色,holidayName 用于节日名文案 + tag: dayTag(date), + holidayName: holidayName(date) }); } return cells; @@ -581,9 +730,13 @@ async function loadCalendarData() { start.getMonth(), start.getDate() + 41 ); + const startStr = fmtDate(start); + const endStr = fmtDate(end); + // 节假日不在这里加载:由上方 watch([calendarYear, calendarMonth]) 统一驱动, + // 保证"翻月即加载对应月份所需年份",且首次挂载、二次进入都能自动补齐。 const res = await getScheduleList({ - start_date: fmtDate(start), - end_date: fmtDate(end), + start_date: startStr, + end_date: endStr, page: 1, pageSize: 500, category: category.value @@ -684,6 +837,8 @@ watch(viewMode, mode => { // ---------- 详情/新增/编辑/操作 ---------- const editVisible = ref(false); const editingItem = ref(null); +// 新建时预填的标题(节假日快捷新建使用) +const prefillTitle = ref(""); const detailVisible = ref(false); const detailItem = ref(null); // 从通知设置页返回时恢复的草稿(含提醒设置) @@ -691,12 +846,24 @@ const pendingDraft = ref(null); function openCreate(date) { editingItem.value = null; + prefillTitle.value = ""; if (date) { selectedDate.value = date; } editVisible.value = true; } +// 从节假日快捷新建:以节日名预填标题,日期取当前选中日 +function openHolidayCreate() { + const h = selectedHoliday.value; + if (!h || h.type !== 1) { + return; + } + editingItem.value = null; + prefillTitle.value = h.name || "节假日"; + editVisible.value = true; +} + function openEdit(item) { editingItem.value = item; editVisible.value = true; @@ -1174,6 +1341,96 @@ onMounted(() => { color: #409eff; } +/* 节假日:休/班 角标。休=红(放假),班=灰(调休补班),与国内日历习惯一致 */ +.holiday-tag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 3px; + border-radius: 3px; + font-size: 10px; + line-height: 1; + flex-shrink: 0; +} + +.holiday-tag.is-off { + background: #fef0f0; + color: #f56c6c; + border: 1px solid #fde2e2; +} + +.holiday-tag.is-work { + background: #f4f4f5; + color: #909399; + border: 1px solid #e9e9eb; +} + +/* 放假日整格浅红底,一眼区分连续假期 */ +.calendar-cell.is-off { + background: #fffbfb; +} + +.calendar-cell.is-off:hover { + background: #fff5f5; +} + +/* 调休上班日的周六日按工作日处理,弱化底色避免误判为休息 */ +.calendar-cell.is-work { + background: #fff; +} + +/* 今天:整格实蓝底,最醒目。放在 is-off / is-work 之后, + 保证今天即使逢节假日也保留蓝色标识。 + 实底较深,格内文字统一转白(或白底反白)以保证对比度。 */ +.calendar-cell.today { + background: #409eff; +} + +.calendar-cell.today:hover { + background: #66b1ff; +} + +/* 日期数字改为白底蓝字,避免在蓝底上"蓝圆叠蓝底"糊成一片 */ +.calendar-cell.today .day-num { + background: #fff; + color: #409eff; +} + +.calendar-cell.today .cell-holiday, +.calendar-cell.today .today-tag { + color: #fff; +} + +/* 休/班角标改为白底,保持原有红/灰字色可辨识 */ +.calendar-cell.today .holiday-tag.is-off { + background: #fff; + color: #f56c6c; + border-color: #fff; +} + +.calendar-cell.today .holiday-tag.is-work { + background: #fff; + color: #909399; + border-color: #fff; +} + +/* 格内节日名称 */ +.cell-holiday { + font-size: 11px; + line-height: 16px; + margin-bottom: 2px; + color: #f56c6c; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.calendar-cell.other-month .cell-holiday { + color: #e0a0a0; +} + .cell-items { display: flex; flex-direction: column; @@ -1260,6 +1517,22 @@ onMounted(() => { font-weight: 600; } +/* 标题内的节假日标记(休/班 角标 + 名称) */ +.day-title-holiday { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 6px; + font-size: 12px; + font-weight: 400; + color: #f56c6c; + vertical-align: middle; +} + +.day-title-holiday .holiday-tag.is-work { + color: #909399; +} + .day-sub { font-size: 12px; color: #909399; @@ -1381,6 +1654,11 @@ onMounted(() => { display: none; } + /* 窄屏下节日名与休/班角标可能挤压日期数字,仅保留角标 */ + .cell-holiday { + display: none; + } + .calendar-cell { position: relative; } @@ -1454,6 +1732,69 @@ html.dark & { .month-tag { color: var(--el-color-primary); } + /* 节假日标记暗色适配:浅色底改为半透明色块,保证深底上仍可读 */ + .holiday-tag.is-off { + background: rgba(245, 108, 108, 0.16); + color: #f89898; + border-color: rgba(245, 108, 108, 0.3); + } + .holiday-tag.is-work { + background: var(--el-fill-color); + color: var(--el-text-color-secondary); + border-color: var(--el-border-color-lighter); + } + .calendar-cell.is-off { + background: rgba(245, 108, 108, 0.07); + + &:hover { + background: rgba(245, 108, 108, 0.12); + } + } + .calendar-cell.is-work { + background: var(--el-bg-color); + } + /* 今天:整格主色实底,与浅色主题保持一致 */ + .calendar-cell.today { + background: var(--el-color-primary); + + &:hover { + background: var(--el-color-primary-light-3); + } + + .day-num { + background: #fff; + color: var(--el-color-primary); + } + + .cell-holiday, + .today-tag { + color: #fff; + } + + .holiday-tag.is-off { + background: #fff; + color: #f56c6c; + border-color: #fff; + } + + .holiday-tag.is-work { + background: #fff; + color: #909399; + border-color: #fff; + } + } + .cell-holiday { + color: #f89898; + } + .calendar-cell.other-month .cell-holiday { + color: rgba(248, 152, 152, 0.55); + } + .day-title-holiday { + color: #f89898; + } + .day-title-holiday .holiday-tag.is-work { + color: var(--el-text-color-secondary); + } .cell-item { background: var(--el-fill-color-light); diff --git a/go/controllers/backend_oa_schedule.go b/go/controllers/backend_oa_schedule.go index f15d5c6..6b7b844 100644 --- a/go/controllers/backend_oa_schedule.go +++ b/go/controllers/backend_oa_schedule.go @@ -11,6 +11,7 @@ import ( "unicode/utf8" "server/models" + "server/pkg/holiday" "server/pkg/jwtutil" "github.com/beego/beego/v2/client/orm" @@ -240,6 +241,162 @@ func (c *BackendOaScheduleController) List() { }) } +// Holidays GET /backend/oa/holiday +// 返回指定日期区间(或指定年份)内的中国法定节假日/调休日,供日历控件标注 +// 「休 / 班」角标与节日名称。 +// +// 参数(二选一): +// - start_date + end_date:"YYYY-MM-DD",日历按 42 格区间查询; +// - year:整年查询。 +// +// 数据获取顺序:内存缓存 → 内置数据(2024-2026,零网络)→ 外网接口(仅未收录年份)。 +// 已收录年份不会发起任何网络请求,因此日历翻月是纯内存操作、无卡顿。 +// 响应里的 builtin_years 字段告知前端内置数据覆盖到哪一年。 +func (c *BackendOaScheduleController) Holidays() { + if _, err := c.oaScheduleClaims(); err != nil { + c.oasJsonErr(401, 401, "未登录或无权限") + return + } + + startDate := strings.TrimSpace(c.GetString("start_date")) + endDate := strings.TrimSpace(c.GetString("end_date")) + yearStr := strings.TrimSpace(c.GetString("year")) + + // 参数归一化:区间优先,其次按年 + var start, end string + if isValidOaDate(startDate) && isValidOaDate(endDate) && startDate <= endDate { + start, end = startDate, endDate + } else if y, err := strconv.Atoi(yearStr); err == nil && y >= 2000 && y <= 2100 { + start = fmt.Sprintf("%04d-01-01", y) + end = fmt.Sprintf("%04d-12-31", y) + } else { + // 未传参时默认当前年份,保证前端任何情况下都能拿到可用数据 + now := time.Now() + start = now.Format("2006-01-01") + end = now.Format("2006") + "-12-31" + } + + list := ensureOaHolidays(start, end) + + c.oasOk(map[string]interface{}{ + "list": list, + "start_date": start, + "end_date": end, + "builtin_years": holiday.Years(), + }) +} + +// ensureOaHolidays 取区间内的节假日数据。 +// +// 顺序上的取舍(性能关键):先调 holiday.FetchYear,而不是先查数据库。 +// 原因是 FetchYear 对已收录年份(2024-2026)直接返回内置数据、不发网络请求, +// 也无需查库;日历每次翻月都会走到这里,先查库会白白多一次 SQL 往返。 +// 只有 FetchYear 真的联网拉到了数据(即年份未收录),才回写数据库缓存, +// 供后续同一年份的请求直接命中。 +func ensureOaHolidays(start, end string) []holiday.Day { + list := make([]holiday.Day, 0, 64) + + startYear, endYear := holidayYear(start), holidayYear(end) + for y := startYear; y <= endYear; y++ { + if y == 0 { + continue + } + items, src, ferr := holiday.FetchYear(y) + if ferr != nil || len(items) == 0 { + continue + } + // 仅当数据来自外网时才落库:内置数据本身就在代码里,落库没有收益, + // 反而会让每次翻月都产生一批无谓的写库操作。 + if src == holiday.SourceHolidayCN { + saveOaHolidays(items) + } + for _, d := range items { + if d.Date >= start && d.Date <= end { + list = append(list, d) + } + } + } + + return mergeOaHolidays(list) +} + +// holidayYear 从 "YYYY-MM-DD" 取年份,失败返回 0。 +func holidayYear(date string) int { + if len(date) < 4 { + return 0 + } + y, err := strconv.Atoi(date[:4]) + if err != nil { + return 0 + } + return y +} + +// saveOaHolidays 把外网拉取到的数据写入缓存表。 +// 使用 insert-or-update:已存在的日期更新其名称/类型/来源,保证外网数据 +// 修正后能覆盖库里的旧值。写入失败静默忽略(缓存非强依赖)。 +func saveOaHolidays(list []holiday.Day) { + now := time.Now() + for _, d := range list { + if !isValidOaDate(d.Date) { + continue + } + var exist models.OaHoliday + ferr := models.Orm.QueryTable(new(models.OaHoliday)). + Filter("date", d.Date). + One(&exist) + if ferr == nil { + // 已存在:仅当来源或名称有变化时才更新,减少无谓写库 + if exist.Name == d.Name && exist.Type == d.Type && exist.Source == d.Source { + continue + } + exist.Name = d.Name + exist.Type = d.Type + exist.Year = d.Year + exist.Wage = d.Wage + exist.Source = d.Source + exist.UpdateTime = &now + _, _ = models.Orm.Update(&exist, "Name", "Type", "Year", "Wage", "Source", "UpdateTime") + continue + } + item := models.OaHoliday{ + Date: d.Date, + Name: d.Name, + Type: d.Type, + Year: d.Year, + Wage: d.Wage, + Source: d.Source, + CreateTime: now, + UpdateTime: &now, + } + _, _ = models.Orm.Insert(&item) + } +} + +// mergeOaHolidays 按日期去重并升序排列;同日重复时保留先出现的记录 +// (数据库缓存排在前面,因此缓存优先于外网新拉取的数据)。 +func mergeOaHolidays(list []holiday.Day) []holiday.Day { + seen := make(map[string]bool, len(list)) + out := make([]holiday.Day, 0, len(list)) + for _, d := range list { + if d.Date == "" || seen[d.Date] { + continue + } + seen[d.Date] = true + out = append(out, d) + } + for i := 1; i < len(out); i++ { + cur := out[i] + j := i - 1 + for j >= 0 && out[j].Date > cur.Date { + out[j+1] = out[j] + j-- + } + out[j+1] = cur + } + return out +} + // Stats GET /backend/oa/schedule/stats // 返回今日/本周/本月的日程统计与逾期数,供页面顶部统计卡片展示。 func (c *BackendOaScheduleController) Stats() { diff --git a/go/docs/sql/create_oa_holiday.sql b/go/docs/sql/create_oa_holiday.sql new file mode 100644 index 0000000..75b66b3 --- /dev/null +++ b/go/docs/sql/create_oa_holiday.sql @@ -0,0 +1,36 @@ +-- ============================================================= +-- OA 日程管理 —— 中国法定节假日/调休日表 +-- +-- 表结构变更一律以 SQL 文件形式放在 docs/sql/ 下由人工执行, +-- 代码中不做自动建表。本脚本仅新建表,不改动任何既有数据。 +-- +-- 数据说明: +-- 本表全局共享(不按租户隔离),只存两类无法用"星期几"推算出来的日期: +-- type = 1 休假日(法定节假日放假,含调休连休的日子) +-- type = 0 工作日(调休上班,即周末补班) +-- 普通周六/周日不入库,由前端按星期直接判定。 +-- +-- 表本身可以为空:后端接口在查不到数据时会调用外网接口拉取并写入本表, +-- 外网不可用时回落到代码内置的 2024-2026 年节假日数据(go/pkg/holiday/fallback.go), +-- 因此不执行本脚本之外的任何数据预置也能正常使用。 +-- +-- 注意:不显式指定 COLLATE,沿用数据库默认排序规则,避免与既有表在 +-- JOIN 比较字符串时报 Illegal mix of collations。 +-- ============================================================= + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `yz_backend_oa_holiday` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `date` CHAR(10) NOT NULL DEFAULT '' COMMENT '日期 YYYY-MM-DD', + `name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '节日名称,如 国庆节 / 春节', + `type` TINYINT NOT NULL DEFAULT 0 COMMENT '0工作日(调休补班) 1休假日(法定节假日)', + `year` INT NOT NULL DEFAULT 0 COMMENT '所属年份,便于按年清理与查询', + `wage` TINYINT NOT NULL DEFAULT 1 COMMENT '薪资倍数:1普通 2双薪 3三薪', + `source` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '数据来源:timor / holiday-cn / builtin / manual', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_date` (`date`), + KEY `idx_year` (`year`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='中国法定节假日/调休日(全局共享)'; diff --git a/go/models/init.go b/go/models/init.go index 8109074..31d7fc6 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -150,6 +150,7 @@ func Init(_ string) { new(BackendScheduleReminderSendLog), new(OaSchedule), + new(OaHoliday), new(OaNotice), new(OaDocCategory), new(OaDoc), diff --git a/go/models/oa_holiday.go b/go/models/oa_holiday.go new file mode 100644 index 0000000..7fca331 --- /dev/null +++ b/go/models/oa_holiday.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" +) + +// OaHoliday 中国法定节假日/调休日:yz_backend_oa_holiday +// +// 数据是全局共享的(不按租户隔离),因此不带 tid 字段。日历控件按 +// "YYYY-MM-DD" 展示休/班角标与节日名称时读取本表。 +// +// Type 取值: +// - 0 工作日(调休上班,如周末补班) +// - 1 休假日(法定节假日放假,含与周末连休的日子) +// - 2 普通周末(周六/周日,非节假日) +// +// 只有 0 / 1 两类需要落库——它们无法用"星期几"推算出来;普通周末由前端 +// 按星期直接判定,不入库,避免数据量无谓膨胀。 +type OaHoliday struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Date string `orm:"column(date);size(10);unique" json:"date"` + Name string `orm:"column(name);size(64);default()" json:"name"` + Type int8 `orm:"column(type);default(0)" json:"type"` + Year int `orm:"column(year);index" json:"year"` + Wage int8 `orm:"column(wage);default(1)" json:"wage"` + Source string `orm:"column(source);size(20);default()" json:"source"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"` +} + +func (m *OaHoliday) TableName() string { + return "yz_backend_oa_holiday" +} diff --git a/go/pkg/holiday/fallback.go b/go/pkg/holiday/fallback.go new file mode 100644 index 0000000..0e49673 --- /dev/null +++ b/go/pkg/holiday/fallback.go @@ -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 +} diff --git a/go/pkg/holiday/fetch.go b/go/pkg/holiday/fetch.go new file mode 100644 index 0000000..6c49fe8 --- /dev/null +++ b/go/pkg/holiday/fetch.go @@ -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 +} diff --git a/go/pkg/holiday/holiday.go b/go/pkg/holiday/holiday.go new file mode 100644 index 0000000..44c431e --- /dev/null +++ b/go/pkg/holiday/holiday.go @@ -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 + } +} diff --git a/go/pkg/holiday/holiday_test.go b/go/pkg/holiday/holiday_test.go new file mode 100644 index 0000000..3f3985c --- /dev/null +++ b/go/pkg/holiday/holiday_test.go @@ -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) +} diff --git a/go/pkg/holiday/year2027_probe_test.go b/go/pkg/holiday/year2027_probe_test.go new file mode 100644 index 0000000..5daafd5 --- /dev/null +++ b/go/pkg/holiday/year2027_probe_test.go @@ -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) + } +} diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index b1e329e..dc138c9 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -314,6 +314,9 @@ func RegisterAuthRoutes() { beego.Router("/backend/oa/schedule/images/:id", &controllers.BackendOaScheduleController{}, "post:SetImages") beego.Router("/backend/oa/schedule/content/:id", &controllers.BackendOaScheduleController{}, "post:UpdateContent") + // 中国法定节假日/调休日(日历控件休/班标记,数据全局共享、按年缓存) + beego.Router("/backend/oa/holiday", &controllers.BackendOaScheduleController{}, "get:Holidays") + // OA通知公告 beego.Router("/backend/oa/notice/list", &controllers.BackendOaNoticeController{}, "get:List") beego.Router("/backend/oa/notice/portal", &controllers.BackendOaNoticeController{}, "get:Portal")