@dd-code/oss-uploader 0.1.0
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/.env +19 -0
- package/README.md +285 -0
- package/dist/cli.cjs +2 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/config/EnvConfigResolverImpl.d.ts +18 -0
- package/dist/config/EnvConfigResolverImpl.d.ts.map +1 -0
- package/dist/config/ProviderConfigResolverImpl.d.ts +15 -0
- package/dist/config/ProviderConfigResolverImpl.d.ts.map +1 -0
- package/dist/config/loadObsCredentialsFromUrl.d.ts +6 -0
- package/dist/config/loadObsCredentialsFromUrl.d.ts.map +1 -0
- package/dist/config/types.d.ts +41 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/core/DirectoryUploadFlow.d.ts +17 -0
- package/dist/core/DirectoryUploadFlow.d.ts.map +1 -0
- package/dist/core/StorageClient.d.ts +22 -0
- package/dist/core/StorageClient.d.ts.map +1 -0
- package/dist/core/StorageFactory.d.ts +8 -0
- package/dist/core/StorageFactory.d.ts.map +1 -0
- package/dist/core/UploadFlowTemplate.d.ts +49 -0
- package/dist/core/UploadFlowTemplate.d.ts.map +1 -0
- package/dist/core/fileProcessor.d.ts +35 -0
- package/dist/core/fileProcessor.d.ts.map +1 -0
- package/dist/core/filters.d.ts +10 -0
- package/dist/core/filters.d.ts.map +1 -0
- package/dist/core/reporter.d.ts +50 -0
- package/dist/core/reporter.d.ts.map +1 -0
- package/dist/core/urlHelper.d.ts +8 -0
- package/dist/core/urlHelper.d.ts.map +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/middleware/OssService.d.ts +27 -0
- package/dist/middleware/OssService.d.ts.map +1 -0
- package/dist/providers/huawei/HuaweiObsClient.d.ts +18 -0
- package/dist/providers/huawei/HuaweiObsClient.d.ts.map +1 -0
- package/package.json +43 -0
- package/rollup.config.mjs +102 -0
- package/src/cli.ts +55 -0
- package/src/config/EnvConfigResolverImpl.ts +44 -0
- package/src/config/ProviderConfigResolverImpl.ts +34 -0
- package/src/config/loadObsCredentialsFromUrl.ts +46 -0
- package/src/config/types.ts +47 -0
- package/src/core/DirectoryUploadFlow.ts +96 -0
- package/src/core/StorageClient.ts +25 -0
- package/src/core/StorageFactory.ts +17 -0
- package/src/core/UploadFlowTemplate.ts +119 -0
- package/src/core/fileProcessor.ts +40 -0
- package/src/core/filters.ts +31 -0
- package/src/core/reporter.ts +88 -0
- package/src/core/urlHelper.ts +11 -0
- package/src/index.ts +63 -0
- package/src/middleware/OssService.ts +54 -0
- package/src/providers/huawei/HuaweiObsClient.ts +72 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置与类型定义:华为 OBS、Provider、环境解析器等。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** 华为 OBS 连接与鉴权配置,含 basePrefix(所有 key 的基础前缀,仅在 config 中配置) */
|
|
6
|
+
export interface HuaweiObsConfig {
|
|
7
|
+
endpoint: string;
|
|
8
|
+
accessKey: string;
|
|
9
|
+
secretKey: string;
|
|
10
|
+
region?: string;
|
|
11
|
+
basePrefix: string;
|
|
12
|
+
/** CDN 加速根地址,配置后上传完成可拼出完整访问 URL(如 https://cdn.example.com) */
|
|
13
|
+
cdnBaseUrl?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 当前支持的存储 Provider 类型 */
|
|
17
|
+
export type ProviderType = 'huawei';
|
|
18
|
+
|
|
19
|
+
/** 创建 StorageClient 时传入的配置,按 type 选择具体实现 */
|
|
20
|
+
export interface ProviderConfig {
|
|
21
|
+
type: ProviderType;
|
|
22
|
+
options: HuaweiObsConfig;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 根据环境标识解析华为 OBS 配置的接口,便于替换为配置文件等实现 */
|
|
26
|
+
export interface EnvConfigResolver {
|
|
27
|
+
resolveHuaweiConfig(env?: string): HuaweiObsConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 解析后的 Provider 配置(含上传流程所需的 basePrefix、cdnBaseUrl)。
|
|
32
|
+
* 由 ProviderConfigResolver 统一返回,OssService 只依赖此结构,不关心具体云厂商。
|
|
33
|
+
*/
|
|
34
|
+
export interface ResolvedProviderConfig {
|
|
35
|
+
providerConfig: ProviderConfig;
|
|
36
|
+
basePrefix: string;
|
|
37
|
+
cdnBaseUrl?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 统一 Provider 配置解析器:返回当前要用的 Provider 及其配置。
|
|
42
|
+
* 切换云厂商时只需改此接口的【一个实现文件】即可(如改 env 或增加 case)。
|
|
43
|
+
*/
|
|
44
|
+
export interface ProviderConfigResolver {
|
|
45
|
+
resolve(): ResolvedProviderConfig;
|
|
46
|
+
}
|
|
47
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { UploadFlowTemplate, UploadContext, LocalFile } from './UploadFlowTemplate';
|
|
3
|
+
import { UploadSummary } from './reporter';
|
|
4
|
+
import { buildCdnAccessUrl } from './urlHelper';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 目录上传流程的具体实现:遍历本地目录,按 key 规则上传,云端同名则跳过。
|
|
8
|
+
* 规则:先 headObject,exists 则 skipped,否则 putObject。
|
|
9
|
+
*/
|
|
10
|
+
export class DirectoryUploadFlow extends UploadFlowTemplate {
|
|
11
|
+
constructor(context: UploadContext) {
|
|
12
|
+
super(context);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 递归收集 localDir 下所有文件 */
|
|
16
|
+
protected async collectFiles(): Promise<LocalFile[]> {
|
|
17
|
+
return this.walkDir(this.context.localDir);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 逐个文件上传:先 head 判断是否存在,存在则跳过,否则读取本地文件并 putObject。
|
|
22
|
+
* 每个文件的结果通过 reportFileResult 交给 Reporter。
|
|
23
|
+
*/
|
|
24
|
+
protected async uploadFiles(files: LocalFile[]): Promise<UploadSummary> {
|
|
25
|
+
const { bucket, storageClient, cdnBaseUrl } = this.context;
|
|
26
|
+
|
|
27
|
+
const summary: UploadSummary = {
|
|
28
|
+
total: files.length,
|
|
29
|
+
success: 0,
|
|
30
|
+
failed: 0,
|
|
31
|
+
skipped: 0,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
const key = this.buildKey(file.relativePath);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const head = await storageClient.headObject(bucket, key);
|
|
39
|
+
|
|
40
|
+
// 云端已有同名对象则跳过,不比较内容
|
|
41
|
+
if (head.exists) {
|
|
42
|
+
summary.skipped += 1;
|
|
43
|
+
await this.reportFileResult({
|
|
44
|
+
localPath: file.absolutePath,
|
|
45
|
+
relativePath: file.relativePath,
|
|
46
|
+
bucket,
|
|
47
|
+
key,
|
|
48
|
+
status: 'skipped',
|
|
49
|
+
...(cdnBaseUrl && { accessUrl: buildCdnAccessUrl(cdnBaseUrl, key) }),
|
|
50
|
+
});
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const data = await fs.readFile(file.absolutePath);
|
|
55
|
+
|
|
56
|
+
const { buffer, contentType } = this.context.fileProcessor
|
|
57
|
+
? await this.context.fileProcessor.process({
|
|
58
|
+
localPath: file.absolutePath,
|
|
59
|
+
relativePath: file.relativePath,
|
|
60
|
+
buffer: data,
|
|
61
|
+
})
|
|
62
|
+
: { buffer: data, contentType: undefined as string | undefined };
|
|
63
|
+
|
|
64
|
+
await storageClient.putObject({
|
|
65
|
+
bucket,
|
|
66
|
+
key,
|
|
67
|
+
body: buffer,
|
|
68
|
+
contentType,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
summary.success += 1;
|
|
72
|
+
await this.reportFileResult({
|
|
73
|
+
localPath: file.absolutePath,
|
|
74
|
+
relativePath: file.relativePath,
|
|
75
|
+
bucket,
|
|
76
|
+
key,
|
|
77
|
+
status: 'success',
|
|
78
|
+
...(cdnBaseUrl && { accessUrl: buildCdnAccessUrl(cdnBaseUrl, key) }),
|
|
79
|
+
});
|
|
80
|
+
} catch (err: any) {
|
|
81
|
+
summary.failed += 1;
|
|
82
|
+
await this.reportFileResult({
|
|
83
|
+
localPath: file.absolutePath,
|
|
84
|
+
relativePath: file.relativePath,
|
|
85
|
+
bucket,
|
|
86
|
+
key,
|
|
87
|
+
status: 'failed',
|
|
88
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return summary;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 存储客户端统一接口(中间件层与各云厂商 Adapter 的契约)。
|
|
3
|
+
* 实现类负责将 putObject/headObject 翻译为具体云厂商的 API 调用。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 上传单个对象时的参数 */
|
|
7
|
+
export interface PutObjectOptions {
|
|
8
|
+
bucket: string;
|
|
9
|
+
key: string;
|
|
10
|
+
body: Buffer | NodeJS.ReadableStream;
|
|
11
|
+
contentType?: string;
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** headObject 的返回结果,仅用于判断云端是否已有同名对象(决定是否跳过上传) */
|
|
16
|
+
export interface HeadObjectResult {
|
|
17
|
+
exists: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 统一存储客户端接口,各 Provider(华为 OBS、阿里 OSS、S3 等)需实现此接口 */
|
|
21
|
+
export interface StorageClient {
|
|
22
|
+
putObject(options: PutObjectOptions): Promise<void>;
|
|
23
|
+
headObject(bucket: string, key: string): Promise<HeadObjectResult>;
|
|
24
|
+
}
|
|
25
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { StorageClient } from './StorageClient';
|
|
2
|
+
import { ProviderConfig } from '../config/types';
|
|
3
|
+
import { HuaweiObsClient } from '../providers/huawei/HuaweiObsClient';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 根据 Provider 类型创建对应的 StorageClient(简单工厂)。
|
|
7
|
+
* 后续扩展阿里 OSS、AWS S3 时在此增加 case 即可。
|
|
8
|
+
*/
|
|
9
|
+
export function createStorageClient(config: ProviderConfig): StorageClient {
|
|
10
|
+
switch (config.type) {
|
|
11
|
+
case 'huawei':
|
|
12
|
+
return new HuaweiObsClient(config.options);
|
|
13
|
+
default:
|
|
14
|
+
throw new Error(`Unsupported provider type: ${config.type}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { Reporter, UploadSummary, UploadFileResult } from './reporter';
|
|
4
|
+
import { StorageClient } from './StorageClient';
|
|
5
|
+
import { shouldInclude } from './filters';
|
|
6
|
+
import type { FileProcessor } from './fileProcessor';
|
|
7
|
+
|
|
8
|
+
/** 上传流程的上下文:环境、bucket、前缀、本地目录、存储客户端、Reporter、CDN 根地址、上传前处理器等 */
|
|
9
|
+
export interface UploadContext {
|
|
10
|
+
env?: string;
|
|
11
|
+
bucket: string;
|
|
12
|
+
basePrefix: string;
|
|
13
|
+
pathPrefix?: string;
|
|
14
|
+
localDir: string;
|
|
15
|
+
include?: string[];
|
|
16
|
+
exclude?: string[];
|
|
17
|
+
storageClient: StorageClient;
|
|
18
|
+
reporter?: Reporter;
|
|
19
|
+
/** CDN 加速根地址,配置后可为每个文件生成 accessUrl */
|
|
20
|
+
cdnBaseUrl?: string;
|
|
21
|
+
/** 上传前对文件内容的处理器(如图片压缩),未配置则直接上传原内容 */
|
|
22
|
+
fileProcessor?: FileProcessor;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 本地文件信息:绝对路径与相对路径 */
|
|
26
|
+
export interface LocalFile {
|
|
27
|
+
absolutePath: string;
|
|
28
|
+
relativePath: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 上传流程模板(Template Method)。
|
|
33
|
+
* 定义固定步骤:onStart -> collectFiles -> filterFiles -> uploadFiles -> onComplete,
|
|
34
|
+
* 子类实现 collectFiles 与 uploadFiles,其余步骤可复用或重写。
|
|
35
|
+
*/
|
|
36
|
+
export abstract class UploadFlowTemplate {
|
|
37
|
+
protected constructor(protected readonly context: UploadContext) {}
|
|
38
|
+
|
|
39
|
+
/** 执行完整上传流程 */
|
|
40
|
+
async execute(): Promise<UploadSummary> {
|
|
41
|
+
const { reporter } = this.context;
|
|
42
|
+
|
|
43
|
+
if (reporter?.onStart) {
|
|
44
|
+
await reporter.onStart({
|
|
45
|
+
env: this.context.env,
|
|
46
|
+
bucket: this.context.bucket,
|
|
47
|
+
basePrefix: this.context.basePrefix,
|
|
48
|
+
pathPrefix: this.context.pathPrefix,
|
|
49
|
+
localDir: this.context.localDir,
|
|
50
|
+
cdnBaseUrl: this.context.cdnBaseUrl,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const files = await this.collectFiles();
|
|
55
|
+
const filtered = this.filterFiles(files);
|
|
56
|
+
const summary = await this.uploadFiles(filtered);
|
|
57
|
+
|
|
58
|
+
if (reporter?.onComplete) {
|
|
59
|
+
await reporter.onComplete(summary);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return summary;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
protected abstract collectFiles(): Promise<LocalFile[]>;
|
|
66
|
+
protected abstract uploadFiles(files: LocalFile[]): Promise<UploadSummary>;
|
|
67
|
+
|
|
68
|
+
/** 使用 include/exclude 过滤文件列表 */
|
|
69
|
+
protected filterFiles(files: LocalFile[]): LocalFile[] {
|
|
70
|
+
const { include, exclude } = this.context;
|
|
71
|
+
return files.filter((f) => shouldInclude(f.relativePath, include, exclude));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 构造远端 key:basePrefix / env / pathPrefix / relativePath。
|
|
76
|
+
* 保持目录结构,且 base 前缀来自 config,不对外暴露。
|
|
77
|
+
*/
|
|
78
|
+
protected buildKey(relativePath: string): string {
|
|
79
|
+
const parts: string[] = [];
|
|
80
|
+
if (this.context.basePrefix) parts.push(this.context.basePrefix);
|
|
81
|
+
if (this.context.env) parts.push(this.context.env);
|
|
82
|
+
if (this.context.pathPrefix) parts.push(this.context.pathPrefix);
|
|
83
|
+
|
|
84
|
+
parts.push(relativePath.replace(/\\/g, '/'));
|
|
85
|
+
return parts.join('/');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 递归遍历目录,收集所有文件的绝对路径与相对路径 */
|
|
89
|
+
protected async walkDir(rootDir: string, currentDir = ''): Promise<LocalFile[]> {
|
|
90
|
+
const dirPath = path.join(rootDir, currentDir);
|
|
91
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
92
|
+
const files: LocalFile[] = [];
|
|
93
|
+
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
const rel = path.join(currentDir, entry.name);
|
|
96
|
+
const abs = path.join(rootDir, rel);
|
|
97
|
+
|
|
98
|
+
if (entry.isDirectory()) {
|
|
99
|
+
files.push(...(await this.walkDir(rootDir, rel)));
|
|
100
|
+
} else if (entry.isFile()) {
|
|
101
|
+
files.push({
|
|
102
|
+
absolutePath: abs,
|
|
103
|
+
relativePath: rel.replace(/\\/g, '/'),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return files;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 将单个文件的上传结果交给 Reporter */
|
|
112
|
+
protected async reportFileResult(result: UploadFileResult): Promise<void> {
|
|
113
|
+
const { reporter } = this.context;
|
|
114
|
+
if (reporter?.onFileResult) {
|
|
115
|
+
await reporter.onFileResult(result);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上传前文件处理抽象。
|
|
3
|
+
* 可在 putObject 前对内容做转换,例如图片压缩、水印、格式转换等,便于后期扩展。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 传入处理器的文件信息与内容 */
|
|
7
|
+
export interface ProcessFileInput {
|
|
8
|
+
/** 本地绝对路径 */
|
|
9
|
+
localPath: string;
|
|
10
|
+
/** 相对 localDir 的路径 */
|
|
11
|
+
relativePath: string;
|
|
12
|
+
/** 文件内容,处理器可修改后返回 */
|
|
13
|
+
buffer: Buffer;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 处理器返回结果,将用于 putObject */
|
|
17
|
+
export interface ProcessFileResult {
|
|
18
|
+
/** 处理后的内容(如压缩后的图片) */
|
|
19
|
+
buffer: Buffer;
|
|
20
|
+
/** 可选:覆盖上传时的 Content-Type(如压缩后统一为 image/jpeg) */
|
|
21
|
+
contentType?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 上传前文件处理器接口。
|
|
26
|
+
* 实现后可在上传前对文件做压缩、转换等,未配置则直接上传原内容。
|
|
27
|
+
*/
|
|
28
|
+
export interface FileProcessor {
|
|
29
|
+
process(input: ProcessFileInput): Promise<ProcessFileResult>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 默认实现:不做任何处理,直接返回原 buffer。
|
|
34
|
+
* 后续可替换为图片压缩等实现。
|
|
35
|
+
*/
|
|
36
|
+
export class NoOpFileProcessor implements FileProcessor {
|
|
37
|
+
async process(input: ProcessFileInput): Promise<ProcessFileResult> {
|
|
38
|
+
return { buffer: input.buffer };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import micromatch from 'micromatch';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 根据 include/exclude 规则判断相对路径是否应参与上传。
|
|
5
|
+
* 使用 micromatch 支持 glob(如 **\/*.js)。
|
|
6
|
+
* @param relativePath 相对路径
|
|
7
|
+
* @param include 包含模式,不传则默认全部包含
|
|
8
|
+
* @param exclude 排除模式
|
|
9
|
+
* @returns 是否应包含该文件
|
|
10
|
+
*/
|
|
11
|
+
export function shouldInclude(
|
|
12
|
+
relativePath: string,
|
|
13
|
+
include?: string[],
|
|
14
|
+
exclude?: string[],
|
|
15
|
+
): boolean {
|
|
16
|
+
let included = true;
|
|
17
|
+
|
|
18
|
+
if (include && include.length > 0) {
|
|
19
|
+
included = micromatch.isMatch(relativePath, include);
|
|
20
|
+
}
|
|
21
|
+
if (!included) return false;
|
|
22
|
+
|
|
23
|
+
if (exclude && exclude.length > 0) {
|
|
24
|
+
if (micromatch.isMatch(relativePath, exclude)) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上传结果上报抽象(Reporter)。
|
|
3
|
+
* 默认实现为控制台输出,可扩展为企业微信、钉钉等通知。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type UploadStatus = 'success' | 'failed' | 'skipped';
|
|
7
|
+
|
|
8
|
+
/** 单个文件的上传结果,供 Reporter 使用 */
|
|
9
|
+
export interface UploadFileResult {
|
|
10
|
+
localPath: string;
|
|
11
|
+
relativePath: string;
|
|
12
|
+
bucket: string;
|
|
13
|
+
key: string;
|
|
14
|
+
status: UploadStatus;
|
|
15
|
+
error?: Error;
|
|
16
|
+
/** 配置了 CDN 时的完整访问地址,由 buildCdnAccessUrl 生成 */
|
|
17
|
+
accessUrl?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 整次上传的汇总统计 */
|
|
21
|
+
export interface UploadSummary {
|
|
22
|
+
total: number;
|
|
23
|
+
success: number;
|
|
24
|
+
failed: number;
|
|
25
|
+
skipped: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Reporter 接口:上传开始、每个文件结果、全部完成时回调,便于接入不同通知方式 */
|
|
29
|
+
export interface Reporter {
|
|
30
|
+
onStart?(context: {
|
|
31
|
+
env?: string;
|
|
32
|
+
bucket: string;
|
|
33
|
+
basePrefix: string;
|
|
34
|
+
pathPrefix?: string;
|
|
35
|
+
localDir: string;
|
|
36
|
+
cdnBaseUrl?: string;
|
|
37
|
+
}): void | Promise<void>;
|
|
38
|
+
|
|
39
|
+
onFileResult?(result: UploadFileResult): void | Promise<void>;
|
|
40
|
+
|
|
41
|
+
onComplete?(summary: UploadSummary): void | Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 默认实现:将上传进度与结果输出到控制台 */
|
|
45
|
+
export class ConsoleReporter implements Reporter {
|
|
46
|
+
onStart(context: {
|
|
47
|
+
env?: string;
|
|
48
|
+
bucket: string;
|
|
49
|
+
basePrefix: string;
|
|
50
|
+
pathPrefix?: string;
|
|
51
|
+
localDir: string;
|
|
52
|
+
cdnBaseUrl?: string;
|
|
53
|
+
}): void {
|
|
54
|
+
const { env, bucket, basePrefix, pathPrefix, localDir, cdnBaseUrl } = context;
|
|
55
|
+
// console.log('开始上传目录:');
|
|
56
|
+
// console.log(` 环境: ${env ?? '未指定'}`);
|
|
57
|
+
// console.log(` Bucket: ${bucket}`);
|
|
58
|
+
// console.log(` Base 前缀: ${basePrefix || '(空)'}`);
|
|
59
|
+
// console.log(` 业务前缀(pathPrefix): ${pathPrefix || '(空)'}`);
|
|
60
|
+
// console.log(` 本地目录: ${localDir}`);
|
|
61
|
+
if (cdnBaseUrl) {
|
|
62
|
+
console.log(` CDN 根地址: ${cdnBaseUrl}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
onFileResult(result: UploadFileResult): void {
|
|
67
|
+
const { status, localPath, key, error, accessUrl } = result;
|
|
68
|
+
if (status === 'success') {
|
|
69
|
+
console.log(`[SUCCESS] ${localPath} -> ${key}`);
|
|
70
|
+
if (accessUrl) console.log(` 访问地址: ${accessUrl}`);
|
|
71
|
+
} else if (status === 'skipped') {
|
|
72
|
+
console.log(`[SKIPPED] ${localPath} -> ${key} (云端同名已存在)`);
|
|
73
|
+
if (accessUrl) console.log(` 访问地址: ${accessUrl}`);
|
|
74
|
+
} else {
|
|
75
|
+
console.error(`[FAILED] ${localPath} -> ${key}`);
|
|
76
|
+
if (error) console.error(` Error: ${error.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onComplete(summary: UploadSummary): void {
|
|
81
|
+
console.log('上传完成:');
|
|
82
|
+
console.log(` 总数: ${summary.total}`);
|
|
83
|
+
console.log(` 成功: ${summary.success}`);
|
|
84
|
+
console.log(` 失败: ${summary.failed}`);
|
|
85
|
+
console.log(` 跳过: ${summary.skipped}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 根据 CDN 加速域名与对象 key 拼接完整的访问地址。
|
|
3
|
+
* @param cdnBaseUrl CDN 根地址(如 https://cdn.example.com),可带或不带末尾斜杠
|
|
4
|
+
* @param key 对象在桶内的 key(如 basePrefix/path/file.js)
|
|
5
|
+
* @returns 完整访问 URL,如 https://cdn.example.com/basePrefix/path/file.js
|
|
6
|
+
*/
|
|
7
|
+
export function buildCdnAccessUrl(cdnBaseUrl: string, key: string): string {
|
|
8
|
+
const base = cdnBaseUrl.replace(/\/+$/, '');
|
|
9
|
+
const pathPart = key.replace(/^\/+/, '');
|
|
10
|
+
return pathPart ? `${base}/${pathPart}` : base;
|
|
11
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包入口:显式导出所有对外 API 与类型,便于调用方按需引用。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { buildCdnAccessUrl } from './core/urlHelper';
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
type StorageClient,
|
|
9
|
+
type PutObjectOptions,
|
|
10
|
+
type HeadObjectResult,
|
|
11
|
+
} from './core/StorageClient';
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
type Reporter,
|
|
15
|
+
type UploadFileResult,
|
|
16
|
+
type UploadSummary,
|
|
17
|
+
type UploadStatus,
|
|
18
|
+
ConsoleReporter,
|
|
19
|
+
} from './core/reporter';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
type FileProcessor,
|
|
23
|
+
type ProcessFileInput,
|
|
24
|
+
type ProcessFileResult,
|
|
25
|
+
NoOpFileProcessor,
|
|
26
|
+
} from './core/fileProcessor';
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
UploadFlowTemplate,
|
|
30
|
+
type UploadContext,
|
|
31
|
+
type LocalFile,
|
|
32
|
+
} from './core/UploadFlowTemplate';
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
DirectoryUploadFlow,
|
|
36
|
+
} from './core/DirectoryUploadFlow';
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
createStorageClient,
|
|
40
|
+
} from './core/StorageFactory';
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
OssService,
|
|
44
|
+
type UploadDirectoryInput,
|
|
45
|
+
} from './middleware/OssService';
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
type HuaweiObsConfig,
|
|
49
|
+
type ProviderConfig,
|
|
50
|
+
type ProviderType,
|
|
51
|
+
type EnvConfigResolver,
|
|
52
|
+
type ResolvedProviderConfig,
|
|
53
|
+
type ProviderConfigResolver,
|
|
54
|
+
} from './config/types';
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
EnvConfigResolverImpl,
|
|
58
|
+
} from './config/EnvConfigResolverImpl';
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
ProviderConfigResolverImpl,
|
|
62
|
+
} from './config/ProviderConfigResolverImpl';
|
|
63
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Reporter, UploadSummary } from '../core/reporter';
|
|
2
|
+
import { ProviderConfigResolver } from '../config/types';
|
|
3
|
+
import { createStorageClient } from '../core/StorageFactory';
|
|
4
|
+
import { DirectoryUploadFlow } from '../core/DirectoryUploadFlow';
|
|
5
|
+
import type { FileProcessor } from '../core/fileProcessor';
|
|
6
|
+
|
|
7
|
+
/** 上传目录的入参:环境、bucket、本地目录、路径前缀、过滤规则、Reporter、上传前文件处理器 */
|
|
8
|
+
export interface UploadDirectoryInput {
|
|
9
|
+
env?: string;
|
|
10
|
+
bucket: string;
|
|
11
|
+
localDir: string;
|
|
12
|
+
pathPrefix?: string;
|
|
13
|
+
include?: string[];
|
|
14
|
+
exclude?: string[];
|
|
15
|
+
reporter?: Reporter;
|
|
16
|
+
/** 上传前对文件内容的处理器(如图片压缩),未配置则直接上传原内容 */
|
|
17
|
+
fileProcessor?: FileProcessor;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 中间件:对主流程暴露统一 API(Facade)。
|
|
22
|
+
* 内部只依赖 ProviderConfigResolver 得到当前 Provider 配置,再创建 StorageClient 与流程并执行。
|
|
23
|
+
* 切换云厂商时只需改 ProviderConfigResolver 的【一个实现文件】。
|
|
24
|
+
*/
|
|
25
|
+
export class OssService {
|
|
26
|
+
constructor(private readonly providerConfigResolver: ProviderConfigResolver) {}
|
|
27
|
+
|
|
28
|
+
/** 上传指定本地目录到 OSS,key 规则为 basePrefix/env/pathPrefix/相对路径,同名则跳过 */
|
|
29
|
+
async uploadDirectory(input: UploadDirectoryInput): Promise<UploadSummary> {
|
|
30
|
+
const { env, bucket= 'hr-uat', localDir, pathPrefix, include, exclude, reporter, fileProcessor } = input;
|
|
31
|
+
|
|
32
|
+
const resolved = this.providerConfigResolver.resolve();
|
|
33
|
+
const { providerConfig, basePrefix, cdnBaseUrl } = resolved;
|
|
34
|
+
|
|
35
|
+
const client = createStorageClient(providerConfig);
|
|
36
|
+
|
|
37
|
+
const flow = new DirectoryUploadFlow({
|
|
38
|
+
env,
|
|
39
|
+
bucket,
|
|
40
|
+
localDir,
|
|
41
|
+
pathPrefix,
|
|
42
|
+
include,
|
|
43
|
+
exclude,
|
|
44
|
+
basePrefix,
|
|
45
|
+
storageClient: client,
|
|
46
|
+
reporter,
|
|
47
|
+
cdnBaseUrl,
|
|
48
|
+
fileProcessor,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return flow.execute();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { StorageClient, PutObjectOptions, HeadObjectResult } from '../../core/StorageClient';
|
|
2
|
+
import { HuaweiObsConfig } from '../../config/types';
|
|
3
|
+
|
|
4
|
+
// 华为 OBS Node.js SDK(CommonJS)
|
|
5
|
+
const ObsClient = require('esdk-obs-nodejs');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 华为 OBS 的 StorageClient 实现(Adapter)。
|
|
9
|
+
* 通过 SDK 的 getObjectMetadata 查元数据判断是否存在,putObject 上传对象。
|
|
10
|
+
*/
|
|
11
|
+
export class HuaweiObsClient implements StorageClient {
|
|
12
|
+
private readonly client: InstanceType<typeof ObsClient>;
|
|
13
|
+
|
|
14
|
+
constructor(private readonly config: HuaweiObsConfig) {
|
|
15
|
+
this.client = new ObsClient({
|
|
16
|
+
access_key_id: config.accessKey,
|
|
17
|
+
secret_access_key: config.secretKey,
|
|
18
|
+
server: config.endpoint,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 调用 OBS getObjectMetadata 查元数据,判断对象是否存在(同路径即跳过上传)。
|
|
24
|
+
*/
|
|
25
|
+
async headObject(bucket: string, key: string): Promise<HeadObjectResult> {
|
|
26
|
+
try {
|
|
27
|
+
const result = await this.client.getObjectMetadata({
|
|
28
|
+
Bucket: bucket,
|
|
29
|
+
Key: key,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (result.CommonMsg.Status <= 300) {
|
|
33
|
+
return { exists: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 对象不存在
|
|
37
|
+
if (result.CommonMsg.Status === 404 || result.CommonMsg.Code === 'NoSuchKey' || result.CommonMsg.Code === 'NotFound') {
|
|
38
|
+
return { exists: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw new Error(`OBS getObjectMetadata 失败: ${result.CommonMsg.Status} ${result.CommonMsg.Code} ${result.CommonMsg.Message}`);
|
|
42
|
+
} catch (err: unknown) {
|
|
43
|
+
const msg = err && typeof (err as any).message === 'string' ? (err as Error).message : String(err);
|
|
44
|
+
const code = (err as any)?.Code ?? (err as any)?.code;
|
|
45
|
+
if (code === 'NoSuchKey' || code === 'NotFound' || msg.includes('404') || msg.includes('NoSuchKey')) {
|
|
46
|
+
return { exists: false };
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 调用 OBS putObject 上传对象 */
|
|
53
|
+
async putObject(options: PutObjectOptions): Promise<void> {
|
|
54
|
+
const { bucket, key, body, contentType } = options;
|
|
55
|
+
// console.log('putObject', { bucket, key, endpoint: this.config.endpoint, accessKey: this.config.accessKey, secretKey: this.config.secretKey })
|
|
56
|
+
const result = await this.client.putObject({
|
|
57
|
+
Bucket: bucket,
|
|
58
|
+
Key: key,
|
|
59
|
+
Body: body,
|
|
60
|
+
// ContentType: contentType,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (result.CommonMsg.Status > 300) {
|
|
64
|
+
const { Status, Code, Message, RequestId } = result.CommonMsg;
|
|
65
|
+
const detail = [Status, Code, Message, RequestId].filter(Boolean).join(' ');
|
|
66
|
+
throw new Error(
|
|
67
|
+
`OBS putObject 失败 (${detail || '无详情'}). ` +
|
|
68
|
+
'请检查: 1) 桶名是否正确且已创建 2) OBS_ENDPOINT 是否与该桶所在区域一致,如 https://obs.xx-xx-1.myhuaweicloud.com'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Node",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"declarationMap": true,
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"outDir": "./dist",
|
|
13
|
+
"rootDir": "./src"
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"]
|
|
16
|
+
}
|