更新日历功能

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
+12
View File
@@ -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
})
}
@@ -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 = "";
+346 -5
View File
@@ -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)"
>
<div class="cell-date">
<span class="day-num">{{ cell.day }}</span>
<span v-if="cell.tag === 'off'" class="holiday-tag is-off">休</span>
<span v-else-if="cell.tag === 'work'" class="holiday-tag is-work"
>班</span
>
<span v-if="cell.isToday" class="today-tag">今</span>
<span v-else-if="cell.day === 1" class="month-tag"
>{{ cell.month }}月</span
>
</div>
<div v-if="cell.holidayName" class="cell-holiday" :title="cell.holidayName">
{{ cell.holidayName }}
</div>
<div class="cell-items">
<div
v-for="item in cell.items.slice(0, 3)"
@@ -142,7 +151,20 @@
<div class="day-panel">
<div class="day-panel-header">
<div>
<div class="day-title">{{ selectedDateLabel }}</div>
<div class="day-title">
{{ selectedDateLabel }}
<span v-if="selectedHoliday" class="day-title-holiday">
<span
class="holiday-tag"
:class="selectedHoliday.type === 1 ? 'is-off' : 'is-work'"
>{{ selectedHoliday.type === 1 ? "休" : "班" }}</span
>
{{ selectedHoliday.type === 1 ? selectedHoliday.name : "调休上班" }}
</span>
<span v-else-if="selectedIsRestDay" class="day-title-holiday"
>周末</span
>
</div>
<div class="day-sub">
{{ selectedItems.length }} 项日程<template v-if="pendingCount > 0"
>,{{ pendingCount }} 项未完成</template
@@ -152,6 +174,14 @@
</div>
</div>
<div class="panel-actions">
<el-button
v-if="selectedHoliday && selectedHoliday.type === 1"
size="small"
:icon="Plus"
:title="`以「${selectedHoliday.name}」为标题新建日程`"
@click="openHolidayCreate"
>节日日程</el-button
>
<el-button
v-if="pendingCount > 0"
size="small"
@@ -366,6 +396,7 @@
:schedule="editingItem"
:default-date="selectedDate"
:draft="pendingDraft"
:prefill-title="prefillTitle"
@saved="reloadAll"
@draft-applied="pendingDraft = null"
/>
@@ -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);
+157
View File
@@ -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() {
+36
View File
@@ -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='中国法定节假日/调休日(全局共享)';
+1
View File
@@ -150,6 +150,7 @@ func Init(_ string) {
new(BackendScheduleReminderSendLog),
new(OaSchedule),
new(OaHoliday),
new(OaNotice),
new(OaDocCategory),
new(OaDoc),
+33
View File
@@ -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"
}
+122
View File
@@ -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
}
+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
}
+201
View File
@@ -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
}
}
+229
View File
@@ -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)
}
+43
View File
@@ -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)
}
}
+3
View File
@@ -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")