@openclaw-china/shared 0.1.27 → 0.1.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw-china/shared",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -35,23 +35,31 @@ export {
35
35
  } from "./media-parser.js";
36
36
 
37
37
  // 媒体 IO
38
- export {
39
- // 类型
40
- type MediaReadResult,
41
- type MediaReadOptions,
42
- type PathSecurityOptions,
43
- // 错误类
44
- FileSizeLimitError,
45
- MediaTimeoutError,
46
- PathSecurityError,
38
+ export {
39
+ // 类型
40
+ type MediaReadResult,
41
+ type MediaReadOptions,
42
+ type DownloadToTempFileResult,
43
+ type DownloadToTempFileOptions,
44
+ type FinalizeInboundMediaOptions,
45
+ type PruneInboundMediaDirOptions,
46
+ type PathSecurityOptions,
47
+ // 错误类
48
+ FileSizeLimitError,
49
+ MediaTimeoutError,
50
+ PathSecurityError,
47
51
  // 路径安全
48
52
  validatePathSecurity,
49
53
  getDefaultAllowedPrefixes,
50
54
  // MIME 类型
51
55
  getMimeType,
52
56
  // 媒体读取函数
53
- fetchMediaFromUrl,
54
- readMediaFromLocal,
55
- readMedia,
56
- readMediaBatch,
57
- } from "./media-io.js";
57
+ fetchMediaFromUrl,
58
+ readMediaFromLocal,
59
+ readMedia,
60
+ readMediaBatch,
61
+ downloadToTempFile,
62
+ finalizeInboundMediaFile,
63
+ pruneInboundMediaDir,
64
+ cleanupFileSafe,
65
+ } from "./media-io.js";
@@ -0,0 +1,180 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as fsPromises from "node:fs/promises";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import {
7
+ FileSizeLimitError,
8
+ MediaTimeoutError,
9
+ cleanupFileSafe,
10
+ downloadToTempFile,
11
+ finalizeInboundMediaFile,
12
+ pruneInboundMediaDir,
13
+ } from "./media-io.js";
14
+
15
+ const tempDirs: string[] = [];
16
+
17
+ async function createTempDir(prefix: string): Promise<string> {
18
+ const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), prefix));
19
+ tempDirs.push(dir);
20
+ return dir;
21
+ }
22
+
23
+ afterEach(async () => {
24
+ for (const dir of tempDirs.splice(0, tempDirs.length)) {
25
+ await fsPromises.rm(dir, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ describe("downloadToTempFile", () => {
30
+ it("downloads HTTP response and stores a temp file", async () => {
31
+ const dir = await createTempDir("shared-media-io-");
32
+ const body = Buffer.from("hello-media", "utf8");
33
+ const fetchFn: typeof globalThis.fetch = async () =>
34
+ new Response(body, {
35
+ status: 200,
36
+ headers: {
37
+ "content-type": "image/png",
38
+ "content-length": String(body.length),
39
+ },
40
+ });
41
+
42
+ const result = await downloadToTempFile("https://example.com/a.png", {
43
+ fetch: fetchFn,
44
+ tempDir: dir,
45
+ tempPrefix: "dingtalk-file",
46
+ });
47
+
48
+ expect(result.path.startsWith(dir)).toBe(true);
49
+ expect(result.fileName.endsWith(".png")).toBe(true);
50
+ expect(result.size).toBe(body.length);
51
+ expect(result.contentType).toBe("image/png");
52
+
53
+ const saved = await fsPromises.readFile(result.path);
54
+ expect(saved.equals(body)).toBe(true);
55
+ });
56
+
57
+ it("throws FileSizeLimitError when Content-Length exceeds maxSize", async () => {
58
+ const fetchFn: typeof globalThis.fetch = async () =>
59
+ new Response("too-large", {
60
+ status: 200,
61
+ headers: {
62
+ "content-type": "application/octet-stream",
63
+ "content-length": "1024",
64
+ },
65
+ });
66
+
67
+ await expect(
68
+ downloadToTempFile("https://example.com/too-large.bin", {
69
+ fetch: fetchFn,
70
+ maxSize: 100,
71
+ })
72
+ ).rejects.toBeInstanceOf(FileSizeLimitError);
73
+ });
74
+
75
+ it("throws MediaTimeoutError on timeout", async () => {
76
+ const fetchFn: typeof globalThis.fetch = async (_url, init) =>
77
+ await new Promise<Response>((_resolve, reject) => {
78
+ const signal = init?.signal;
79
+ signal?.addEventListener("abort", () => {
80
+ const err = new Error("aborted");
81
+ (err as Error & { name: string }).name = "AbortError";
82
+ reject(err);
83
+ });
84
+ });
85
+
86
+ await expect(
87
+ downloadToTempFile("https://example.com/slow.bin", {
88
+ fetch: fetchFn,
89
+ timeout: 10,
90
+ })
91
+ ).rejects.toBeInstanceOf(MediaTimeoutError);
92
+ });
93
+ });
94
+
95
+ describe("cleanupFileSafe", () => {
96
+ it("removes file and ignores missing file", async () => {
97
+ const dir = await createTempDir("shared-media-clean-");
98
+ const filePath = path.join(dir, "a.txt");
99
+ await fsPromises.writeFile(filePath, "x", "utf8");
100
+ expect(fs.existsSync(filePath)).toBe(true);
101
+
102
+ await cleanupFileSafe(filePath);
103
+ expect(fs.existsSync(filePath)).toBe(false);
104
+
105
+ await expect(cleanupFileSafe(filePath)).resolves.toBeUndefined();
106
+ await expect(cleanupFileSafe(undefined)).resolves.toBeUndefined();
107
+ });
108
+ });
109
+
110
+ describe("inbound media retention", () => {
111
+ it("finalizes temp media into inbound/YYYY-MM-DD", async () => {
112
+ const tempDir = await createTempDir("shared-media-temp-");
113
+ const inboundDir = await createTempDir("shared-media-inbound-");
114
+ const sourcePath = path.join(tempDir, "img-1.jpg");
115
+ await fsPromises.writeFile(sourcePath, "abc", "utf8");
116
+
117
+ const finalPath = await finalizeInboundMediaFile({
118
+ filePath: sourcePath,
119
+ tempDir,
120
+ inboundDir,
121
+ });
122
+
123
+ expect(finalPath.startsWith(inboundDir)).toBe(true);
124
+ expect(fs.existsSync(finalPath)).toBe(true);
125
+ expect(fs.existsSync(sourcePath)).toBe(false);
126
+ });
127
+
128
+ it("does not move files outside tempDir", async () => {
129
+ const tempDir = await createTempDir("shared-media-temp-");
130
+ const inboundDir = await createTempDir("shared-media-inbound-");
131
+ const outsideDir = await createTempDir("shared-media-outside-");
132
+ const sourcePath = path.join(outsideDir, "a.txt");
133
+ await fsPromises.writeFile(sourcePath, "x", "utf8");
134
+
135
+ const finalPath = await finalizeInboundMediaFile({
136
+ filePath: sourcePath,
137
+ tempDir,
138
+ inboundDir,
139
+ });
140
+
141
+ expect(finalPath).toBe(sourcePath);
142
+ expect(fs.existsSync(sourcePath)).toBe(true);
143
+ });
144
+
145
+ it("prunes only expired files in date dirs and keeps recent files", async () => {
146
+ const inboundDir = await createTempDir("shared-media-prune-");
147
+ const oldDir = path.join(inboundDir, "2024-01-01");
148
+ const newDir = path.join(inboundDir, "2024-01-02");
149
+ await fsPromises.mkdir(oldDir, { recursive: true });
150
+ await fsPromises.mkdir(newDir, { recursive: true });
151
+
152
+ const oldFile = path.join(oldDir, "old.jpg");
153
+ const newFile = path.join(newDir, "new.jpg");
154
+ const nestedDir = path.join(oldDir, "nested");
155
+ const nestedFile = path.join(nestedDir, "nested.jpg");
156
+ await fsPromises.writeFile(oldFile, "old", "utf8");
157
+ await fsPromises.writeFile(newFile, "new", "utf8");
158
+ await fsPromises.mkdir(nestedDir, { recursive: true });
159
+ await fsPromises.writeFile(nestedFile, "nested", "utf8");
160
+
161
+ const oldTs = new Date("2024-01-01T00:00:00.000Z");
162
+ const newTs = new Date("2024-01-02T00:00:00.000Z");
163
+ await fsPromises.utimes(oldDir, oldTs, oldTs);
164
+ await fsPromises.utimes(oldFile, oldTs, oldTs);
165
+ await fsPromises.utimes(newDir, newTs, newTs);
166
+ await fsPromises.utimes(newFile, newTs, newTs);
167
+
168
+ const nowMs = new Date("2024-01-03T00:00:00.000Z").getTime();
169
+ await pruneInboundMediaDir({
170
+ inboundDir,
171
+ keepDays: 1,
172
+ nowMs,
173
+ });
174
+
175
+ expect(fs.existsSync(oldFile)).toBe(false);
176
+ expect(fs.existsSync(newFile)).toBe(true);
177
+ expect(fs.existsSync(nestedFile)).toBe(true);
178
+ expect(fs.existsSync(oldDir)).toBe(true);
179
+ });
180
+ });
@@ -6,11 +6,12 @@
6
6
  * @module @openclaw-china/shared/media
7
7
  */
8
8
 
9
- import * as fs from "fs";
10
- import * as fsPromises from "fs/promises";
11
- import * as path from "path";
12
- import * as os from "os";
13
- import { isHttpUrl, normalizeLocalPath, getExtension } from "./media-parser.js";
9
+ import * as fs from "fs";
10
+ import * as fsPromises from "fs/promises";
11
+ import * as path from "path";
12
+ import * as os from "os";
13
+ import { isHttpUrl, normalizeLocalPath, getExtension } from "./media-parser.js";
14
+ import { resolveExtension } from "../file/file-utils.js";
14
15
 
15
16
  // ============================================================================
16
17
  // 类型定义
@@ -19,7 +20,7 @@ import { isHttpUrl, normalizeLocalPath, getExtension } from "./media-parser.js";
19
20
  /**
20
21
  * 媒体读取结果
21
22
  */
22
- export interface MediaReadResult {
23
+ export interface MediaReadResult {
23
24
  /** 文件内容 Buffer */
24
25
  buffer: Buffer;
25
26
  /** 文件名 */
@@ -28,19 +29,71 @@ export interface MediaReadResult {
28
29
  size: number;
29
30
  /** MIME 类型(如果可检测) */
30
31
  mimeType?: string;
31
- }
32
+ }
33
+
34
+ /**
35
+ * 下载并落盘到临时文件的结果
36
+ */
37
+ export interface DownloadToTempFileResult {
38
+ /** 绝对路径 */
39
+ path: string;
40
+ /** 写入文件名 */
41
+ fileName: string;
42
+ /** MIME 类型 */
43
+ contentType: string;
44
+ /** 文件大小(字节) */
45
+ size: number;
46
+ /** 来源文件名(若可解析) */
47
+ sourceFileName?: string;
48
+ }
32
49
 
33
50
  /**
34
51
  * 媒体读取选项
35
52
  */
36
- export interface MediaReadOptions {
53
+ export interface MediaReadOptions {
37
54
  /** 超时时间(毫秒),默认 30000 */
38
55
  timeout?: number;
39
56
  /** 最大文件大小(字节),默认 100MB */
40
57
  maxSize?: number;
41
58
  /** 自定义 fetch 函数(用于依赖注入) */
42
59
  fetch?: typeof globalThis.fetch;
43
- }
60
+ }
61
+
62
+ /**
63
+ * 下载并落盘到临时文件的选项
64
+ */
65
+ export interface DownloadToTempFileOptions extends MediaReadOptions {
66
+ /** 目标临时目录,默认 os.tmpdir() */
67
+ tempDir?: string;
68
+ /** 文件名前缀,默认 "media" */
69
+ tempPrefix?: string;
70
+ /** 来源文件名(优先用于扩展名推断) */
71
+ sourceFileName?: string;
72
+ }
73
+
74
+ /**
75
+ * 归档入站媒体参数
76
+ */
77
+ export interface FinalizeInboundMediaOptions {
78
+ /** 当前文件路径 */
79
+ filePath: string;
80
+ /** 临时目录(仅该目录下文件会归档) */
81
+ tempDir: string;
82
+ /** 入站归档根目录 */
83
+ inboundDir: string;
84
+ }
85
+
86
+ /**
87
+ * 过期清理参数
88
+ */
89
+ export interface PruneInboundMediaDirOptions {
90
+ /** 入站归档根目录 */
91
+ inboundDir: string;
92
+ /** 保留天数,>=0 生效 */
93
+ keepDays: number;
94
+ /** 当前时间(测试用) */
95
+ nowMs?: number;
96
+ }
44
97
 
45
98
  /**
46
99
  * 路径安全检查选项
@@ -65,17 +118,68 @@ const DEFAULT_TIMEOUT = 30000;
65
118
  const DEFAULT_MAX_SIZE = 100 * 1024 * 1024;
66
119
 
67
120
  /** 默认最大路径长度 */
68
- const DEFAULT_MAX_PATH_LENGTH = 4096;
121
+ const DEFAULT_MAX_PATH_LENGTH = 4096;
69
122
 
70
123
  /** 默认允许的路径前缀(Unix) */
71
- const DEFAULT_UNIX_PREFIXES = [
124
+ const DEFAULT_UNIX_PREFIXES = [
72
125
  "/tmp",
73
126
  "/var/tmp",
74
127
  "/private/tmp",
75
128
  "/Users",
76
129
  "/home",
77
130
  "/root",
78
- ];
131
+ ];
132
+
133
+ function parseContentDispositionFilename(value: string | null): string | undefined {
134
+ if (!value) return undefined;
135
+ const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
136
+ if (utf8Match?.[1]) {
137
+ try {
138
+ return decodeURIComponent(utf8Match[1].trim());
139
+ } catch {
140
+ return utf8Match[1].trim();
141
+ }
142
+ }
143
+ const plainMatch = value.match(/filename=([^;]+)/i);
144
+ if (!plainMatch?.[1]) return undefined;
145
+ const raw = plainMatch[1].trim().replace(/^["']|["']$/g, "");
146
+ return raw || undefined;
147
+ }
148
+
149
+ function sanitizeFileName(name: string): string {
150
+ const trimmed = name.trim();
151
+ if (!trimmed) return "file";
152
+ const normalized = trimmed.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_");
153
+ return normalized || "file";
154
+ }
155
+
156
+ function resolveFileNameFromUrl(url: string): string | undefined {
157
+ try {
158
+ const parsed = new URL(url);
159
+ const base = path.basename(parsed.pathname);
160
+ if (!base || base === "/") return undefined;
161
+ return base;
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ }
166
+
167
+ function normalizeForCompare(value: string): string {
168
+ return path.resolve(value).replace(/\\/g, "/").toLowerCase();
169
+ }
170
+
171
+ function isPathUnderDir(filePath: string, dirPath: string): boolean {
172
+ const f = normalizeForCompare(filePath);
173
+ const d = normalizeForCompare(dirPath).replace(/\/+$/, "");
174
+ return f === d || f.startsWith(`${d}/`);
175
+ }
176
+
177
+ function formatDateDir(date = new Date()): string {
178
+ const yyyy = date.getFullYear();
179
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
180
+ const dd = String(date.getDate()).padStart(2, "0");
181
+ return `${yyyy}-${mm}-${dd}`;
182
+ }
79
183
 
80
184
  /** 扩展名到 MIME 类型映射 */
81
185
  const EXT_TO_MIME: Record<string, string> = {
@@ -270,7 +374,7 @@ export function getMimeType(filePath: string): string | undefined {
270
374
  * @param options - 读取选项
271
375
  * @returns 媒体读取结果
272
376
  */
273
- export async function fetchMediaFromUrl(
377
+ export async function fetchMediaFromUrl(
274
378
  url: string,
275
379
  options: MediaReadOptions = {}
276
380
  ): Promise<MediaReadResult> {
@@ -336,7 +440,106 @@ export async function fetchMediaFromUrl(
336
440
  } finally {
337
441
  clearTimeout(timeoutId);
338
442
  }
339
- }
443
+ }
444
+
445
+ /**
446
+ * 下载 HTTP 媒体并写入临时文件
447
+ */
448
+ export async function downloadToTempFile(
449
+ url: string,
450
+ options: DownloadToTempFileOptions = {}
451
+ ): Promise<DownloadToTempFileResult> {
452
+ if (!isHttpUrl(url)) {
453
+ throw new Error(`downloadToTempFile expects an HTTP URL, got: ${url}`);
454
+ }
455
+
456
+ const {
457
+ timeout = DEFAULT_TIMEOUT,
458
+ maxSize = DEFAULT_MAX_SIZE,
459
+ fetch: customFetch = globalThis.fetch,
460
+ tempDir = os.tmpdir(),
461
+ tempPrefix = "media",
462
+ sourceFileName,
463
+ } = options;
464
+
465
+ const controller = new AbortController();
466
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
467
+
468
+ try {
469
+ const response = await customFetch(url, { signal: controller.signal });
470
+ if (!response.ok) {
471
+ const responseBody = await response.text().catch(() => "");
472
+ throw new Error(`HTTP ${response.status}: ${responseBody}`);
473
+ }
474
+
475
+ const contentType =
476
+ response.headers.get("content-type")?.split(";")[0].trim() ||
477
+ "application/octet-stream";
478
+
479
+ const contentLength = response.headers.get("content-length");
480
+ if (contentLength) {
481
+ const declared = parseInt(contentLength, 10);
482
+ if (!Number.isNaN(declared) && declared > maxSize) {
483
+ throw new FileSizeLimitError(declared, maxSize);
484
+ }
485
+ }
486
+
487
+ const body = response.body;
488
+ if (!body) {
489
+ throw new Error("Response body is null");
490
+ }
491
+
492
+ const chunks: Uint8Array[] = [];
493
+ let totalBytes = 0;
494
+ const reader = body.getReader();
495
+
496
+ try {
497
+ while (true) {
498
+ const { done, value } = await reader.read();
499
+ if (done) break;
500
+ totalBytes += value.length;
501
+ if (totalBytes > maxSize) {
502
+ reader.cancel();
503
+ throw new FileSizeLimitError(totalBytes, maxSize);
504
+ }
505
+ chunks.push(value);
506
+ }
507
+ } finally {
508
+ reader.releaseLock();
509
+ }
510
+
511
+ const sourceName =
512
+ sourceFileName ||
513
+ parseContentDispositionFilename(response.headers.get("content-disposition")) ||
514
+ resolveFileNameFromUrl(url) ||
515
+ "file";
516
+
517
+ const safePrefix = sanitizeFileName(tempPrefix) || "media";
518
+ const ext = resolveExtension(contentType, sourceName);
519
+ const random = Math.random().toString(36).slice(2, 8);
520
+ const fileName = `${safePrefix}-${Date.now()}-${random}${ext}`;
521
+ const fullPath = path.join(tempDir, fileName);
522
+
523
+ await fsPromises.mkdir(tempDir, { recursive: true });
524
+ const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
525
+ await fsPromises.writeFile(fullPath, buffer);
526
+
527
+ return {
528
+ path: fullPath,
529
+ fileName,
530
+ contentType,
531
+ size: totalBytes,
532
+ sourceFileName: sourceName,
533
+ };
534
+ } catch (error) {
535
+ if (error instanceof Error && error.name === "AbortError") {
536
+ throw new MediaTimeoutError(timeout);
537
+ }
538
+ throw error;
539
+ } finally {
540
+ clearTimeout(timeoutId);
541
+ }
542
+ }
340
543
 
341
544
  /**
342
545
  * 从本地路径读取媒体
@@ -406,7 +609,7 @@ export async function readMedia(
406
609
  * @param options - 读取选项
407
610
  * @returns 媒体读取结果列表(包含成功和失败的结果)
408
611
  */
409
- export async function readMediaBatch(
612
+ export async function readMediaBatch(
410
613
  sources: string[],
411
614
  options: MediaReadOptions & PathSecurityOptions = {}
412
615
  ): Promise<Array<{ source: string; result?: MediaReadResult; error?: Error }>> {
@@ -414,10 +617,116 @@ export async function readMediaBatch(
414
617
  sources.map((source) => readMedia(source, options))
415
618
  );
416
619
 
417
- return results.map((result, index) => {
620
+ return results.map((result, index) => {
418
621
  if (result.status === "fulfilled") {
419
622
  return { source: sources[index], result: result.value };
420
623
  }
421
- return { source: sources[index], error: result.reason as Error };
422
- });
423
- }
624
+ return { source: sources[index], error: result.reason as Error };
625
+ });
626
+ }
627
+
628
+ /**
629
+ * 将临时媒体文件归档到 inbound/YYYY-MM-DD
630
+ * - 仅归档 tempDir 下的文件
631
+ * - 失败时返回原路径,不抛错
632
+ */
633
+ export async function finalizeInboundMediaFile(
634
+ options: FinalizeInboundMediaOptions
635
+ ): Promise<string> {
636
+ const current = String(options.filePath ?? "").trim();
637
+ if (!current) return current;
638
+
639
+ if (!isPathUnderDir(current, options.tempDir)) {
640
+ return current;
641
+ }
642
+
643
+ const datedDir = path.join(options.inboundDir, formatDateDir());
644
+ const target = path.join(datedDir, path.basename(current));
645
+
646
+ try {
647
+ await fsPromises.mkdir(datedDir, { recursive: true });
648
+ await fsPromises.rename(current, target);
649
+ return target;
650
+ } catch {
651
+ return current;
652
+ }
653
+ }
654
+
655
+ /**
656
+ * 清理 inbound 目录中过期文件
657
+ * - 仅处理 YYYY-MM-DD 目录
658
+ * - 仅删除过期文件,不递归子目录,不删除目录
659
+ */
660
+ export async function pruneInboundMediaDir(
661
+ options: PruneInboundMediaDirOptions
662
+ ): Promise<void> {
663
+ const keepDays = Number(options.keepDays);
664
+ if (!Number.isFinite(keepDays) || keepDays < 0) return;
665
+
666
+ const now = options.nowMs ?? Date.now();
667
+ const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
668
+
669
+ let entries: string[] = [];
670
+ try {
671
+ entries = await fsPromises.readdir(options.inboundDir);
672
+ } catch {
673
+ return;
674
+ }
675
+
676
+ for (const entry of entries) {
677
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(entry)) continue;
678
+ const dirPath = path.join(options.inboundDir, entry);
679
+
680
+ let dirStats;
681
+ try {
682
+ dirStats = await fsPromises.stat(dirPath);
683
+ } catch {
684
+ continue;
685
+ }
686
+ if (!dirStats.isDirectory()) continue;
687
+
688
+ const dirTime = dirStats.mtimeMs || dirStats.ctimeMs || 0;
689
+ if (dirTime >= cutoff) continue;
690
+
691
+ let files: string[] = [];
692
+ try {
693
+ files = await fsPromises.readdir(dirPath);
694
+ } catch {
695
+ continue;
696
+ }
697
+
698
+ for (const file of files) {
699
+ const fp = path.join(dirPath, file);
700
+ try {
701
+ const fst = await fsPromises.stat(fp);
702
+ if (fst.isFile() && (fst.mtimeMs || fst.ctimeMs || 0) < cutoff) {
703
+ await fsPromises.unlink(fp);
704
+ }
705
+ } catch {
706
+ // ignore
707
+ }
708
+ }
709
+ }
710
+ }
711
+
712
+ /**
713
+ * 安全删除文件
714
+ * - filePath 为空直接返回
715
+ * - ENOENT 视为成功
716
+ * - 其他错误可通过 onError 回调记录
717
+ */
718
+ export async function cleanupFileSafe(
719
+ filePath: string | undefined,
720
+ onError?: (error: unknown, filePath: string) => void
721
+ ): Promise<void> {
722
+ if (!filePath) return;
723
+
724
+ try {
725
+ await fsPromises.unlink(filePath);
726
+ } catch (error) {
727
+ if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") {
728
+ return;
729
+ }
730
+ onError?.(error, filePath);
731
+ }
732
+ }