Sfoglia il codice sorgente

feat: add gcs upload storage module

ChenYu 4 mesi fa
parent
commit
93f9fa1b69

+ 11 - 0
backend/package-lock.json

@@ -29,6 +29,7 @@
         "@nestjs/testing": "^11.0.0",
         "@types/express": "^5.0.3",
         "@types/jest": "^30.0.0",
+        "@types/multer": "^2.1.0",
         "@types/node": "^22.0.0",
         "@types/supertest": "^6.0.2",
         "eslint": "^9.0.0",
@@ -2946,6 +2947,16 @@
       "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
       "license": "MIT"
     },
+    "node_modules/@types/multer": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
+      "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/express": "*"
+      }
+    },
     "node_modules/@types/node": {
       "version": "22.20.0",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",

+ 1 - 0
backend/package.json

@@ -50,6 +50,7 @@
     "@nestjs/testing": "^11.0.0",
     "@types/express": "^5.0.3",
     "@types/jest": "^30.0.0",
+    "@types/multer": "^2.1.0",
     "@types/node": "^22.0.0",
     "@types/supertest": "^6.0.2",
     "eslint": "^9.0.0",

+ 2 - 0
backend/src/app.module.ts

@@ -6,6 +6,7 @@ import { AuthModule } from './modules/auth/auth.module';
 import { CustomersModule } from './modules/customers/customers.module';
 import { HealthModule } from './modules/health/health.module';
 import { SimulationsModule } from './modules/simulations/simulations.module';
+import { StorageModule } from './modules/storage/storage.module';
 import { TemplatesModule } from './modules/templates/templates.module';
 import { PrismaModule } from './prisma/prisma.module';
 import { RedisModule } from './redis/redis.module';
@@ -23,6 +24,7 @@ import { RedisModule } from './redis/redis.module';
     AuthModule,
     CustomersModule,
     HealthModule,
+    StorageModule,
     TemplatesModule,
     SimulationsModule,
   ],

+ 10 - 0
backend/src/modules/storage/dto/upload-file.dto.ts

@@ -0,0 +1,10 @@
+import { IsNotEmpty, Matches } from 'class-validator';
+import { STORAGE_TYPE_PATTERN } from '../storage.constants';
+
+export class UploadFileDto {
+  @IsNotEmpty()
+  @Matches(STORAGE_TYPE_PATTERN, {
+    message: '上传类型不合法',
+  })
+  type!: string;
+}

+ 7 - 0
backend/src/modules/storage/dto/upload-result.dto.ts

@@ -0,0 +1,7 @@
+export class UploadResultDto {
+  url!: string;
+  objectKey!: string;
+  bucket!: string;
+  contentType!: string;
+  size!: number;
+}

+ 31 - 0
backend/src/modules/storage/gcs-storage.driver.ts

@@ -0,0 +1,31 @@
+import { Storage as GoogleCloudStorage } from '@google-cloud/storage';
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { StorageDriver } from './storage.driver';
+import { DriverUploadInput } from './storage.types';
+
+@Injectable()
+export class GcsStorageDriver implements StorageDriver {
+  private readonly storage: GoogleCloudStorage;
+  private readonly bucket: string;
+
+  constructor(private readonly configService: ConfigService) {
+    this.bucket = this.configService.getOrThrow<string>('storage.gcs.bucket');
+    this.storage = new GoogleCloudStorage({
+      projectId: this.configService.getOrThrow<string>('storage.gcs.projectId'),
+      credentials: {
+        client_email: this.configService.getOrThrow<string>(
+          'storage.gcs.clientEmail',
+        ),
+        private_key: this.configService.getOrThrow<string>('storage.gcs.privateKey'),
+      },
+    });
+  }
+
+  async upload(input: DriverUploadInput): Promise<void> {
+    await this.storage.bucket(this.bucket).file(input.objectKey).save(input.buffer, {
+      resumable: false,
+      contentType: input.contentType,
+    });
+  }
+}

+ 3 - 0
backend/src/modules/storage/storage.constants.ts

@@ -0,0 +1,3 @@
+export const STORAGE_TYPE_PATTERN = /^[A-Za-z0-9_-]+$/;
+export const GCS_PUBLIC_BASE_URL = 'https://storage.googleapis.com';
+export const STORAGE_DRIVER = Symbol('STORAGE_DRIVER');

+ 45 - 0
backend/src/modules/storage/storage.controller.ts

