Skip to content
/ core Public

Commit 32ce773

Browse files
committed
fix(file): fallback image upload to local storage when S3 is disabled
1 parent 29bf581 commit 32ce773

File tree

2 files changed

+123
-4
lines changed

2 files changed

+123
-4
lines changed

apps/core/src/modules/file/file.controller.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,14 @@ export class FileController {
162162

163163
// 获取文件上传配置
164164
const uploadConfig = await this.configsService.get('fileUploadOptions')
165+
const imageStorageConfig =
166+
type === 'image'
167+
? await this.configsService.get('imageStorageOptions')
168+
: null
165169

166-
if (type === 'image') {
167-
const config = await this.configsService.get('imageStorageOptions')
170+
if (type === 'image' && imageStorageConfig?.enable) {
171+
const config = imageStorageConfig
168172
if (
169-
!config.enable ||
170173
!config.endpoint ||
171174
!config.secretId ||
172175
!config.secretKey ||
@@ -230,7 +233,14 @@ export class FileController {
230233
return { url: s3Url, name: filename }
231234
}
232235

233-
const file = await this.uploadService.getAndValidMultipartField(req)
236+
const file = await this.uploadService.getAndValidMultipartField(
237+
req,
238+
type === 'image'
239+
? {
240+
maxFileSize: 20 * 1024 * 1024,
241+
}
242+
: undefined,
243+
)
234244

235245
// 生成文件名(可能包含子路径)
236246
const rawFilename = generateFilename(uploadConfig, {
@@ -257,6 +267,12 @@ export class FileController {
257267

258268
await this.service.writeFile(type, relativePath, file.file)
259269
const fileUrl = await this.service.resolveFileUrl(type, relativePath)
270+
if (type === 'image') {
271+
await this.fileReferenceService.createPendingReference(
272+
fileUrl,
273+
relativePath,
274+
)
275+
}
260276

261277
return { url: fileUrl, name: path.basename(relativePath) }
262278
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Readable } from 'node:stream'
2+
3+
import { describe, expect, it, vi } from 'vitest'
4+
5+
import { ErrorCodeEnum } from '~/constants/error-code.constant'
6+
import { FileController } from '~/modules/file/file.controller'
7+
8+
describe('FileController', () => {
9+
it('falls back to local storage for image uploads when S3 is disabled', async () => {
10+
const writeFile = vi.fn().mockResolvedValue(undefined)
11+
const resolveFileUrl = vi
12+
.fn()
13+
.mockResolvedValue('http://example.com/objects/image/nested/origin.png')
14+
const getAndValidMultipartField = vi.fn().mockResolvedValue({
15+
filename: 'origin.png',
16+
file: Readable.from(['image-bytes']),
17+
})
18+
const createPendingReference = vi.fn().mockResolvedValue(undefined)
19+
const get = vi.fn().mockImplementation((key: string) => {
20+
if (key === 'fileUploadOptions') {
21+
return Promise.resolve({
22+
enableCustomNaming: true,
23+
filenameTemplate: '{name}{ext}',
24+
pathTemplate: '{type}/nested',
25+
})
26+
}
27+
if (key === 'imageStorageOptions') {
28+
return Promise.resolve({
29+
enable: false,
30+
})
31+
}
32+
return Promise.reject(new Error(`Unexpected config key: ${key}`))
33+
})
34+
35+
const controller = new FileController(
36+
{ writeFile, resolveFileUrl } as any,
37+
{ getAndValidMultipartField } as any,
38+
{ createPendingReference } as any,
39+
{ get } as any,
40+
)
41+
42+
const result = await controller.upload({ type: 'image' } as any, {} as any)
43+
44+
expect(getAndValidMultipartField).toHaveBeenCalledWith(
45+
{},
46+
expect.objectContaining({ maxFileSize: 20 * 1024 * 1024 }),
47+
)
48+
expect(writeFile).toHaveBeenCalledWith(
49+
'image',
50+
'nested/origin.png',
51+
expect.any(Readable),
52+
)
53+
expect(resolveFileUrl).toHaveBeenCalledWith('image', 'nested/origin.png')
54+
expect(createPendingReference).toHaveBeenCalledWith(
55+
'http://example.com/objects/image/nested/origin.png',
56+
'nested/origin.png',
57+
)
58+
expect(result).toEqual({
59+
url: 'http://example.com/objects/image/nested/origin.png',
60+
name: 'origin.png',
61+
})
62+
})
63+
64+
it('throws when S3 image storage is enabled but incomplete', async () => {
65+
const writeFile = vi.fn()
66+
const getAndValidMultipartField = vi.fn()
67+
const createPendingReference = vi.fn()
68+
const get = vi.fn().mockImplementation((key: string) => {
69+
if (key === 'fileUploadOptions') {
70+
return Promise.resolve({
71+
enableCustomNaming: false,
72+
})
73+
}
74+
if (key === 'imageStorageOptions') {
75+
return Promise.resolve({
76+
enable: true,
77+
endpoint: '',
78+
secretId: '',
79+
secretKey: '',
80+
bucket: '',
81+
})
82+
}
83+
return Promise.reject(new Error(`Unexpected config key: ${key}`))
84+
})
85+
86+
const controller = new FileController(
87+
{ writeFile, resolveFileUrl: vi.fn() } as any,
88+
{ getAndValidMultipartField } as any,
89+
{ createPendingReference } as any,
90+
{ get } as any,
91+
)
92+
93+
await expect(
94+
controller.upload({ type: 'image' } as any, {} as any),
95+
).rejects.toMatchObject({
96+
bizCode: ErrorCodeEnum.ImageStorageNotConfigured,
97+
})
98+
99+
expect(getAndValidMultipartField).not.toHaveBeenCalled()
100+
expect(writeFile).not.toHaveBeenCalled()
101+
expect(createPendingReference).not.toHaveBeenCalled()
102+
})
103+
})

0 commit comments

Comments
 (0)