@yixinshuke/node-log 0.0.0-stage → 0.0.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/src/logger.ts ADDED
@@ -0,0 +1,427 @@
1
+ /**
2
+ * 全局日志器(单例)—— 实现 AGENTS.md §0 的两条最高原则:
3
+ *
4
+ * 1. 写日志 = **先写环形内存缓冲,立即返回**;后台定时/攒批,在互斥下**追加写**到目标目录的文件;
5
+ * 2. **写日志的任何异常都不影响业务**:不抛错、不阻塞,最坏只是这段日志没记下来。
6
+ */
7
+ import { hostname } from 'os';
8
+ import {
9
+ LoggerConfig,
10
+ LogLevel,
11
+ mergeConfig,
12
+ validateConfig,
13
+ resolveMaxBufferSize,
14
+ } from './config.js';
15
+ import { RingBuffer, LogEntry } from './buffer.js';
16
+ import { FileWriter } from './writer.js';
17
+ import { buildRelPath } from './path.js';
18
+ import { beijingTimestamp } from './time.js';
19
+ import { createLocker, WriteLocker } from './lock.js';
20
+
21
+ export interface LogContext {
22
+ merchantId: string | number;
23
+ storeId: string | number;
24
+ system: string;
25
+ /** 业务主键(订单号),决定写入哪个文件 */
26
+ bizKey: string;
27
+ /** 单据日期 YYYYMMDD;缺省时从 bizKey 前 8 位解析 */
28
+ bizDate?: string;
29
+ /**
30
+ * 类别路径段(可选):传入后路径改为 `{年}/{年月}/{年月日}/{category}/{bizKey}.log`,
31
+ * 替代默认的「商户/门店/system」三段(见 path.ts)。不传则保持旧结构,向后兼容。
32
+ */
33
+ category?: string;
34
+ userId?: string;
35
+ requestId?: string;
36
+ extra?: Record<string, any>;
37
+ }
38
+
39
+ export interface WriteOptions {
40
+ /** true = 立即触发一次刷盘(仍走后台,不阻塞业务) */
41
+ immediate?: boolean;
42
+ metadata?: Record<string, any>;
43
+ }
44
+
45
+ export interface LoggerStats {
46
+ buffered: number;
47
+ dropped: number;
48
+ writeFailures: number;
49
+ lockBusy: number;
50
+ }
51
+
52
+ const LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error'];
53
+
54
+ /** Node 定时器上限(2^31-1 ms),超过会被截断成 1ms */
55
+ const MAX_INTERVAL_MS = 2_147_483_647;
56
+
57
+ /** 单次 flush 的最大轮数(防死循环) */
58
+ const MAX_FLUSH_ROUNDS = 1000;
59
+
60
+ /** 写失败退避上限(5 分钟) */
61
+ const MAX_BACKOFF_MS = 300_000;
62
+
63
+ interface Chunk {
64
+ text: string;
65
+ count: number;
66
+ }
67
+
68
+ export class GlobalLogger {
69
+ private static instance: GlobalLogger | null = null;
70
+
71
+ private config: Required<LoggerConfig>;
72
+ private buffer: RingBuffer;
73
+ private writer: FileWriter;
74
+ private timer: NodeJS.Timeout | null = null;
75
+ private locker: WriteLocker | null = null;
76
+ private closed = false;
77
+ private flushing = false;
78
+ private rerun = false;
79
+ private readonly warned = new Set<string>();
80
+ private readonly host: string;
81
+ private readonly pid = process.pid;
82
+ private counters = { writeFailures: 0, lockBusy: 0 };
83
+ /** 写失败退避:rel → 下次可重试的时间戳(故障期间不反复死磕同一文件) */
84
+ private readonly retryAfter = new Map<string, number>();
85
+ /** rel → 连续失败次数(用于指数退避) */
86
+ private readonly failureStreak = new Map<string, number>();
87
+
88
+ private constructor(config: Required<LoggerConfig>) {
89
+ this.config = config;
90
+ this.host =
91
+ config.host || process.env.NODE_LOG_HOST || process.env.POD_NAME || hostname();
92
+ this.buffer = new RingBuffer(resolveMaxBufferSize(config));
93
+ this.writer = new FileWriter({
94
+ baseDir: config.baseDir,
95
+ writeTimeoutMs: config.writeTimeoutMs,
96
+ maxFileSizeBytes: config.maxFileSizeBytes,
97
+ onWarn: msg => this.warnOnce(`write:${msg}`, `[node-log] ${msg}`),
98
+ });
99
+ this.applyLock(); // 锁由外部注入(node-lock 实例),没注入就是无锁直写
100
+ }
101
+
102
+ /** 装配写入互斥:注入了锁实例才互斥,否则无锁直写(不告警,属预期用法) */
103
+ private applyLock(): void {
104
+ this.locker = createLocker(this.config.lock, this.config.lockTtlMs, msg =>
105
+ this.warnOnce(`lock:${msg}`, msg)
106
+ );
107
+ this.writer.setLocker(this.locker, this.config.lockTtlMs);
108
+ }
109
+
110
+ // ---------------------------------------------------------------- 生命周期
111
+
112
+ static init(config: LoggerConfig): void {
113
+ if (GlobalLogger.instance) {
114
+ throw new Error('GlobalLogger 已初始化,请勿重复调用 init()');
115
+ }
116
+ const merged = mergeConfig(config);
117
+ validateConfig(merged); // 唯一允许抛错的地方:启动期暴露配置问题
118
+ const instance = new GlobalLogger(merged);
119
+ GlobalLogger.instance = instance;
120
+ instance.startFlushTimer();
121
+ }
122
+
123
+ static getInstance(): GlobalLogger {
124
+ if (!GlobalLogger.instance) {
125
+ throw new Error('请先调用 GlobalLogger.init() 初始化');
126
+ }
127
+ return GlobalLogger.instance;
128
+ }
129
+
130
+ static updateConfig(overrides: Partial<LoggerConfig>): void {
131
+ const instance = GlobalLogger.getInstance();
132
+ const merged = mergeConfig({ ...instance.config, ...overrides });
133
+ validateConfig(merged); // 配置错误在启动/变更期暴露,运行期写路径不抛错
134
+ instance.config = merged;
135
+ instance.writer.setBaseDir(merged.baseDir);
136
+ instance.writer.setWriteTimeoutMs(merged.writeTimeoutMs);
137
+ instance.writer.setMaxFileSizeBytes(merged.maxFileSizeBytes);
138
+ instance.applyLock();
139
+ instance.startFlushTimer();
140
+ }
141
+
142
+ static async shutdown(): Promise<void> {
143
+ const instance = GlobalLogger.instance;
144
+ if (!instance) return;
145
+ GlobalLogger.instance = null;
146
+ await instance.close();
147
+ }
148
+
149
+ private async close(): Promise<void> {
150
+ this.closed = true;
151
+ this.stopTimer();
152
+ try {
153
+ await this.flushAll();
154
+ } catch {
155
+ /* 关闭阶段不再抛错 */
156
+ }
157
+ try {
158
+ await this.writer.drain();
159
+ } catch {
160
+ /* ignore */
161
+ }
162
+ // 锁实例由外部注入,生命周期归宿主项目,这里不做关闭
163
+ }
164
+
165
+ // ---------------------------------------------------------------- 定时刷盘
166
+
167
+ private startFlushTimer(): void {
168
+ this.stopTimer();
169
+ const base = Math.min(Math.max(50, this.config.flushIntervalMs), MAX_INTERVAL_MS);
170
+ const jitter = Math.max(0, this.config.flushJitterMs);
171
+ // 递归 setTimeout 而非 setInterval:每轮都带随机抖动,避免多进程同相位抢同一把锁
172
+ const schedule = (): void => {
173
+ const delay = Math.min(base + Math.floor(Math.random() * (jitter + 1)), MAX_INTERVAL_MS);
174
+ this.timer = setTimeout(() => {
175
+ void this.tick().finally(schedule);
176
+ }, delay);
177
+ this.timer.unref?.();
178
+ };
179
+ schedule();
180
+ }
181
+
182
+ private stopTimer(): void {
183
+ if (this.timer) {
184
+ clearTimeout(this.timer);
185
+ this.timer = null;
186
+ }
187
+ }
188
+
189
+ /** 一轮刷盘;并发调用合并(期间来的 immediate 只标记 rerun,结束后补一轮) */
190
+ private async tick(): Promise<void> {
191
+ if (this.closed) return;
192
+ if (this.flushing) {
193
+ this.rerun = true;
194
+ return;
195
+ }
196
+ this.flushing = true;
197
+ try {
198
+ do {
199
+ this.rerun = false;
200
+ await this.flushOnce();
201
+ } while (this.rerun && !this.closed);
202
+ } catch (err: any) {
203
+ this.warnOnce(`flush:${err?.message ?? ''}`, `[node-log] 刷盘失败:${err?.message ?? err}`);
204
+ } finally {
205
+ this.flushing = false;
206
+ }
207
+ }
208
+
209
+ /** 手动刷盘(进程退出前、请求结束时调用) */
210
+ async flush(): Promise<void> {
211
+ if (this.closed) return;
212
+ await this.flushAll();
213
+ }
214
+
215
+ private async flushAll(): Promise<void> {
216
+ // 手动/关闭前刷盘:**忽略退避**(调用方显式要求立刻写,不能被退避窗口挡住)
217
+ for (let i = 0; i < MAX_FLUSH_ROUNDS; i++) {
218
+ const written = await this.flushOnce(true);
219
+ if (written === 0) break;
220
+ }
221
+ await this.writer.drain();
222
+ }
223
+
224
+ private async flushOnce(ignoreBackoff = false): Promise<number> {
225
+ const groups = this.buffer.drainByKey(this.config.flushBatchSize);
226
+ if (groups.size === 0) return 0;
227
+ const now = Date.now();
228
+ let written = 0;
229
+ for (const [rel, entries] of groups) {
230
+ const until = this.retryAfter.get(rel) ?? 0;
231
+ if (!ignoreBackoff && now < until) {
232
+ this.buffer.pushBack(rel, entries); // 退避窗口内不重试;条目仍在缓冲,恢复后补写
233
+ continue;
234
+ }
235
+ written += await this.writeGroup(rel, entries);
236
+ }
237
+ return written;
238
+ }
239
+
240
+ /** 写入一个文件的整批;失败/繁忙时保留未写出的条目(下轮再写,顺序不变) */
241
+ private async writeGroup(rel: string, entries: LogEntry[]): Promise<number> {
242
+ const chunks = this.buildChunks(entries);
243
+ let consumed = 0;
244
+ for (const chunk of chunks) {
245
+ const status = await this.writer.write(rel, chunk.text);
246
+ if (status === 'ok') {
247
+ consumed += chunk.count;
248
+ continue;
249
+ }
250
+ if (status === 'busy') {
251
+ this.counters.lockBusy += 1; // 锁繁忙不算故障:不设退避,下轮正常再抢
252
+ } else {
253
+ this.counters.writeFailures += 1;
254
+ this.backoff(rel);
255
+ }
256
+ // 未写出的部分放回队首,下轮优先重试(不重复已写出的内容)
257
+ this.buffer.pushBack(rel, entries.slice(consumed));
258
+ return consumed;
259
+ }
260
+ // 整批成功 → 清除该文件的退避与失败计数
261
+ this.retryAfter.delete(rel);
262
+ this.failureStreak.delete(rel);
263
+ return consumed;
264
+ }
265
+
266
+ /** 写失败后指数退避(上限 5 分钟):磁盘/挂载故障期间不每轮死磕同一个文件 */
267
+ private backoff(rel: string): void {
268
+ const streak = (this.failureStreak.get(rel) ?? 0) + 1;
269
+ this.failureStreak.set(rel, streak);
270
+ const base = Math.max(0, this.config.retryBackoffMs);
271
+ const delay = Math.min(base * 2 ** (streak - 1), MAX_BACKOFF_MS);
272
+ this.retryAfter.set(rel, Date.now() + delay);
273
+ }
274
+
275
+ /** 按 maxFlushBytes 切块(不切断行) */
276
+ private buildChunks(entries: LogEntry[]): Chunk[] {
277
+ const limit = Math.max(1, Math.floor(this.config.maxFlushBytes));
278
+ const chunks: Chunk[] = [];
279
+ let lines: string[] = [];
280
+ let size = 0;
281
+ for (const entry of entries) {
282
+ const line = this.formatEntry(entry);
283
+ const lineSize = Buffer.byteLength(line, 'utf8') + 1;
284
+ if (lines.length > 0 && size + lineSize > limit) {
285
+ chunks.push({ text: lines.join('\n') + '\n', count: lines.length });
286
+ lines = [];
287
+ size = 0;
288
+ }
289
+ lines.push(line);
290
+ size += lineSize;
291
+ }
292
+ if (lines.length > 0) chunks.push({ text: lines.join('\n') + '\n', count: lines.length });
293
+ return chunks;
294
+ }
295
+
296
+ // ---------------------------------------------------------------- 日志 API
297
+
298
+ debug(context: LogContext, message: string, options?: WriteOptions): void {
299
+ this.write('debug', context, message, options);
300
+ }
301
+
302
+ info(context: LogContext, message: string, options?: WriteOptions): void {
303
+ this.write('info', context, message, options);
304
+ }
305
+
306
+ warn(context: LogContext, message: string, options?: WriteOptions): void {
307
+ this.write('warn', context, message, options);
308
+ }
309
+
310
+ error(context: LogContext, message: string, options?: WriteOptions): void {
311
+ this.write('error', context, message, options);
312
+ }
313
+
314
+ /** 运行状态(供监控:丢弃条数、写失败次数、锁繁忙次数) */
315
+ stats(): LoggerStats {
316
+ return {
317
+ buffered: this.buffer.size,
318
+ dropped: this.buffer.droppedCount,
319
+ writeFailures: this.counters.writeFailures,
320
+ lockBusy: this.counters.lockBusy,
321
+ };
322
+ }
323
+
324
+ private write(level: LogLevel, context: LogContext, message: string, options?: WriteOptions): void {
325
+ if (this.closed) return;
326
+ if (!this.shouldLog(level)) return;
327
+
328
+ const entry: LogEntry = {
329
+ level,
330
+ context,
331
+ message,
332
+ timestamp: new Date(),
333
+ metadata: options?.metadata,
334
+ };
335
+
336
+ try {
337
+ // 阶段一:只写内存,同步入队后立即返回(不做任何 IO)
338
+ this.buffer.push(this.buildRelPath(entry), entry);
339
+ } catch (err: any) {
340
+ this.warnOnce(`buffer:${err?.message ?? ''}`, `[node-log] 入缓冲失败(已丢弃该条):${err?.message ?? err}`);
341
+ return;
342
+ }
343
+
344
+ // 关键日志:立即触发一次刷盘(后台执行,不阻塞业务)
345
+ if (options?.immediate || LEVELS.indexOf(level) >= LEVELS.indexOf(this.config.immediateLevel)) {
346
+ void this.tick();
347
+ }
348
+ }
349
+
350
+ private shouldLog(level: LogLevel): boolean {
351
+ return LEVELS.indexOf(level) >= LEVELS.indexOf(this.config.minLevel);
352
+ }
353
+
354
+ // ---------------------------------------------------------------- 路径与格式
355
+
356
+ private buildRelPath(entry: LogEntry): string {
357
+ return buildRelPath(
358
+ {
359
+ merchantId: entry.context.merchantId,
360
+ storeId: entry.context.storeId,
361
+ system: entry.context.system,
362
+ bizKey: entry.context.bizKey,
363
+ bizDate: entry.context.bizDate,
364
+ category: entry.context.category,
365
+ },
366
+ { fileScope: this.config.fileScope, host: this.host },
367
+ entry.timestamp
368
+ );
369
+ }
370
+
371
+ private formatEntry(entry: LogEntry): string {
372
+ const { timestamp, level, context, message, metadata } = entry;
373
+ const record: Record<string, any> = {
374
+ timestamp: beijingTimestamp(timestamp), // 固定 +08:00,不依赖运行环境 TZ
375
+ host: this.host,
376
+ pid: this.pid,
377
+ level,
378
+ message,
379
+ };
380
+ // 类别模式:仅输出 category,不输出 商户/门店/system(避免噪声)
381
+ if (context.category) {
382
+ record.category = context.category;
383
+ } else {
384
+ record.merchantId = context.merchantId;
385
+ record.storeId = context.storeId;
386
+ record.system = context.system;
387
+ }
388
+ record.bizKey = context.bizKey;
389
+ if (context.userId) record.userId = context.userId;
390
+ if (context.requestId) record.requestId = context.requestId;
391
+ if (context.extra) record.extra = context.extra;
392
+ if (metadata) record.metadata = metadata;
393
+ // 容错序列化:单条脏数据只影响这一条,绝不影响整批
394
+ return stringifySafe(record);
395
+ }
396
+
397
+ private warnOnce(key: string, message: string): void {
398
+ if (this.warned.has(key)) return;
399
+ this.warned.add(key);
400
+ try {
401
+ console.warn(message);
402
+ } catch {
403
+ /* console 不可用时静默 */
404
+ }
405
+ }
406
+ }
407
+
408
+ /**
409
+ * 容错 JSON 序列化:循环引用替换为 `[Circular]`、BigInt 转字符串、其余异常降级为占位文本。
410
+ */
411
+ function stringifySafe(value: unknown): string {
412
+ const seen = new WeakSet<object>();
413
+ try {
414
+ return (
415
+ JSON.stringify(value, (_key, val) => {
416
+ if (typeof val === 'bigint') return val.toString();
417
+ if (typeof val === 'object' && val !== null) {
418
+ if (seen.has(val)) return '[Circular]';
419
+ seen.add(val);
420
+ }
421
+ return val;
422
+ }) ?? '{}'
423
+ );
424
+ } catch (err: any) {
425
+ return JSON.stringify({ message: '[node-log] 日志序列化失败', reason: String(err?.message ?? err) });
426
+ }
427
+ }
package/src/path.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * 目录与文件路径拼装 + 安全过滤。
3
+ *
4
+ * 规则(AGENTS.md §3):
5
+ * {baseDir}/{年}/{年月}/{年月日}/商户{merchantId}/门店{storeId}/{system}/{bizKey}.log
6
+ * 日期目录 = **单据创建日期**(本项目无跨天订单),保证同一单据永远写到同一个文件。
7
+ */
8
+ import { beijingDateKey, beijingDateParts } from './time.js';
9
+
10
+ export interface PathInput {
11
+ merchantId: string | number;
12
+ storeId: string | number;
13
+ system: string;
14
+ bizKey: string;
15
+ /** 单据日期 YYYYMMDD;缺省时从 bizKey 前 8 位解析,再缺省用当前北京时间 */
16
+ bizDate?: string;
17
+ /**
18
+ * 类别路径段(可选):用于按业务类别分子目录(如 `db/sql`、`db/msql`、`db/shardorm`)。
19
+ * 传入后「商户/门店/system」三段被整段替换为该类别路径,便于按 docs/日志.md 的类别约定归档。
20
+ * 为空(不传)时走默认「商户{id}/门店{id}/{system}」结构,向后兼容既有调用。
21
+ */
22
+ category?: string;
23
+ }
24
+
25
+ export type FileScope = 'shared' | 'host';
26
+
27
+ export interface BuildPathOptions {
28
+ /** shared = 多机多进程写同一个文件(默认);host = 每台机器各写各的(彻底免锁) */
29
+ fileScope?: FileScope;
30
+ /** fileScope='host' 时追加的主机标识 */
31
+ host?: string;
32
+ }
33
+
34
+ /** 商户/门店 ID:只保留数字,杜绝路径遍历 */
35
+ export function sanitizeId(value: string | number): string {
36
+ const digits = String(value ?? '').replace(/[^0-9]/g, '');
37
+ return digits || '0';
38
+ }
39
+
40
+ /**
41
+ * 路径片段通用清洗:保留「字母/数字/._-」(含中文等 Unicode 字母数字),
42
+ * 其余(含 `/`、`\`、空格)统一替换为 `-`;再消除 `..` 与前导点,杜绝路径穿越。
43
+ */
44
+ function sanitizeSegment(value: string, maxLen: number): string {
45
+ return String(value ?? '')
46
+ .replace(/[^\p{L}\p{N}._-]/gu, '-')
47
+ .replace(/\.{2,}/g, '-')
48
+ .replace(/^\.+/, '')
49
+ .slice(0, maxLen);
50
+ }
51
+
52
+ /** 业务系统名(如「支付系统」):清洗后为空则 default */
53
+ export function sanitizeSystem(system: string): string {
54
+ return sanitizeSegment(system, 64) || 'default';
55
+ }
56
+
57
+ /** 业务主键:清洗后为空则 unknown */
58
+ export function sanitizeBizKey(bizKey: string): string {
59
+ return sanitizeSegment(bizKey, 200) || 'unknown';
60
+ }
61
+
62
+ /**
63
+ * 类别路径段(可选):用于按业务类别分子目录(如 `db/sql`、`db/msql`、`db/shardorm`)。
64
+ * 允许字母/数字/._-/ 与 `/`(支持多级子目录),但阻断路径穿越:
65
+ * 去掉 `..`、裁掉前导 `/`(避免绝对路径)、折叠连续 `/`、裁掉首尾 `-`、超长截断。
66
+ * 为空时返回 '',调用方据此走默认的「商户/门店/system」结构,向后兼容。
67
+ */
68
+ export function sanitizeCategory(value: string | undefined): string {
69
+ if (!value) return '';
70
+ return String(value)
71
+ .replace(/[^\p{L}\p{N}._\/-]/gu, '-')
72
+ .replace(/\.{2,}/g, '-')
73
+ .replace(/^\/+/, '')
74
+ .replace(/\/+/g, '/')
75
+ .replace(/^-+/, '')
76
+ .replace(/-+$/, '')
77
+ .slice(0, 120);
78
+ }
79
+
80
+ /**
81
+ * 解析单据日期 YYYYMMDD:显式 bizDate > bizKey 前 8 位数字 > 当前北京时间。
82
+ * 与展示侧一致:全部节点统一北京时间,保证同一单据永远命中同一目录。
83
+ */
84
+ export function resolveBizDate(input: PathInput, now: Date = new Date()): string {
85
+ const explicit = String(input.bizDate ?? '').trim();
86
+ if (/^\d{8}$/.test(explicit)) return explicit;
87
+
88
+ const fromKey = String(input.bizKey ?? '').match(/^(\d{8})/);
89
+ if (fromKey) return fromKey[1];
90
+
91
+ return beijingDateKey(now);
92
+ }
93
+
94
+ /**
95
+ * 拼装相对路径(不含 baseDir),如:
96
+ * 2026/202609/20260929/商户1/门店1/支付系统/202609290001.log
97
+ */
98
+ export function buildRelPath(
99
+ input: PathInput,
100
+ options: BuildPathOptions = {},
101
+ now: Date = new Date()
102
+ ): string {
103
+ const date = resolveBizDate(input, now);
104
+ const year = date.slice(0, 4);
105
+ const yearMonth = date.slice(0, 6);
106
+
107
+ const merchantId = sanitizeId(input.merchantId);
108
+ const storeId = sanitizeId(input.storeId);
109
+ const system = sanitizeSystem(input.system);
110
+ const bizKey = sanitizeBizKey(input.bizKey);
111
+ const suffix = options.fileScope === 'host' && options.host ? `.${options.host}` : '';
112
+
113
+ // 类别模式:整段替换「商户/门店/system」为类别路径(支持多级子目录,如 db/sql)
114
+ const category = sanitizeCategory(input.category);
115
+ const middle = category ? [category] : [`商户${merchantId}`, `门店${storeId}`, system];
116
+
117
+ return [
118
+ year,
119
+ yearMonth,
120
+ date,
121
+ ...middle,
122
+ `${bizKey}${suffix}.log`,
123
+ ].join('/');
124
+ }
package/src/time.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 北京时间工具。
3
+ *
4
+ * 组件内所有时间(日志字段、目录日期)**固定 +08:00**,不依赖运行环境的 `TZ`:
5
+ * Docker/K8s 容器默认是 UTC,直接用 `toISOString()` 会整体差 8 小时,
6
+ * 表现为「日志时间不对」和「每天 00:00–08:00 的日志落到前一天的目录」。
7
+ */
8
+
9
+ const BEIJING_OFFSET_MS = 8 * 3600 * 1000;
10
+
11
+ /** 北京时间的 Date 视图(把时间轴平移 +8h 后用 UTC 取值) */
12
+ function beijingDate(d: Date): Date {
13
+ return new Date(d.getTime() + BEIJING_OFFSET_MS);
14
+ }
15
+
16
+ /** ISO-8601 毫秒,固定 +08:00 偏移,如 2026-09-29T10:00:00.123+08:00 */
17
+ export function beijingTimestamp(d: Date = new Date()): string {
18
+ return beijingDate(d).toISOString().replace(/Z$/, '+08:00');
19
+ }
20
+
21
+ export interface BeijingDateParts {
22
+ /** 2026 */
23
+ year: string;
24
+ /** 09 */
25
+ month: string;
26
+ /** 29 */
27
+ day: string;
28
+ /** 202609 */
29
+ yearMonth: string;
30
+ /** 20260929 */
31
+ date: string;
32
+ }
33
+
34
+ /** 北京时间的日期各段(目录分层用) */
35
+ export function beijingDateParts(d: Date = new Date()): BeijingDateParts {
36
+ const b = beijingDate(d);
37
+ const year = String(b.getUTCFullYear());
38
+ const month = String(b.getUTCMonth() + 1).padStart(2, '0');
39
+ const day = String(b.getUTCDate()).padStart(2, '0');
40
+ return { year, month, day, yearMonth: `${year}${month}`, date: `${year}${month}${day}` };
41
+ }
42
+
43
+ /** 北京时间的 YYYYMMDD */
44
+ export function beijingDateKey(d: Date = new Date()): string {
45
+ return beijingDateParts(d).date;
46
+ }