@@ -0,0 +1,45 @@
+import {
+  BadRequestException,
+  Body,
+  Controller,
+  Post,
+  UploadedFile,
+  UseInterceptors,
+} from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { memoryStorage } from 'multer';
+import { UploadFileDto } from './dto/upload-file.dto';
+import { StorageService } from './storage.service';
+
+type UploadedMemoryFile = {
+  buffer: Buffer;
+  mimetype: string;
+  originalname: string;
+};
+
+@Controller('storage')
+export class StorageController {
+  constructor(private readonly storageService: StorageService) {}
+
+  @Post('upload')
+  @UseInterceptors(
+    FileInterceptor('file', {
+      storage: memoryStorage(),
+    }),
+  )
+  upload(
+    @Body() body: UploadFileDto,
+    @UploadedFile() file?: UploadedMemoryFile,
+  ) {
+    if (!file) {
+      throw new BadRequestException('请选择要上传的文件');
+    }
+
+    return this.storageService.uploadBuffer({
+      buffer: file.buffer,
+      contentType: file.mimetype,
+      type: body.type,
+      originalFilename: file.originalname,
+    });
+  }
+}

+ 5 - 0
backend/src/modules/storage/storage.driver.ts

@@ -0,0 +1,5 @@
+import { DriverUploadInput } from './storage.types';
+
+export interface StorageDriver {
+  upload(input: DriverUploadInput): Promise<void>;
+}

+ 19 - 0
backend/src/modules/storage/storage.module.ts

@@ -0,0 +1,19 @@
+import { Module } from '@nestjs/common';
+import { GcsStorageDriver } from './gcs-storage.driver';
+import { STORAGE_DRIVER } from './storage.constants';
+import { StorageController } from './storage.controller';
+import { StorageService } from './storage.service';
+
+@Module({
+  controllers: [StorageController],
+  providers: [
+    GcsStorageDriver,
+    StorageService,
+    {
+      provide: STORAGE_DRIVER,
+      useExisting: GcsStorageDriver,
+    },
+  ],
+  exports: [StorageService, STORAGE_DRIVER],
+})
+export class StorageModule {}

+ 69 - 0
backend/src/modules/storage/storage.service.spec.ts

