create-pardx-scaffold 0.1.2 → 0.1.3
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 +1 -1
- package/template/apps/api/libs/infra/common/ts-rest/response.helper.ts +9 -1
- package/template/apps/api/libs/infra/shared-services/uploader/uploader.service.ts +4 -4
- package/template/apps/api/src/app.module.ts +4 -0
- package/template/apps/api/src/modules/uploader/uploader.controller.ts +305 -0
- package/template/apps/api/src/modules/uploader/uploader.module.ts +17 -0
package/package.json
CHANGED
|
@@ -42,6 +42,14 @@ export type TsRestResponse<T, S extends number = 200> = {
|
|
|
42
42
|
body: SuccessBody<T> | ErrorBody;
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* ts-rest 成功响应类型(仅包含成功体)
|
|
47
|
+
*/
|
|
48
|
+
export type TsRestSuccessResponse<T, S extends number = 200> = {
|
|
49
|
+
status: S;
|
|
50
|
+
body: SuccessBody<T>;
|
|
51
|
+
};
|
|
52
|
+
|
|
45
53
|
/**
|
|
46
54
|
* 创建成功响应
|
|
47
55
|
*
|
|
@@ -56,7 +64,7 @@ export type TsRestResponse<T, S extends number = 200> = {
|
|
|
56
64
|
* }
|
|
57
65
|
* ```
|
|
58
66
|
*/
|
|
59
|
-
export function success<T>(data: T, status: 200 = 200):
|
|
67
|
+
export function success<T>(data: T, status: 200 = 200): TsRestSuccessResponse<T, 200> {
|
|
60
68
|
return {
|
|
61
69
|
status,
|
|
62
70
|
body: {
|
|
@@ -64,7 +64,7 @@ export class UploaderService {
|
|
|
64
64
|
try {
|
|
65
65
|
const jsonString = rsaDecrypt(cmd.signature);
|
|
66
66
|
if (!jsonString || jsonString.trim() === '') {
|
|
67
|
-
|
|
67
|
+
this.logger.error(
|
|
68
68
|
'[Signature Validation] Decryption failed: empty result',
|
|
69
69
|
{
|
|
70
70
|
signature: cmd.signature?.substring(0, 50) + '...',
|
|
@@ -75,7 +75,7 @@ export class UploaderService {
|
|
|
75
75
|
}
|
|
76
76
|
signatureData = JSON.parse(jsonString);
|
|
77
77
|
} catch (e) {
|
|
78
|
-
|
|
78
|
+
this.logger.error('[Signature Validation] Decryption or parsing failed:', {
|
|
79
79
|
error: e.message || e,
|
|
80
80
|
signature: cmd.signature?.substring(0, 50) + '...',
|
|
81
81
|
userId,
|
|
@@ -86,7 +86,7 @@ export class UploaderService {
|
|
|
86
86
|
? undefined
|
|
87
87
|
: signatureData.userId;
|
|
88
88
|
if (userId != uploaderUserId) {
|
|
89
|
-
|
|
89
|
+
this.logger.error('[Signature Validation] UserId mismatch:', {
|
|
90
90
|
expectedUserId: userId,
|
|
91
91
|
signatureUserId: uploaderUserId,
|
|
92
92
|
signatureData,
|
|
@@ -99,7 +99,7 @@ export class UploaderService {
|
|
|
99
99
|
enviromentUtil.isProduction() &&
|
|
100
100
|
now - signatureData.timestamp > 15 * 1000
|
|
101
101
|
) {
|
|
102
|
-
|
|
102
|
+
this.logger.error('[Signature Validation] Timestamp expired:', {
|
|
103
103
|
now,
|
|
104
104
|
signatureTimestamp: signatureData.timestamp,
|
|
105
105
|
diff: now - signatureData.timestamp,
|
|
@@ -19,6 +19,8 @@ import * as loggerUtil from '@/utils/logger.util';
|
|
|
19
19
|
|
|
20
20
|
/** health module */
|
|
21
21
|
import { HealthModule } from './modules/health/health.module';
|
|
22
|
+
/** uploader module */
|
|
23
|
+
import { UploaderModule } from './modules/uploader/uploader.module';
|
|
22
24
|
|
|
23
25
|
/** i18n */
|
|
24
26
|
import {
|
|
@@ -179,6 +181,8 @@ import { DbMetricsService } from '@app/prisma/db-metrics/src/db-metrics.service'
|
|
|
179
181
|
VerifyModule,
|
|
180
182
|
SystemHealthModule,
|
|
181
183
|
JwtModule,
|
|
184
|
+
HealthModule,
|
|
185
|
+
UploaderModule,
|
|
182
186
|
],
|
|
183
187
|
providers: [
|
|
184
188
|
{
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { Controller, Req, VERSION_NEUTRAL } from '@nestjs/common';
|
|
2
|
+
import { TsRestHandler, tsRestHandler } from '@ts-rest/nest';
|
|
3
|
+
import { uploaderContract as c } from '@repo/contracts/api';
|
|
4
|
+
import { success } from '@/common/ts-rest/response.helper';
|
|
5
|
+
import { UploaderService } from '@app/shared-services/uploader';
|
|
6
|
+
import { FileSourceService } from '@app/db';
|
|
7
|
+
import { FileStorageService } from '@app/shared-services/file-storage';
|
|
8
|
+
import { ConfigService } from '@nestjs/config';
|
|
9
|
+
import { AppConfig } from '@/config/validation';
|
|
10
|
+
import fileUtil from '@/utils/file.util';
|
|
11
|
+
import ipUtil from '@/utils/ip.util';
|
|
12
|
+
import { FileBucketVendor } from '@prisma/client';
|
|
13
|
+
import { AuthenticatedRequest } from '@app/auth';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 文件上传控制器
|
|
17
|
+
*
|
|
18
|
+
* 提供文件上传相关的 API 端点,包括:
|
|
19
|
+
* - 获取上传凭证(私有/公开/缩略图)
|
|
20
|
+
* - 分片上传初始化和凭证获取
|
|
21
|
+
* - 上传完成确认
|
|
22
|
+
* - 上传取消
|
|
23
|
+
*
|
|
24
|
+
* @version VERSION_NEUTRAL
|
|
25
|
+
*
|
|
26
|
+
* 版本控制说明:
|
|
27
|
+
* - VERSION_NEUTRAL 表示该控制器为"版本中立"
|
|
28
|
+
* - 接受任何版本的请求,或者没有版本头的请求
|
|
29
|
+
* - 客户端调用时无需提供 `x-api-version` 请求头
|
|
30
|
+
* - 适用于上传接口这类稳定、不需要版本迭代的基础设施接口
|
|
31
|
+
*
|
|
32
|
+
* 如需版本控制,可改为:
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* // 指定版本(需要 x-api-version: 1 请求头)
|
|
36
|
+
* @Controller({ version: '1' })
|
|
37
|
+
*
|
|
38
|
+
* // 支持多版本
|
|
39
|
+
* @Controller({ version: ['1', '2'] })
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
@Controller({
|
|
43
|
+
/**
|
|
44
|
+
* VERSION_NEUTRAL: 版本中立模式
|
|
45
|
+
*
|
|
46
|
+
* 在 Header 版本控制模式下(x-api-version):
|
|
47
|
+
* - 接受任何版本号的请求
|
|
48
|
+
* - 接受没有版本头的请求
|
|
49
|
+
* - 不会因版本不匹配而拒绝请求
|
|
50
|
+
*
|
|
51
|
+
* 适用场景:
|
|
52
|
+
* - 基础设施接口(上传、健康检查等)
|
|
53
|
+
* - 不需要版本迭代的稳定接口
|
|
54
|
+
* - 需要向后兼容的公共接口
|
|
55
|
+
*/
|
|
56
|
+
version: VERSION_NEUTRAL,
|
|
57
|
+
})
|
|
58
|
+
export class UploaderController {
|
|
59
|
+
private appConfig: AppConfig;
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
private readonly uploaderService: UploaderService,
|
|
63
|
+
private readonly fileSourceDb: FileSourceService,
|
|
64
|
+
private readonly fileStorageService: FileStorageService,
|
|
65
|
+
private readonly configService: ConfigService,
|
|
66
|
+
) {
|
|
67
|
+
this.appConfig = configService.get<AppConfig>('app');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@TsRestHandler(c.getPrivateThumbToken)
|
|
71
|
+
async getPrivateThumbToken(@Req() req: AuthenticatedRequest) {
|
|
72
|
+
return tsRestHandler(c.getPrivateThumbToken, async ({ body }) => {
|
|
73
|
+
const userId = req.userId;
|
|
74
|
+
const ip = ipUtil.extractIp(req);
|
|
75
|
+
|
|
76
|
+
this.uploaderService.checkValidateAndReturnSignatureData(
|
|
77
|
+
userId,
|
|
78
|
+
body as any,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const result = await this.uploaderService.uploadThumbToken(
|
|
82
|
+
userId,
|
|
83
|
+
body as any,
|
|
84
|
+
ip,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return success({
|
|
88
|
+
token: result.token,
|
|
89
|
+
key: result.key,
|
|
90
|
+
fileId: result.key,
|
|
91
|
+
bucket: result.bucket,
|
|
92
|
+
url: `${result.domain}/${result.key}`,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@TsRestHandler(c.initMultipart)
|
|
98
|
+
async initMultipart(@Req() req: AuthenticatedRequest) {
|
|
99
|
+
return tsRestHandler(c.initMultipart, async ({ body }) => {
|
|
100
|
+
const userId = req.userId;
|
|
101
|
+
const ip = ipUtil.extractIp(req);
|
|
102
|
+
|
|
103
|
+
const signatureData =
|
|
104
|
+
this.uploaderService.checkValidateAndReturnSignatureData(
|
|
105
|
+
userId,
|
|
106
|
+
body as any,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const vendor =
|
|
110
|
+
body.vendor ?? (this.appConfig.defaultVendor as FileBucketVendor);
|
|
111
|
+
const bucket = await this.fileStorageService.getBucketString(
|
|
112
|
+
body.bucket,
|
|
113
|
+
ip,
|
|
114
|
+
false,
|
|
115
|
+
body.locale,
|
|
116
|
+
vendor,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const ext = fileUtil.getFileExtension(body.filename);
|
|
120
|
+
const key = await this.fileStorageService.formatNewKeyString(
|
|
121
|
+
'private',
|
|
122
|
+
ext,
|
|
123
|
+
bucket,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Create file source record
|
|
127
|
+
const fileSource = await this.fileSourceDb.create({
|
|
128
|
+
key,
|
|
129
|
+
bucket,
|
|
130
|
+
vendor,
|
|
131
|
+
fsize: body.fsize,
|
|
132
|
+
mimeType: fileUtil.getMimeType(body.filename),
|
|
133
|
+
ext,
|
|
134
|
+
sha256: body.sha256 || signatureData.sha256,
|
|
135
|
+
isUploaded: false,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Get multipart upload ID
|
|
139
|
+
const uploadId = await this.fileStorageService.getMultipartUploadId(
|
|
140
|
+
vendor,
|
|
141
|
+
bucket,
|
|
142
|
+
key,
|
|
143
|
+
ip,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Get presigned URL for first part
|
|
147
|
+
const token = await this.fileStorageService.getPresignedUrl(
|
|
148
|
+
vendor,
|
|
149
|
+
bucket,
|
|
150
|
+
{ uploadId, key, partNumber: 1 },
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return success({
|
|
154
|
+
token,
|
|
155
|
+
key,
|
|
156
|
+
fileId: fileSource.id,
|
|
157
|
+
bucket,
|
|
158
|
+
url: uploadId,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@TsRestHandler(c.getMultipartToken)
|
|
164
|
+
async getMultipartToken(@Req() req: AuthenticatedRequest) {
|
|
165
|
+
return tsRestHandler(c.getMultipartToken, async ({ body }) => {
|
|
166
|
+
const userId = req.userId;
|
|
167
|
+
const ip = ipUtil.extractIp(req);
|
|
168
|
+
|
|
169
|
+
this.uploaderService.checkValidateAndReturnSignatureData(
|
|
170
|
+
userId,
|
|
171
|
+
body as any,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const result = await this.uploaderService.getUploaderPresignedUrl(
|
|
175
|
+
body as any,
|
|
176
|
+
ip,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const defaultBucket = await this.fileStorageService.getDefaultBucket();
|
|
180
|
+
|
|
181
|
+
return success({
|
|
182
|
+
token: result.token,
|
|
183
|
+
key: result.fileKey,
|
|
184
|
+
fileId: body.key || result.fileKey,
|
|
185
|
+
bucket: body.bucket || defaultBucket,
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@TsRestHandler(c.getPrivateToken)
|
|
191
|
+
async getPrivateToken(@Req() req: AuthenticatedRequest) {
|
|
192
|
+
return tsRestHandler(c.getPrivateToken, async ({ body }) => {
|
|
193
|
+
const userId = req.userId;
|
|
194
|
+
const ip = ipUtil.extractIp(req);
|
|
195
|
+
|
|
196
|
+
const signatureData =
|
|
197
|
+
this.uploaderService.checkValidateAndReturnSignatureData(
|
|
198
|
+
userId,
|
|
199
|
+
body as any,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const vendor =
|
|
203
|
+
body.vendor ?? (this.appConfig.defaultVendor as FileBucketVendor);
|
|
204
|
+
const bucket = await this.fileStorageService.getBucketString(
|
|
205
|
+
body.bucket,
|
|
206
|
+
ip,
|
|
207
|
+
false,
|
|
208
|
+
body.locale,
|
|
209
|
+
vendor,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const ext = fileUtil.getFileExtension(body.filename);
|
|
213
|
+
const key = await this.fileStorageService.formatNewKeyString(
|
|
214
|
+
'private',
|
|
215
|
+
ext,
|
|
216
|
+
bucket,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// Create file source record
|
|
220
|
+
const fileSource = await this.fileSourceDb.create({
|
|
221
|
+
key,
|
|
222
|
+
bucket,
|
|
223
|
+
vendor,
|
|
224
|
+
fsize: body.fsize,
|
|
225
|
+
mimeType: fileUtil.getMimeType(body.filename),
|
|
226
|
+
ext,
|
|
227
|
+
sha256: body.sha256 || signatureData.sha256,
|
|
228
|
+
isUploaded: false,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Get upload token with callback
|
|
232
|
+
const result = await this.uploaderService.uploadTokenWithCallback(
|
|
233
|
+
vendor,
|
|
234
|
+
bucket,
|
|
235
|
+
key,
|
|
236
|
+
ip,
|
|
237
|
+
body.locale,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const config = await this.fileStorageService.getFileServiceConfig(
|
|
241
|
+
vendor,
|
|
242
|
+
bucket,
|
|
243
|
+
ip,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
return success({
|
|
247
|
+
token: result.token,
|
|
248
|
+
key: result.fileKey,
|
|
249
|
+
fileId: fileSource.id,
|
|
250
|
+
bucket,
|
|
251
|
+
url: `${config.domain}/${result.fileKey}`,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@TsRestHandler(c.abort)
|
|
257
|
+
async abort(@Req() req: AuthenticatedRequest) {
|
|
258
|
+
return tsRestHandler(c.abort, async ({ body }) => {
|
|
259
|
+
const userId = req.userId;
|
|
260
|
+
|
|
261
|
+
this.uploaderService.checkValidateAndReturnSignatureData(userId, {
|
|
262
|
+
signature: body.signature,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Mark file as deleted
|
|
266
|
+
await this.fileSourceDb.update({ id: body.fileId }, { isDeleted: true });
|
|
267
|
+
|
|
268
|
+
return success({ success: true });
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
@TsRestHandler(c.complete)
|
|
273
|
+
async complete(@Req() req: AuthenticatedRequest) {
|
|
274
|
+
return tsRestHandler(c.complete, async ({ body }) => {
|
|
275
|
+
const userId = req.userId;
|
|
276
|
+
|
|
277
|
+
this.uploaderService.checkValidateAndReturnSignatureData(userId, {
|
|
278
|
+
signature: body.signature,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Get file source and mark as uploaded
|
|
282
|
+
const fileSource = await this.fileSourceDb.update(
|
|
283
|
+
{ id: body.fileId },
|
|
284
|
+
{ isUploaded: true },
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const config = await this.fileStorageService.getFileServiceConfig(
|
|
288
|
+
fileSource.vendor,
|
|
289
|
+
fileSource.bucket,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
return success({
|
|
293
|
+
id: fileSource.id,
|
|
294
|
+
key: fileSource.key,
|
|
295
|
+
bucket: fileSource.bucket,
|
|
296
|
+
fsize: fileSource.fsize,
|
|
297
|
+
mimeType: fileSource.mimeType,
|
|
298
|
+
ext: fileSource.ext,
|
|
299
|
+
sha256: fileSource.sha256,
|
|
300
|
+
isUploaded: fileSource.isUploaded,
|
|
301
|
+
url: `${config.domain}/${fileSource.key}`,
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ConfigModule } from '@nestjs/config';
|
|
3
|
+
import { UploaderController } from './uploader.controller';
|
|
4
|
+
import { UploaderModule as UploaderServiceModule } from '@app/shared-services/uploader';
|
|
5
|
+
import { FileStorageServiceModule } from '@app/shared-services/file-storage';
|
|
6
|
+
import { FileSourceModule } from '@app/db';
|
|
7
|
+
|
|
8
|
+
@Module({
|
|
9
|
+
imports: [
|
|
10
|
+
ConfigModule,
|
|
11
|
+
UploaderServiceModule,
|
|
12
|
+
FileStorageServiceModule,
|
|
13
|
+
FileSourceModule,
|
|
14
|
+
],
|
|
15
|
+
controllers: [UploaderController],
|
|
16
|
+
})
|
|
17
|
+
export class UploaderModule {}
|