dsh-session-guard 4.1.16 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +19 -0
- package/CHANGELOG.ko.md +19 -0
- package/CHANGELOG.md +21 -0
- package/README.de.md +1 -1
- package/README.en.md +7 -0
- package/README.es.md +1 -1
- package/README.fr.md +1 -1
- package/README.it.md +1 -1
- package/README.md +8 -1
- package/README.ru.md +1 -1
- package/dsh.plugin.json +2 -2
- package/lib/client.js +4 -9
- package/lib/client.js.map +1 -1
- package/package.json +6 -6
- package/src/client/index.ts +26 -16
- package/src/holiday-store.js +196 -0
- package/src/holidays.js +159 -0
- package/src/index.js +38 -6
- package/src/settings.js +5 -1
- package/src/time.js +30 -9
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 修正表的 IO 层:按年缓存落盘 + 自动抓取 + 启动指纹校验。
|
|
3
|
+
*
|
|
4
|
+
* 数据链路(全部 fail-open,任何失败只降级不抛错):
|
|
5
|
+
* 1. 内置快照(holidays.js BUILTIN_TABLES,已定稿年份);
|
|
6
|
+
* 2. 磁盘缓存 `$DSH_HOME/.dsh/session-guard/holidays/<year>.json`
|
|
7
|
+
* (`DSH_SESSION_GUARD_HOLIDAY_DIR` 可覆盖根目录)——**启动时校验指纹**:
|
|
8
|
+
* 重算内容 sha256 与文件内记录比对,不一致视为损坏,弃用并触发重抓;
|
|
9
|
+
* 3. 远端 holiday-cn(jsDelivr 主源、raw.githubusercontent 备源)——
|
|
10
|
+
* 抓取成功且指纹有变化才原子写缓存(tmp + rename);指纹一致只刷新
|
|
11
|
+
* checkedAt,不写盘(2026 已定稿,日常核对几乎恒等)。
|
|
12
|
+
*
|
|
13
|
+
* 「按年自动尝试抓取」:启动核对一次,之后每小时 ensureFresh;相关年份 =
|
|
14
|
+
* 北京时间当年 + 12 月追加次年(国务院公告一般 11–12 月发布)。内置表
|
|
15
|
+
* 覆盖的年份仍会被远端覆盖更新(公告修正 / 补录),但抓取节奏不变。
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { storeRoot } from './store.js'
|
|
20
|
+
import { wallClock, BILLING_TIMEZONE } from './time.js'
|
|
21
|
+
import { BUILTIN_TABLES, buildMap, fingerprint, parseHolidayCn } from './holidays.js'
|
|
22
|
+
|
|
23
|
+
/** 单轮远端抓取超时(ms)。 */
|
|
24
|
+
const FETCH_TIMEOUT_MS = 10_000
|
|
25
|
+
/** 已到手年份表的复核间隔:24h。 */
|
|
26
|
+
const REFRESH_MS = 24 * 3600 * 1000
|
|
27
|
+
|
|
28
|
+
/** 缓存根目录(独立于 pause / 队列锁子目录)。 */
|
|
29
|
+
export function holidaysRoot() {
|
|
30
|
+
return process.env.DSH_SESSION_GUARD_HOLIDAY_DIR || join(storeRoot(), 'holidays')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 某年缓存文件路径。 */
|
|
34
|
+
export function holidayFilePath(year, root = holidaysRoot()) {
|
|
35
|
+
return join(root, `${Number(year)}.json`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 远端候选源:jsDelivr 主源(国内可达性好),GitHub raw 备源。 */
|
|
39
|
+
export function holidayUrls(year) {
|
|
40
|
+
const y = Number(year)
|
|
41
|
+
return [
|
|
42
|
+
`https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master/${y}.json`,
|
|
43
|
+
`https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/${y}.json`,
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 创建修正表存储。
|
|
49
|
+
* @param {object} [deps] { logger, fetchImpl, now, builtinTables, root }
|
|
50
|
+
* root 供单测注入临时目录;fetchImpl 供 mock。
|
|
51
|
+
* @returns {{
|
|
52
|
+
* loadAll: () => void,
|
|
53
|
+
* ensureFresh: (opts?: { autoFetch?: boolean }) => Promise<void>,
|
|
54
|
+
* map: () => Map<string, boolean>,
|
|
55
|
+
* info: () => object[],
|
|
56
|
+
* }}
|
|
57
|
+
*/
|
|
58
|
+
export function createHolidayStore(deps = {}) {
|
|
59
|
+
const logger = deps.logger
|
|
60
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch
|
|
61
|
+
const now = deps.now ?? (() => Date.now())
|
|
62
|
+
const builtin = deps.builtinTables ?? BUILTIN_TABLES
|
|
63
|
+
const root = deps.root ?? holidaysRoot()
|
|
64
|
+
|
|
65
|
+
/** @type {Map<number, {days: {date:string,isOffDay:boolean}[], sha:string, source:'builtin'|'cache'|'live', fetchedAt:number|null, checkedAt:number|null}>} */
|
|
66
|
+
const tables = new Map()
|
|
67
|
+
/** map() 的惰性合并缓存:任何表更新时置 null。 */
|
|
68
|
+
let merged = null
|
|
69
|
+
|
|
70
|
+
const warn = (msg, e) => {
|
|
71
|
+
const line = `[session-guard] ${msg}`
|
|
72
|
+
if (logger?.warn) logger.warn(e ? `${line}: ${String(e && e.message || e)}` : line)
|
|
73
|
+
else console.warn(e ? `${line}: ${String(e && e.message || e)}` : line)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function putTable(year, days, source, fetchedAt) {
|
|
77
|
+
tables.set(year, { days, sha: fingerprint(days), source, fetchedAt, checkedAt: now() })
|
|
78
|
+
merged = null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 装载单年:内置打底,缓存指纹校验通过则覆盖内置。 */
|
|
82
|
+
function loadYear(year) {
|
|
83
|
+
const seed = builtin.find((t) => t.year === year)
|
|
84
|
+
if (seed) putTable(year, seed.days.slice(), 'builtin', null)
|
|
85
|
+
let cached
|
|
86
|
+
try {
|
|
87
|
+
const f = holidayFilePath(year, root)
|
|
88
|
+
if (!existsSync(f)) return
|
|
89
|
+
cached = JSON.parse(readFileSync(f, 'utf8'))
|
|
90
|
+
} catch (e) {
|
|
91
|
+
warn('holiday cache unreadable (ignored)', e)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const parsed = parseHolidayCn(cached)
|
|
96
|
+
if (parsed.year !== year) throw new Error(`cache year ${parsed.year} != file slot ${year}`)
|
|
97
|
+
const sha = fingerprint(parsed.days)
|
|
98
|
+
if (cached.sha !== sha) {
|
|
99
|
+
// 启动指纹校验失败:内容损坏/被改,弃用缓存(ensureFresh 会重抓)。
|
|
100
|
+
warn(`holiday cache fingerprint mismatch for ${year}, discarding`)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
putTable(year, parsed.days, 'cache', Number(cached.fetchedAt) || null)
|
|
104
|
+
} catch (e) {
|
|
105
|
+
warn(`holiday cache invalid for ${year} (ignored)`, e)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 相关年份:北京当年 +(11/12 月)次年——国务院一般年底公布来年安排。 */
|
|
110
|
+
function relevantYears() {
|
|
111
|
+
const wc = wallClock(BILLING_TIMEZONE, new Date(now()))
|
|
112
|
+
const years = [wc.year]
|
|
113
|
+
if (wc.month >= 11) years.push(wc.year + 1)
|
|
114
|
+
return years
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function fetchYear(year) {
|
|
118
|
+
if (typeof fetchImpl !== 'function') return false
|
|
119
|
+
for (const url of holidayUrls(year)) {
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
|
122
|
+
if (!res || res.ok !== true) continue
|
|
123
|
+
const parsed = parseHolidayCn(await res.json())
|
|
124
|
+
if (parsed.year !== year) throw new Error(`payload year ${parsed.year} != requested ${year}`)
|
|
125
|
+
const sha = fingerprint(parsed.days)
|
|
126
|
+
const cur = tables.get(year)
|
|
127
|
+
if (cur && cur.sha === sha) {
|
|
128
|
+
cur.checkedAt = now() // 指纹一致:不写盘,只记核对时间
|
|
129
|
+
return true
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
mkdirSync(dirname(holidayFilePath(year, root)), { recursive: true })
|
|
133
|
+
const tmp = `${holidayFilePath(year, root)}.tmp`
|
|
134
|
+
writeFileSync(tmp, JSON.stringify({
|
|
135
|
+
year, sha, fetchedAt: now(), sourceUrl: url,
|
|
136
|
+
days: parsed.days,
|
|
137
|
+
}, null, 2))
|
|
138
|
+
renameSync(tmp, holidayFilePath(year, root))
|
|
139
|
+
} catch (e) {
|
|
140
|
+
warn(`holiday cache write failed for ${year} (memory table still updated)`, e)
|
|
141
|
+
}
|
|
142
|
+
putTable(year, parsed.days, 'live', now())
|
|
143
|
+
warn(`holiday table updated for ${year} (${cur ? 'fingerprint changed' : 'new'}, ${sha.slice(0, 17)}…)`)
|
|
144
|
+
return true
|
|
145
|
+
} catch (e) {
|
|
146
|
+
warn(`holiday fetch failed via ${url}`, e)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
/** 启动装载(同步):内置 + 缓存 + 指纹校验。 */
|
|
154
|
+
loadAll() {
|
|
155
|
+
for (const year of relevantYears()) loadYear(year)
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 按年自动抓取核对:缺表的年份立即抓;已有表超 24h 复核。
|
|
160
|
+
* autoFetch=false(设置关)时只补缺年(缓存/内置仍生效),不发请求。
|
|
161
|
+
*/
|
|
162
|
+
async ensureFresh(opts = {}) {
|
|
163
|
+
const autoFetch = opts.autoFetch !== false
|
|
164
|
+
for (const year of relevantYears()) {
|
|
165
|
+
const cur = tables.get(year)
|
|
166
|
+
const anchor = Math.max((cur && cur.fetchedAt) || 0, (cur && cur.checkedAt) || 0)
|
|
167
|
+
if (cur && now() - anchor <= REFRESH_MS) continue // 24h 内已抓/已核对
|
|
168
|
+
if (autoFetch !== true) continue // 关自动抓取时纯靠内置 + 缓存,不发请求
|
|
169
|
+
await fetchYear(year)
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/** 合并所有已装载年份的查表 Map('YYYY-MM-DD' → 是否放假日)。 */
|
|
174
|
+
map() {
|
|
175
|
+
if (merged) return merged
|
|
176
|
+
const days = []
|
|
177
|
+
for (const t of tables.values()) days.push(...t.days)
|
|
178
|
+
merged = buildMap(days)
|
|
179
|
+
return merged
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
/** 诊断输出(/session-guard/status 透出用)。 */
|
|
183
|
+
info() {
|
|
184
|
+
return [...tables.entries()]
|
|
185
|
+
.sort((a, b) => a[0] - b[0])
|
|
186
|
+
.map(([year, t]) => ({
|
|
187
|
+
year,
|
|
188
|
+
source: t.source,
|
|
189
|
+
fingerprint: t.sha,
|
|
190
|
+
fetchedAt: t.fetchedAt,
|
|
191
|
+
checkedAt: t.checkedAt,
|
|
192
|
+
entries: t.days.length,
|
|
193
|
+
}))
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/holidays.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 法定节假日 / 调休修正表(纯逻辑,零 IO,可单测)。
|
|
3
|
+
*
|
|
4
|
+
* 数据源:holiday-cn(NateScarlet/holiday-cn,每日自动解析国务院公告),
|
|
5
|
+
* 全年 JSON:`{ year, papers: [...], days: [{ name, date, isOffDay }] }`。
|
|
6
|
+
* `isOffDay: true` = 放假(含落在周末的假日);`false` = 调休补班日
|
|
7
|
+
* (本来是周六日但要上班)。表外日期回退自然周末判定。
|
|
8
|
+
*
|
|
9
|
+
* 内置快照只覆盖已定稿年份(2026,39 条 = 33 放假日 + 6 调休补班日);
|
|
10
|
+
* 未覆盖年份由 holiday-store.js 按年自动抓取,抓不到即退化为纯自然周末
|
|
11
|
+
* (fail-open,与插件一贯风格一致)。
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
|
|
15
|
+
/** 'YYYY-MM-DD' 严格形态(月/日补零),也是 Map 键与指纹序列化的唯一形态。 */
|
|
16
|
+
const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 2026 年国务院放假安排(公告 content_7047091,经 holiday-cn 机读)。
|
|
20
|
+
* 只保留判定需要的 date / isOffDay 两键;name 仅注释性内嵌于分组注释。
|
|
21
|
+
*/
|
|
22
|
+
export const BUILTIN_2026 = Object.freeze({
|
|
23
|
+
year: 2026,
|
|
24
|
+
paper: 'https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm',
|
|
25
|
+
days: Object.freeze([
|
|
26
|
+
// 元旦:1/1(四)-1/3(六) 放假,1/4(日) 补班
|
|
27
|
+
{ date: '2026-01-01', isOffDay: true },
|
|
28
|
+
{ date: '2026-01-02', isOffDay: true },
|
|
29
|
+
{ date: '2026-01-03', isOffDay: true },
|
|
30
|
+
{ date: '2026-01-04', isOffDay: false },
|
|
31
|
+
// 春节:2/15-2/23 放假,2/14(六)、2/28(六) 补班
|
|
32
|
+
{ date: '2026-02-14', isOffDay: false },
|
|
33
|
+
{ date: '2026-02-15', isOffDay: true },
|
|
34
|
+
{ date: '2026-02-16', isOffDay: true },
|
|
35
|
+
{ date: '2026-02-17', isOffDay: true },
|
|
36
|
+
{ date: '2026-02-18', isOffDay: true },
|
|
37
|
+
{ date: '2026-02-19', isOffDay: true },
|
|
38
|
+
{ date: '2026-02-20', isOffDay: true },
|
|
39
|
+
{ date: '2026-02-21', isOffDay: true },
|
|
40
|
+
{ date: '2026-02-22', isOffDay: true },
|
|
41
|
+
{ date: '2026-02-23', isOffDay: true },
|
|
42
|
+
{ date: '2026-02-28', isOffDay: false },
|
|
43
|
+
// 清明:4/4-4/6 放假,无补班
|
|
44
|
+
{ date: '2026-04-04', isOffDay: true },
|
|
45
|
+
{ date: '2026-04-05', isOffDay: true },
|
|
46
|
+
{ date: '2026-04-06', isOffDay: true },
|
|
47
|
+
// 劳动节:5/1-5/5 放假,5/9(六) 补班
|
|
48
|
+
{ date: '2026-05-01', isOffDay: true },
|
|
49
|
+
{ date: '2026-05-02', isOffDay: true },
|
|
50
|
+
{ date: '2026-05-03', isOffDay: true },
|
|
51
|
+
{ date: '2026-05-04', isOffDay: true },
|
|
52
|
+
{ date: '2026-05-05', isOffDay: true },
|
|
53
|
+
{ date: '2026-05-09', isOffDay: false },
|
|
54
|
+
// 端午:6/19-6/21 放假,无补班
|
|
55
|
+
{ date: '2026-06-19', isOffDay: true },
|
|
56
|
+
{ date: '2026-06-20', isOffDay: true },
|
|
57
|
+
{ date: '2026-06-21', isOffDay: true },
|
|
58
|
+
// 中秋:9/25-9/27 放假,9/20(日) 补班
|
|
59
|
+
{ date: '2026-09-20', isOffDay: false },
|
|
60
|
+
{ date: '2026-09-25', isOffDay: true },
|
|
61
|
+
{ date: '2026-09-26', isOffDay: true },
|
|
62
|
+
{ date: '2026-09-27', isOffDay: true },
|
|
63
|
+
// 国庆:10/1-10/7 放假,10/10(六) 补班
|
|
64
|
+
{ date: '2026-10-01', isOffDay: true },
|
|
65
|
+
{ date: '2026-10-02', isOffDay: true },
|
|
66
|
+
{ date: '2026-10-03', isOffDay: true },
|
|
67
|
+
{ date: '2026-10-04', isOffDay: true },
|
|
68
|
+
{ date: '2026-10-05', isOffDay: true },
|
|
69
|
+
{ date: '2026-10-06', isOffDay: true },
|
|
70
|
+
{ date: '2026-10-07', isOffDay: true },
|
|
71
|
+
{ date: '2026-10-10', isOffDay: false },
|
|
72
|
+
]),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/** 内置快照数组(新定稿年份在此追加)。 */
|
|
76
|
+
export const BUILTIN_TABLES = Object.freeze([BUILTIN_2026])
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 校验并归一化 holiday-cn `days` 数组。
|
|
80
|
+
* 逐条要求 `date` 为补零 'YYYY-MM-DD' 且 `isOffDay` 为布尔;
|
|
81
|
+
* 排序去重(同日重复以末条为准,与上游「后公告覆盖前公告」一致)。
|
|
82
|
+
* @param {unknown} raw days 原始数组
|
|
83
|
+
* @returns {{date:string, isOffDay:boolean}[]} 归一化条目(新数组)
|
|
84
|
+
* @throws {Error} 非数组 / 空条目 / 形态非法
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeDays(raw) {
|
|
87
|
+
if (!Array.isArray(raw)) throw new Error('holiday days: not an array')
|
|
88
|
+
const byDate = new Map()
|
|
89
|
+
for (const item of raw) {
|
|
90
|
+
const date = item && typeof item === 'object' ? item.date : undefined
|
|
91
|
+
if (typeof date !== 'string' || !DATE_RE.test(date)) {
|
|
92
|
+
throw new Error(`holiday days: bad date ${JSON.stringify(date)}`)
|
|
93
|
+
}
|
|
94
|
+
if (typeof item.isOffDay !== 'boolean') {
|
|
95
|
+
throw new Error(`holiday days: bad isOffDay for ${date}`)
|
|
96
|
+
}
|
|
97
|
+
byDate.set(date, { date, isOffDay: item.isOffDay })
|
|
98
|
+
}
|
|
99
|
+
return [...byDate.values()].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 解析 holiday-cn 全年 JSON(远端响应 / 缓存文件通吃)。
|
|
104
|
+
* @param {unknown} json 已 parse 的对象
|
|
105
|
+
* @returns {{year:number, days:{date:string, isOffDay:boolean}[]}}
|
|
106
|
+
* @throws {Error} 形态不合 schema(year 与条目日期必须一致)
|
|
107
|
+
*/
|
|
108
|
+
export function parseHolidayCn(json) {
|
|
109
|
+
if (json === null || typeof json !== 'object') throw new Error('holiday json: not an object')
|
|
110
|
+
const year = Number(json.year)
|
|
111
|
+
if (!Number.isInteger(year) || year < 2000 || year > 2100) throw new Error('holiday json: bad year')
|
|
112
|
+
const days = normalizeDays(json.days)
|
|
113
|
+
for (const d of days) {
|
|
114
|
+
if (Number(d.date.slice(0, 4)) !== year) throw new Error(`holiday json: day ${d.date} outside ${year}`)
|
|
115
|
+
}
|
|
116
|
+
return { year, days }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 修正表指纹:对归一化(排序去重)后的 `date=isOffDay` 序列做 sha256。
|
|
121
|
+
* 只哈希判定语义相关字段——name / papers / 抓取时间不参与,
|
|
122
|
+
* 上游改文案不会引起伪变化。
|
|
123
|
+
* @param {{date:string, isOffDay:boolean}[]} days normalizeDays / parseHolidayCn 产物
|
|
124
|
+
* @returns {string} 'sha256:<hex>'
|
|
125
|
+
*/
|
|
126
|
+
export function fingerprint(days) {
|
|
127
|
+
const canon = days.map((d) => `${d.date}=${d.isOffDay ? 1 : 0}`).join(';')
|
|
128
|
+
return `sha256:${createHash('sha256').update(canon).digest('hex')}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 归一化 days → 查表 Map:'YYYY-MM-DD' → 是否放假日。
|
|
133
|
+
* @param {{date:string, isOffDay:boolean}[]} days
|
|
134
|
+
* @returns {Map<string, boolean>}
|
|
135
|
+
*/
|
|
136
|
+
export function buildMap(days) {
|
|
137
|
+
const m = new Map()
|
|
138
|
+
for (const d of days) m.set(d.date, d.isOffDay)
|
|
139
|
+
return m
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** wallClock 分量 → 补零 'YYYY-MM-DD'。 */
|
|
143
|
+
export function ymdOf(wc) {
|
|
144
|
+
return `${wc.year}-${String(wc.month).padStart(2, '0')}-${String(wc.day).padStart(2, '0')}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 修正表感知的「当天是否休息日」:
|
|
149
|
+
* - 表内条目:`isOffDay` 直接定乾坤(true 放假 / false 调休补班——周末也算上班);
|
|
150
|
+
* - 表外 / 无表(map 为 null 或空):回退自然周末(0=周日、6=周六)。
|
|
151
|
+
* @param {Map<string, boolean>|null|undefined} map buildMap 产物
|
|
152
|
+
* @param {string} ymd 补零日期串
|
|
153
|
+
* @param {number} weekday 该日期在用户时区的星期(0-6,同 wallClock 约定)
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
export function isRestDay(map, ymd, weekday) {
|
|
157
|
+
if (map instanceof Map && map.has(ymd)) return map.get(ymd) === true
|
|
158
|
+
return weekday === 0 || weekday === 6
|
|
159
|
+
}
|
package/src/index.js
CHANGED
|
@@ -3,16 +3,19 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 高峰自动会话门:
|
|
5
5
|
* - 每 30s tick 判定状态(NORMAL ↔ PAUSED_PEAK),纯状态机见 scheduler.js。
|
|
6
|
-
* -
|
|
6
|
+
* - 入峰(且非周末/休息日):对所有 running root session 调 gate.stopNextTurn
|
|
7
7
|
* (有 taskControl → 会话门 safe+wait;无 → 回退锁等待队列)。
|
|
8
|
-
* -
|
|
8
|
+
* - 退峰/休息日:gate.resume 全部。
|
|
9
|
+
* - 法定假/调休修正表(holidayMode):内置快照 + 启动校验缓存指纹 + 按年自动
|
|
10
|
+
* 抓取 holiday-cn(holiday-store.js);法定假视同周末,调休补班周末按工作日。
|
|
9
11
|
* - `ctx.provide('sessionGuard')` 冗余端口,input-traffic 冻结按钮透传接入。
|
|
10
12
|
* - 设置:设置 → 插件 → session-guard 子板块,简单开关(enabled / weekendMode / queueFallback)。
|
|
11
13
|
* schemastery 为常规 dependency,设置经 `ctx.inject(['settings'])` 本地接口注册(src/settings.js,
|
|
12
14
|
* 对齐 dsh-thinking-levels;不 value-import dsh-settings);设置服务缺失时 fail-open 用默认配置照常运行。
|
|
13
15
|
*/
|
|
14
16
|
import { computeState, transition } from './scheduler.js'
|
|
15
|
-
import { wallClock,
|
|
17
|
+
import { wallClock, isRestForSettings, isInPeak, BILLING_TIMEZONE } from './time.js'
|
|
18
|
+
import { createHolidayStore } from './holiday-store.js'
|
|
16
19
|
import { createStore } from './store.js'
|
|
17
20
|
import { createGate } from './gate.js'
|
|
18
21
|
import { createBridge } from './bridge.js'
|
|
@@ -43,6 +46,21 @@ export function apply(ctx, config = {}) {
|
|
|
43
46
|
const store = createStore()
|
|
44
47
|
let lastState = null
|
|
45
48
|
|
|
49
|
+
// ── 法定假/调休修正表:内置快照打底,启动校验缓存指纹,按年自动抓取 ──
|
|
50
|
+
// holidayMap 只在 holidayMode 开启时挂进 cfg(readCfg);消费方(time.shouldPause
|
|
51
|
+
// 及其全部调用链)无感知:表缺席即退回自然周末。fail-open:抓取/解析失败只
|
|
52
|
+
// warn,不阻断 tick / 暂停主链路。DSH_SESSION_GUARD_HOLIDAY_FETCH=0 可断网
|
|
53
|
+
// (单测/离线环境):退化为纯内置 + 已缓存,不发起任何请求。
|
|
54
|
+
const holidays = createHolidayStore({
|
|
55
|
+
logger: ctx.logger,
|
|
56
|
+
fetchImpl: process.env.DSH_SESSION_GUARD_HOLIDAY_FETCH === '0' ? null : globalThis.fetch,
|
|
57
|
+
})
|
|
58
|
+
try {
|
|
59
|
+
holidays.loadAll() // 同步:内置 + 缓存(含启动指纹校验)
|
|
60
|
+
} catch (e) {
|
|
61
|
+
ctx.logger?.warn?.(`[session-guard] holiday load failed (weekend-only fallback): ${String(e)}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
/** 读实时设置(settings 服务不可用时回退默认)。 */
|
|
47
65
|
let configSource = config
|
|
48
66
|
/** 组合条目快照:volatile 字段解引,供默认值合并。 */
|
|
@@ -56,7 +74,10 @@ export function apply(ctx, config = {}) {
|
|
|
56
74
|
function readCfg() {
|
|
57
75
|
try {
|
|
58
76
|
const v = resolveEntryConfig()
|
|
59
|
-
|
|
77
|
+
const cfg = v && typeof v === 'object' ? { ...DEFAULT_SETTINGS, ...v } : { ...DEFAULT_SETTINGS }
|
|
78
|
+
// 修正表活引用:holidayMode 开启才挂;每次重组装取最新合并表(抓取更新后自然生效)
|
|
79
|
+
if (cfg.holidayMode === true) cfg.holidayMap = holidays.map()
|
|
80
|
+
return cfg
|
|
60
81
|
} catch {
|
|
61
82
|
return { ...DEFAULT_SETTINGS }
|
|
62
83
|
}
|
|
@@ -273,6 +294,15 @@ export function apply(ctx, config = {}) {
|
|
|
273
294
|
ctx.effect(() => ctx.timer.interval(tick, 30_000), 'session-guard: tick')
|
|
274
295
|
tick()
|
|
275
296
|
|
|
297
|
+
// ── 修正表自动刷新:启动后立即核对一次,之后每小时 ensureFresh ──
|
|
298
|
+
// 当年 +(11/12 月)次年;已有表 24h 内不重抓,指纹一致不写盘(见 holiday-store.js)。
|
|
299
|
+
const holidayRefresh = () => {
|
|
300
|
+
void holidays.ensureFresh({ autoFetch: readCfg().holidayAutoFetch === true })
|
|
301
|
+
.catch((e) => ctx.logger?.warn?.(`[session-guard] holiday refresh failed: ${String(e)}`))
|
|
302
|
+
}
|
|
303
|
+
ctx.effect(() => ctx.timer.interval(holidayRefresh, 3_600_000), 'session-guard: holiday refresh')
|
|
304
|
+
holidayRefresh()
|
|
305
|
+
|
|
276
306
|
// ── HTTP 路由 ──
|
|
277
307
|
// 0.1.5 compat:注册失败只记日志,不让 apply 抛错炸掉宿主插件加载(#5926 教训)。
|
|
278
308
|
// webServer.register 前缀路由 + SSE 在 0.1.5 契约不变(packages/host/webserver WebRoute)。
|
|
@@ -353,19 +383,21 @@ export function apply(ctx, config = {}) {
|
|
|
353
383
|
}
|
|
354
384
|
// GET /session-guard/status —— 全局当前阶段(状态徽标轮询用)
|
|
355
385
|
// 峰谷判定:固定北京时间(BILLING_TIMEZONE),与 DeepSeek 官方计费一致
|
|
356
|
-
//
|
|
386
|
+
// 周末/休息日判定:用配置时区;holidayMode 开启时查修正表(法定假=休,补班周末=工作)
|
|
357
387
|
if (method === 'GET' && url.pathname === '/session-guard/status') {
|
|
358
388
|
const cfg = readCfg()
|
|
359
389
|
const now = new Date()
|
|
360
390
|
const wcUser = wallClock(cfg.timezone, now)
|
|
361
391
|
const wcBilling = wallClock(BILLING_TIMEZONE, now)
|
|
362
|
-
const weekend =
|
|
392
|
+
const weekend = isRestForSettings(cfg, wcUser)
|
|
363
393
|
const peak = cfg.enabled && !weekend && isInPeak(wcBilling, cfg.peakWindows || [])
|
|
364
394
|
return json(200, {
|
|
365
395
|
ok: true,
|
|
366
396
|
status: {
|
|
367
397
|
phase: weekend ? 'weekend' : peak ? 'peak' : 'off-peak',
|
|
368
398
|
weekend,
|
|
399
|
+
holidayMode: cfg.holidayMode === true,
|
|
400
|
+
holidays: holidays.info(),
|
|
369
401
|
peak,
|
|
370
402
|
state: lastState,
|
|
371
403
|
enabled: cfg.enabled,
|
package/src/settings.js
CHANGED
|
@@ -20,7 +20,9 @@ export const NS = 'session-guard'
|
|
|
20
20
|
export const DEFAULT_SETTINGS = Object.freeze({
|
|
21
21
|
enabled: true, // 高峰自动处理开关(简单开关)
|
|
22
22
|
offPeakAutoResume: true, // 低谷自动恢复开关:低峰时段自动恢复被高峰暂停的会话
|
|
23
|
-
weekendMode: true, //
|
|
23
|
+
weekendMode: true, // 周末模式开关:识别周末/休息日,无视峰谷(简单开关)
|
|
24
|
+
holidayMode: true, // 法定节假日修正表:法定假视同周末,调休补班周末按工作日(简单开关)
|
|
25
|
+
holidayAutoFetch: true, // 按年自动抓取 holiday-cn 修正表(关=只用内置+已缓存;启动仍校验缓存指纹)
|
|
24
26
|
timezone: 'Asia/Shanghai',
|
|
25
27
|
peakWindows: [
|
|
26
28
|
{ start: '09:00', end: '12:00' },
|
|
@@ -57,6 +59,8 @@ export const SettingsSchema = z.object({
|
|
|
57
59
|
enabled: z.boolean().default(DEFAULT_SETTINGS.enabled).volatile(),
|
|
58
60
|
offPeakAutoResume: z.boolean().default(DEFAULT_SETTINGS.offPeakAutoResume).volatile(),
|
|
59
61
|
weekendMode: z.boolean().default(DEFAULT_SETTINGS.weekendMode).volatile(),
|
|
62
|
+
holidayMode: z.boolean().default(DEFAULT_SETTINGS.holidayMode).volatile(),
|
|
63
|
+
holidayAutoFetch: z.boolean().default(DEFAULT_SETTINGS.holidayAutoFetch).volatile(),
|
|
60
64
|
timezone: z.string().default(DEFAULT_SETTINGS.timezone).volatile(),
|
|
61
65
|
peakWindows: z
|
|
62
66
|
.array(z.object({ start: z.string(), end: z.string() }))
|
package/src/time.js
CHANGED
|
@@ -6,11 +6,16 @@
|
|
|
6
6
|
*
|
|
7
7
|
* 周末判定基于用户配置时区(settings.timezone),因为"周末"是用户本地概念。
|
|
8
8
|
*
|
|
9
|
+
* 修正表(settings.holidayMap,holiday-cn 语义)存在时,"周末"升级为
|
|
10
|
+
* "休息日":法定节假日(含落在工作日的)视同周末;调休补班周末
|
|
11
|
+
* 视同工作日(峰谷窗口照常生效)。无表时退回纯自然周末。
|
|
12
|
+
*
|
|
9
13
|
* 两者都通过 `Intl.DateTimeFormat(timeZone)` 投影,避免裸 `getUTCDay()`
|
|
10
14
|
* 导致北京周末边界错 8 小时的 bug。
|
|
11
15
|
*
|
|
12
16
|
* 高峰时段为左闭右开 [start, end);跨午夜窗口(start > end)安全。
|
|
13
17
|
*/
|
|
18
|
+
import { isRestDay, ymdOf } from './holidays.js'
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* DeepSeek 峰谷计费基准时区(固定北京时间 UTC+8)。
|
|
@@ -67,6 +72,18 @@ export function isWeekend(weekday) {
|
|
|
67
72
|
return weekday === 0 || weekday === 6
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
/**
|
|
76
|
+
* 用户时区"当天是否休息日":holidayMap 存在则查修正表(含调休补班),
|
|
77
|
+
* 缺省退回自然周末。settings.holidayMap 由 host 装配层(readCfg)挂载,
|
|
78
|
+
* holidayMode 关闭时不挂——本函数只看表的存在性。
|
|
79
|
+
* @param {object} settings 同 shouldPause
|
|
80
|
+
* @param {{year:number,month:number,day:number,weekday:number}} wc wallClock 分量(用户时区)
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
export function isRestForSettings(settings, wc) {
|
|
84
|
+
return isRestDay(settings.holidayMap, ymdOf(wc), wc.weekday)
|
|
85
|
+
}
|
|
86
|
+
|
|
70
87
|
/** 某墙钟分钟是否处于任一高峰窗口内。 */
|
|
71
88
|
export function isInPeak(wc, windows) {
|
|
72
89
|
return windows.some((w) => {
|
|
@@ -80,19 +97,20 @@ export function isInPeak(wc, windows) {
|
|
|
80
97
|
/**
|
|
81
98
|
* 主判定:此刻是否应触发高峰暂停。
|
|
82
99
|
*
|
|
83
|
-
* -
|
|
84
|
-
*
|
|
100
|
+
* - 周末/休息日判定:使用 settings.timezone(用户本地时区),周末是用户本地概念;
|
|
101
|
+
* holidayMap 存在时升级为修正表语义(法定假=休,调休补班周末=工作);
|
|
102
|
+
* - 峰谷判定:固定使用 BILLING_TIMEZONE(北京时间),与 DeepSeek 官方计费一致。
|
|
85
103
|
*
|
|
86
|
-
* @param {object} settings { enabled, weekendMode, timezone, peakWindows }
|
|
104
|
+
* @param {object} settings { enabled, weekendMode, timezone, peakWindows, holidayMap? }
|
|
87
105
|
* @param {Date} date
|
|
88
106
|
* @returns {{pause:boolean, reason:string}}
|
|
89
|
-
* reason: 'disabled' | 'weekend' | 'peak' | 'off-peak'
|
|
107
|
+
* reason: 'disabled' | 'weekend'(休息日,含法定假) | 'peak' | 'off-peak'
|
|
90
108
|
*/
|
|
91
109
|
export function shouldPause(settings, date) {
|
|
92
110
|
if (!settings || settings.enabled !== true) return { pause: false, reason: 'disabled' }
|
|
93
|
-
//
|
|
111
|
+
// 周末/休息日判定:用用户配置时区(用户本地的周末)
|
|
94
112
|
const wcUser = wallClock(settings.timezone, date)
|
|
95
|
-
if (settings.weekendMode === true &&
|
|
113
|
+
if (settings.weekendMode === true && isRestForSettings(settings, wcUser)) {
|
|
96
114
|
return { pause: false, reason: 'weekend' }
|
|
97
115
|
}
|
|
98
116
|
// 峰谷判定:固定北京时间(DeepSeek 官方计费基准)
|
|
@@ -127,7 +145,8 @@ export function localMidnight(tz, year, month, day) {
|
|
|
127
145
|
* 从 `date` 出发、下一个「确定非峰」的时刻;找不到返回 null。
|
|
128
146
|
* 取两个候选的最小值:
|
|
129
147
|
* (a) 当前所在高峰窗口的结束时刻(计费时区,跨午夜安全);
|
|
130
|
-
* (b)
|
|
148
|
+
* (b) 下一个休息日的本地 00:00(周末/法定假整天非峰,仅 weekendMode 开启时;
|
|
149
|
+
* holidayMap 存在时同步识别法定假起点与调休补班周末)。
|
|
131
150
|
* @param {object} settings
|
|
132
151
|
* @param {Date} date 已知处于高峰
|
|
133
152
|
* @returns {number|null}
|
|
@@ -154,9 +173,11 @@ function nextOffPeakInstant(settings, date) {
|
|
|
154
173
|
}
|
|
155
174
|
if (settings.weekendMode === true) {
|
|
156
175
|
const wcUser = wallClock(settings.timezone, date)
|
|
157
|
-
|
|
176
|
+
// 往后找第一个"休息日"(自然周末或法定假);上限 14 天覆盖长假前的补班周末。
|
|
177
|
+
for (let k = 1; k <= 14; k++) {
|
|
158
178
|
const day = new Date(Date.UTC(wcUser.year, wcUser.month - 1, wcUser.day + k))
|
|
159
|
-
|
|
179
|
+
const ymd = `${day.getUTCFullYear()}-${String(day.getUTCMonth() + 1).padStart(2, '0')}-${String(day.getUTCDate()).padStart(2, '0')}`
|
|
180
|
+
if (!isRestDay(settings.holidayMap, ymd, day.getUTCDay())) continue
|
|
160
181
|
candidates.push(localMidnight(settings.timezone, day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate()))
|
|
161
182
|
break
|
|
162
183
|
}
|