返回 AiToEarn
s3.service.ts
根目录 / project / aitoearn-backend / libs / aws-s3 / src / s3.service.ts
1 import {
2 CompleteMultipartUploadCommand,
3 CopyObjectCommand,
4 CreateMultipartUploadCommand,
5 DeleteObjectCommand,
6 GetObjectCommand,
7 HeadObjectCommand,
8 PutObjectCommand,
9 PutObjectCommandInput,
10 S3Client,
11 UploadPartCommand,
12 UploadPartCommandInput,
13 UploadPartCommandOutput,
14 } from '@aws-sdk/client-s3'
15 import { Upload } from '@aws-sdk/lib-storage'
16 import { createPresignedPost } from '@aws-sdk/s3-presigned-post'
17 import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
18 import { Inject, Injectable, Logger } from '@nestjs/common'
19 import { AppException, ResponseCode } from '@yikart/common'
20 import { S3Config } from './s3.config'
21 import { S3_SIGNING_CLIENT } from './s3.constants'
22
23 @Injectable()
24 export class S3Service {
25 private readonly logger = new Logger(S3Service.name)
26
27 constructor(
28 private readonly config: S3Config,
29 private readonly client: S3Client,
30 @Inject(S3_SIGNING_CLIENT) private readonly signingClient: S3Client,
31 ) {}
32
33 async putObject(
34 objectPath: string,
35 file: PutObjectCommandInput['Body'],
36 contentType?: string,
37 ) {
38 const contentDisposition = contentType?.startsWith('video/') ? 'inline' : undefined
39
40 const upload = new Upload({
41 client: this.client,
42 params: {
43 Bucket: this.config.bucketName,
44 Key: objectPath,
45 Body: file,
46 ContentType: contentType,
47 ContentDisposition: contentDisposition,
48 },
49 })
50 await upload.done()
51 return { path: objectPath }
52 }
53
54 async headObject(objectPath: string) {
55 const command = new HeadObjectCommand({
56 Bucket: this.config.bucketName,
57 Key: objectPath,
58 })
59 return await this.client.send(command)
60 }
61
62 async putObjectFromUrl(
63 url: string,
64 objectPath: string,
65 ) {
66 try {
67 await this.headObject(objectPath)
68 return { path: objectPath, exists: true }
69 }
70 catch {
71 const response = await fetch(url)
72 if (response.body === null) {
73 throw new AppException(ResponseCode.S3DownloadFileFailed)
74 }
75 const contentType = response.headers.get('content-type') || undefined
76 return this.putObject(objectPath, response.body, contentType)
77 }
78 }
79
80 // 生成预签名上传 URL
81 async getUploadSignPost(objectPath: string, contentType?: string) {
82 const result = await createPresignedPost(this.signingClient, {
83 Bucket: this.config.bucketName,
84 Key: objectPath,
85 Expires: this.config.signExpires,
86 Conditions: contentType ? [['eq', '$Content-Type', contentType]] : undefined,
87 Fields: contentType ? { 'Content-Type': contentType } : undefined,
88 })
89 return result
90 }
91
92 async getUploadSignUrl(objectPath: string, contentType?: string, contentLength?: number) {
93 return getSignedUrl(this.signingClient, new PutObjectCommand({
94 Bucket: this.config.bucketName,
95 Key: objectPath,
96 ContentType: contentType,
97 ContentLength: contentLength,
98 }), { expiresIn: this.config.signExpires })
99 }
100
101 /**
102 * 开始分片上传
103 * @param {string} objectPath - 文件键
104 * @param {string} contentType - 文件MIME类型
105 * @returns {Promise<string>} - 返回上传ID
106 */
107 async initiateMultipartUpload(
108 objectPath: string,
109 contentType?: string,
110 ): Promise<string> {
111 const contentDisposition = contentType?.startsWith('video/') ? 'inline' : undefined
112
113 const command = new CreateMultipartUploadCommand({
114 Bucket: this.config.bucketName,
115 Key: objectPath,
116 ContentType: contentType,
117 ContentDisposition: contentDisposition,
118 })
119
120 const response = await this.client.send(command)
121 return response.UploadId!
122 }
123
124 /**
125 * 上传单个分片
126 * @param {string} objectPath - 文件键
127 * @param {string} uploadId - 上传ID
128 * @param {number} partNumber - 分片编号
129 * @param {Buffer} partData - 分片数据
130 * @returns {Promise<string>} - 返回ETag
131 */
132 async uploadPart(
133 objectPath: string,
134 uploadId: string,
135 partNumber: number,
136 partData: UploadPartCommandInput['Body'],
137 ): Promise<UploadPartCommandOutput> {
138 const command = new UploadPartCommand({
139 Bucket: this.config.bucketName,
140 Key: objectPath,
141 UploadId: uploadId,
142 PartNumber: partNumber,
143 Body: partData,
144 })
145
146 return await this.client.send(command)
147 }
148
149 /**
150 * 完成分片上传
151 * @param {string} objectPath - 文件键
152 * @param {string} uploadId - 上传ID
153 * @param {Array<{ PartNumber: number; ETag: string }>} parts - 分片列表
154 */
155 async completeMultipartUpload(
156 objectPath: string,
157 uploadId: string,
158 parts: { PartNumber: number, ETag: string }[],
159 ): Promise<void> {
160 const command = new CompleteMultipartUploadCommand({
161 Bucket: this.config.bucketName,
162 Key: objectPath,
163 UploadId: uploadId,
164 MultipartUpload: { Parts: parts },
165 })
166
167 await this.client.send(command)
168 }
169
170 // 删除文件
171 async deleteObject(objectPath: string) {
172 const command = new DeleteObjectCommand({
173 Bucket: this.config.bucketName,
174 Key: objectPath,
175 })
176 await this.client.send(command)
177 }
178
179 /**
180 * 复制对象(用于更新元数据/Content-Type)
181 * S3 不支持直接更新元数据,需要通过复制对象到自身实现
182 */
183 async copyObject(
184 objectPath: string,
185 options: {
186 contentType?: string
187 contentDisposition?: string
188 metadata?: Record<string, string>
189 },
190 ) {
191 const command = new CopyObjectCommand({
192 Bucket: this.config.bucketName,
193 Key: objectPath,
194 CopySource: `${this.config.bucketName}/${objectPath}`,
195 ContentType: options.contentType,
196 ContentDisposition: options.contentDisposition,
197 Metadata: options.metadata,
198 MetadataDirective: 'REPLACE',
199 })
200 await this.client.send(command)
201 }
202
203 /**
204 * 生成预签名 GET URL(用于让外部服务读取 R2 对象,绕过 CDN 限制)
205 */
206 async getReadSignUrl(objectPath: string, expiresIn = 3600) {
207 return getSignedUrl(this.signingClient, new GetObjectCommand({
208 Bucket: this.config.bucketName,
209 Key: objectPath,
210 }), { expiresIn })
211 }
212
213 /**
214 * 将 CDN URL 或 path 转为预签名 GET URL
215 */
216 async toPresignedUrl(urlOrPath: string, expiresIn = 3600): Promise<string> {
217 const objectPath = this.extractObjectPath(urlOrPath)
218 return this.getReadSignUrl(objectPath, expiresIn)
219 }
220
221 /**
222 * 下载 S3 对象
223 */
224 async getObject(key: string) {
225 const command = new GetObjectCommand({
226 Bucket: this.config.bucketName,
227 Key: key,
228 })
229 return await this.client.send(command)
230 }
231
232 private extractObjectPath(url: string): string {
233 if (!url.startsWith('http')) {
234 return url.replace(/^\/+/, '')
235 }
236 let path = url
237 if (this.config.cdnEndpoint && url.startsWith(this.config.cdnEndpoint)) {
238 path = url.replace(this.config.cdnEndpoint, '')
239 }
240 else if (url.startsWith(this.config.endpoint)) {
241 path = url.replace(this.config.endpoint, '')
242 }
243 return decodeURIComponent(path.replace(/^\/+/, ''))
244 }
245 }
246
246 lines TYPESCRIPT