import fs from 'fs/promises'; import path from 'path'; import crypto from 'crypto'; import { Reporter, UploadSummary, UploadFileResult } from './reporter'; import { StorageClient } from './StorageClient'; import { shouldInclude } from './filters'; import { buildCdnAccessUrl } from './urlHelper'; import type { FileProcessor } from './fileProcessor'; /** 常见扩展名 -> Content-Type,避免图片等以错误类型上传导致损坏或无法预览 */ const EXT_TO_MIME: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon', bmp: 'image/bmp', tiff: 'image/tiff', tif: 'image/tiff', js: 'application/javascript', mjs: 'application/javascript', json: 'application/json', css: 'text/css', html: 'text/html', htm: 'text/html', txt: 'text/plain', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', eot: 'application/vnd.ms-fontobject', }; function getContentType(relativePath: string): string | undefined { const ext = path.extname(relativePath).slice(1).toLowerCase(); return ext ? EXT_TO_MIME[ext] : undefined; } /** 在文件名后面拼接 hash,如 file.js -> file.js.abc12def */ function relativePathWithHash(relativePath: string, fileHash: string): string { const normalized = relativePath.replace(/\\/g, '/'); return normalized + '.' + fileHash; } /** 单次上传的目录参数 */ export interface UploadDirectoryOptions { bucket: string; localDir: string; env?: string; pathPrefix?: string; include?: string[]; exclude?: string[]; /** 上传后的文件名在扩展名前拼接文件内容 hash(如 file.js -> file.abc12def.js) */ hash?: boolean; /** 匹配到的 key 强制上传(不跳过),如从 FILE_RE_WHITE_LIST 解析的列表 */ forceUploadPatterns?: string[]; } export interface LocalFile { absolutePath: string; relativePath: string; } /** * 上传流程基类:封装「收集文件 → 过滤 → 逐个上传 → 上报」。 * 子类可覆盖 collectFiles、uploadOneFile 等扩展行为。 */ export abstract class UploadService { protected constructor( protected readonly storageClient: StorageClient, protected readonly basePrefix: string, protected readonly cdnBaseUrl?: string, protected readonly reporter?: Reporter, protected readonly fileProcessor?: FileProcessor, ) {} async uploadDirectory(options: UploadDirectoryOptions): Promise { const files = await this.collectFiles( options.localDir, options.include, options.exclude, ); const summary: UploadSummary = { total: files.length, success: [], failed: [], skipped: [], }; // 若有 listObjectKeys:一次拉取前缀下已有 key,用 Set 判断存在,避免每个文件都 head const prefix = this.buildKey(this.basePrefix, options.env, options.pathPrefix, ''); let existingKeysSet: Set | undefined; if (typeof this.storageClient.listObjectKeys === 'function') { existingKeysSet = new Set(await this.storageClient.listObjectKeys(options.bucket, prefix)); } if (this.reporter?.onStart) { await this.reporter.onStart({ env: options.env, bucket: options.bucket, basePrefix: this.basePrefix, pathPrefix: options.pathPrefix, localDir: options.localDir, cdnBaseUrl: this.cdnBaseUrl, }); } for (const file of files) { const result = await this.uploadOneFile(file, options, existingKeysSet); summary[result.status].push( result.status === 'success' ? [this.cdnBaseUrl ?? '', result.key ?? ''].join('/') : file.relativePath); const current = summary.success.length + summary.failed.length + summary.skipped.length; if (this.reporter?.onProgress) { await this.reporter.onProgress(current, files.length); } } summary.context = { bucket: options.bucket, basePrefix: this.basePrefix, pathPrefix: options.pathPrefix, localDir: options.localDir, cdnBaseUrl: this.cdnBaseUrl, }; if (this.reporter?.onComplete) { await this.reporter.onComplete(summary); } return summary; } /** 收集并过滤要上传的文件,子类可覆盖 */ protected async collectFiles( localDir: string, include?: string[], exclude?: string[], ): Promise { const absolutePath = path.isAbsolute(localDir) ? localDir : path.join(process.cwd(), localDir); const stat = await fs.stat(absolutePath); if (stat.isFile()) { return [{ absolutePath: absolutePath, relativePath: localDir }]; } const files = await this.walkDir(absolutePath); return files.filter((f) => shouldInclude(f.relativePath, include, exclude)); } /** 上传单个文件:有 existingKeysSet 时用 Set 判断存在,否则 head;存在则跳过,否则 put;子类可覆盖 */ protected async uploadOneFile( file: LocalFile, options: UploadDirectoryOptions, existingKeysSet?: Set, ): Promise<{ status: 'success' | 'skipped' | 'failed'; key: string }> { let keyRelativePath = file.relativePath; if (options.hash) { const data = await fs.readFile(file.absolutePath); const fileHash = crypto.createHash('md5').update(data).digest('hex').slice(0, 8); keyRelativePath = relativePathWithHash(file.relativePath, fileHash); } const key = this.buildKey( this.basePrefix, options.env, options.pathPrefix, keyRelativePath, ); try { const forceUpload = options.forceUploadPatterns?.some((p) => key.includes(p)); if (existingKeysSet !== undefined) { if (!forceUpload && existingKeysSet.has(key)) { await this.reportFile(file, options.bucket, key, 'skipped'); return { status: 'skipped', key }; } } else { const head = await this.storageClient.headObject(options.bucket, key); if (head.exists) { await this.reportFile(file, options.bucket, key, 'skipped'); return { status: 'skipped', key }; } } if (this.fileProcessor) { const data = await fs.readFile(file.absolutePath); const { buffer, contentType } = await this.fileProcessor.process({ localPath: file.absolutePath, relativePath: file.relativePath, buffer: data, }); await this.storageClient.putObject({ bucket: options.bucket, key, body: buffer, contentType, }); } else { await this.storageClient.putObject({ bucket: options.bucket, key, sourceFile: file.absolutePath, contentType: getContentType(file.relativePath), }); } await this.reportFile(file, options.bucket, key, 'success'); return { status: 'success', key }; } catch (err: any) { const error = err instanceof Error ? err : new Error(String(err)); await this.reportFile(file, options.bucket, key, 'failed', error); return { status: 'failed', key }; } } protected buildKey( basePrefix: string, env?: string, pathPrefix?: string, relativePath?: string, ): string { const relative = (relativePath ?? '').replace(/\\/g, '/'); return path.join(basePrefix, pathPrefix ?? '', env ?? 'dev', relative).replace(/\\/g, '/'); } protected async walkDir(rootDir: string, currentDir = ''): Promise { const dirPath = path.join(rootDir, currentDir); const entries = await fs.readdir(dirPath, { withFileTypes: true }); const files: LocalFile[] = []; for (const entry of entries) { const rel = path.join(currentDir, entry.name); const abs = path.join(rootDir, rel); if (entry.isDirectory()) { files.push(...(await this.walkDir(rootDir, rel))); } else if (entry.isFile()) { files.push({ absolutePath: abs, relativePath: rel.replace(/\\/g, '/'), }); } } return files; } protected async reportFile( file: LocalFile, bucket: string, key: string, status: UploadFileResult['status'], error?: Error, ): Promise { if (!this.reporter?.onFileResult) return; const result: UploadFileResult = { localPath: file.absolutePath, relativePath: file.relativePath, bucket, key, status, ...(this.cdnBaseUrl && { accessUrl: buildCdnAccessUrl(this.cdnBaseUrl, key) }), ...(error && { error }), }; await this.reporter.onFileResult(result); } }