@@ -0,0 +1,69 @@
+import { BadRequestException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { Test } from '@nestjs/testing';
+import { STORAGE_DRIVER } from './storage.constants';
+import { StorageService } from './storage.service';
+
+describe('StorageService', () => {
+  let service: StorageService;
+  const upload = jest.fn();
+
+  beforeEach(async () => {
+    upload.mockReset();
+
+    const moduleRef = await Test.createTestingModule({
+      providers: [
+        StorageService,
+        {
+          provide: STORAGE_DRIVER,
+          useValue: { upload },
+        },
+        {
+          provide: ConfigService,
+          useValue: {
+            getOrThrow: jest.fn((key: string) => {
+              const map: Record<string, string> = {
+                'storage.gcs.bucket': 'aitori-public',
+              };
+
+              return map[key];
+            }),
+          },
+        },
+      ],
+    }).compile();
+
+    service = moduleRef.get(StorageService);
+  });
+
+  it('uploads a buffer and returns a full public url', async () => {
+    const result = await service.uploadBuffer({
+      buffer: Buffer.from('hello'),
+      contentType: 'image/png',
+      type: 'template',
+      originalFilename: 'look.png',
+    });
+
+    expect(upload).toHaveBeenCalledWith(
+      expect.objectContaining({
+        objectKey: expect.stringMatching(/^stylist\/template\/.+\.png$/),
+        contentType: 'image/png',
+      }),
+    );
+    expect(result.bucket).toBe('aitori-public');
+    expect(result.objectKey).toMatch(/^stylist\/template\/.+\.png$/);
+    expect(result.url).toBe(`https://storage.googleapis.com/aitori-public/${result.objectKey}`);
+    expect(result.size).toBe(5);
+  });
+
+  it('rejects an invalid type', async () => {
+    await expect(
+      service.uploadBuffer({
+        buffer: Buffer.from('hello'),
+        contentType: 'image/png',
+        type: '../template',
+        originalFilename: 'look.png',
+      }),
+    ).rejects.toBeInstanceOf(BadRequestException);
+  });
+});

+ 44 - 0
backend/src/modules/storage/storage.service.ts

@@ -0,0 +1,44 @@
+import { BadRequestException, Inject, Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { randomUUID } from 'crypto';
+import { extname } from 'path';
+import {
+  GCS_PUBLIC_BASE_URL,
+  STORAGE_DRIVER,
+  STORAGE_TYPE_PATTERN,
+} from './storage.constants';
+import { StorageDriver } from './storage.driver';
+import { UploadResultDto } from './dto/upload-result.dto';
+import { UploadBufferInput } from './storage.types';
+
+@Injectable()
+export class StorageService {
+  constructor(
+    @Inject(STORAGE_DRIVER) private readonly storageDriver: StorageDriver,
+    private readonly configService: ConfigService,
+  ) {}
+
+  async uploadBuffer(input: UploadBufferInput): Promise<UploadResultDto> {
+    if (!STORAGE_TYPE_PATTERN.test(input.type)) {
+      throw new BadRequestException('上传类型不合法');
+    }
+
+    const bucket = this.configService.getOrThrow<string>('storage.gcs.bucket');
+    const extension = extname(input.originalFilename);
+    const objectKey = `stylist/${input.type}/${randomUUID()}${extension}`;
+
+    await this.storageDriver.upload({
+      objectKey,
+      buffer: input.buffer,
+      contentType: input.contentType,
+    });
+
+    return {
+      url: `${GCS_PUBLIC_BASE_URL}/${bucket}/${objectKey}`,
+      objectKey,
+      bucket,
+      contentType: input.contentType,
+      size: input.buffer.length,
+    };
+  }
+}

+ 12 - 0
backend/src/modules/storage/storage.types.ts

@@ -0,0 +1,12 @@
+export type UploadBufferInput = {
+  buffer: Buffer;
+  contentType: string;
+  type: string;
+  originalFilename: string;
+};
+
+export type DriverUploadInput = {
+  objectKey: string;
+  buffer: Buffer;
+  contentType: string;
+};

+ 71 - 1
backend/test/app.e2e-spec.ts

@@ -3,14 +3,25 @@ import { Test } from '@nestjs/testing';
 import * as request from 'supertest';
 import { AppModule } from '../src/app.module';
 import { configureApp } from '../src/app.setup';
+import { GcsStorageDriver } from '../src/modules/storage/gcs-storage.driver';
+import { STORAGE_DRIVER } from '../src/modules/storage/storage.constants';
 
 describe('App (e2e)', () => {
   let app: INestApplication;
+  const upload = jest.fn();
 
   beforeAll(async () => {
+    upload.mockReset();
+    upload.mockResolvedValue(undefined);
+
     const moduleRef = await Test.createTestingModule({
       imports: [AppModule],
-    }).compile();
+    })
+      .overrideProvider(STORAGE_DRIVER)
+      .useValue({ upload })
+      .overrideProvider(GcsStorageDriver)
+      .useValue({ upload })
+      .compile();
 
     app = moduleRef.createNestApplication();
     configureApp(app);
@@ -114,4 +125,63 @@ describe('App (e2e)', () => {
       .send({})
       .expect(400);
   });
+
+  it('/api/v1/storage/upload (POST) uploads a file and returns a public url', async () => {
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/storage/upload')
+      .field('type', 'template')
+      .attach('file', Buffer.from('hello'), {
+        filename: 'look.png',
+        contentType: 'image/png',
+      })
+      .expect(201);
+
+    expect(upload).toHaveBeenCalledWith(
+      expect.objectContaining({
+        objectKey: expect.stringMatching(/^stylist\/template\/.+\.png$/),
+        contentType: 'image/png',
+      }),
+    );
+    expect(response.body.ok).toBe(true);
+    expect(response.body.data.objectKey).toMatch(/^stylist\/template\/.+\.png$/);
+    expect(response.body.data.url).toBe(
+      `https://storage.googleapis.com/aitori-public/${response.body.data.objectKey}`,
+    );
+  });
+
+  it('/api/v1/storage/upload (POST) rejects requests without a file', async () => {
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/storage/upload')
+      .field('type', 'template')
+      .expect(400);
+
+    expect(response.body).toEqual({
+      ok: false,
+      data: null,
+      error: {
+        code: 'HTTP_400',
+        message: '请选择要上传的文件',
+      },
+    });
+  });
+
+  it('/api/v1/storage/upload (POST) validates type', async () => {
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/storage/upload')
+      .field('type', '../template')
+      .attach('file', Buffer.from('hello'), {
+        filename: 'look.png',
+        contentType: 'image/png',
+      })
+      .expect(400);
+
+    expect(response.body).toEqual({
+      ok: false,
+      data: null,
+      error: {
+        code: 'HTTP_400',
+        message: '上传类型不合法',
+      },
+    });
+  });
 });