# GCS Upload Storage Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a reusable GCS-backed upload capability with one HTTP upload endpoint, one internal storage service, validated GCS configuration, and tests that prove uploaded files return full public URLs. **Architecture:** Add a dedicated `storage` module to the NestJS backend. `StorageController` accepts `multipart/form-data`, `StorageService` validates `type`, generates `stylist/{type}/{filename}` object keys plus full public URLs, and `GcsStorageDriver` isolates Google Cloud Storage SDK calls so later business modules can reuse the service layer without touching the SDK directly. **Tech Stack:** NestJS 11, `@google-cloud/storage`, Multer via `@nestjs/platform-express`, Zod env validation, Jest, Supertest ## Global Constraints - 上传能力优先抽象为通用基础设施,而不是绑定单一业务模块。 - 统一由服务端决定最终对象 key 和文件名,不把命名规则散落到前端或业务层。 - `type` 保持自由输入,但必须经过安全校验,避免路径穿越和命名污染。 - 返回体以完整 URL 为主,`objectKey` 作为辅助信息保留。 - 后续业务表默认存 `url`,减少访问拼接逻辑和前后端重复认知。 - GCS SDK 调用细节应隔离在驱动层,业务模块不直接依赖 SDK。 - 未开发的能力显式不做,不提前引入签名 URL、删除策略或私有读链路。 - 本 case 不做前端正式上传页面。 - 本 case 不做签名 URL 上传。 - 本 case 不做分片上传、大文件断点续传。 - 本 case 不开发文件删除、覆盖、批量迁移。 - 本 case 不做复杂权限隔离或私有签名下载。 - 整个实现中不需要硬编码 GCS 凭据。 - 禁止在代码中使用硬编码的绝对路径。 --- ## File Structure - `backend/package.json`: add the Google Cloud Storage dependency used by the new driver. - `backend/.env`: add the provided GCS runtime values for local execution. - `backend/.env.example`: document the new storage env keys for future environments. - `backend/src/config/env.schema.ts`: validate `STORAGE_DRIVER`, `GCS_BUCKET`, `GCS_PROJECT_ID`, `GCS_CLIENT_EMAIL`, and `GCS_PRIVATE_KEY`. - `backend/src/config/app.config.ts`: expose a normalized `storage` config object, including `privateKey` newline normalization. - `backend/src/app.module.ts`: register the new `StorageModule`. - `backend/src/modules/storage/dto/upload-file.dto.ts`: validate the HTTP `type` form field. - `backend/src/modules/storage/dto/upload-result.dto.ts`: define the standard upload response payload. - `backend/src/modules/storage/storage.types.ts`: centralize reusable upload input/output interfaces used by controller, service, and driver. - `backend/src/modules/storage/storage.constants.ts`: hold the `type` validation regex and the public GCS base URL. - `backend/src/modules/storage/storage.driver.ts`: define the driver interface consumed by the service. - `backend/src/modules/storage/gcs-storage.driver.ts`: implement the GCS SDK upload behavior. - `backend/src/modules/storage/storage.service.ts`: validate `type`, generate unique filenames and object keys, call the driver, and return the final URL-first payload. - `backend/src/modules/storage/storage.controller.ts`: expose `POST /api/v1/storage/upload` using Multer memory storage. - `backend/src/modules/storage/storage.module.ts`: wire controller, service, driver provider, and config usage. - `backend/src/modules/storage/storage.service.spec.ts`: cover `type` validation, object key generation, URL generation, and internal `uploadBuffer()` behavior. - `backend/test/app.e2e-spec.ts`: add HTTP upload success and validation failure tests using a mocked storage driver. ### Task 1: Add Storage Dependencies And Configuration **Files:** - Modify: `backend/package.json` - Modify: `backend/.env` - Modify: `backend/.env.example` - Modify: `backend/src/config/env.schema.ts` - Modify: `backend/src/config/app.config.ts` **Interfaces:** - Produces: env keys `STORAGE_DRIVER`, `GCS_BUCKET`, `GCS_PROJECT_ID`, `GCS_CLIENT_EMAIL`, `GCS_PRIVATE_KEY` - Produces: config shape `storage: { driver: string; gcs: { bucket: string; projectId: string; clientEmail: string; privateKey: string } }` - [ ] **Step 1: Add the GCS SDK dependency and write the failing env-schema test inline by extending the schema usage** ```json // backend/package.json { "dependencies": { "@google-cloud/storage": "^7.13.0" } } ``` ```ts // backend/src/config/env.schema.ts const parsed = envSchema.parse({ PORT: '3000', APP_BASE_URL: 'http://localhost:3000', DATABASE_URL: 'mysql://root:123456@localhost:3306/aitori_stylist', REDIS_URL: 'redis://:123456@localhost:6379', JWT_SECRET: 'replace-me', JWT_EXPIRES_IN: '7d', CORS_ORIGIN: 'http://127.0.0.1:5173', NODE_ENV: 'development', STORAGE_DRIVER: 'gcs', GCS_BUCKET: 'aitori-public', GCS_PROJECT_ID: 'app-aitori-jp', GCS_CLIENT_EMAIL: 'oss-227@app-aitori-jp.iam.gserviceaccount.com', GCS_PRIVATE_KEY: 'line1\\nline2', }); expect(parsed.STORAGE_DRIVER).toBe('gcs'); expect(parsed.GCS_BUCKET).toBe('aitori-public'); ``` - [ ] **Step 2: Install dependencies and confirm the config keys are still missing** Run: ```bash npm install npm test -- --runInBand ``` Expected: tests or TypeScript fail because the schema/config does not yet include the new storage keys. - [ ] **Step 3: Extend env validation and app config** ```ts // backend/src/config/env.schema.ts export const envSchema = z.object({ PORT: z.coerce.number().int().positive().default(3000), APP_BASE_URL: z.string().default('http://localhost:3000'), DATABASE_URL: z.string().default('mysql://root:root@localhost:3306/aitori_backend'), REDIS_URL: z.string().default('redis://localhost:6379'), JWT_SECRET: z.string().default('replace-me'), JWT_EXPIRES_IN: z.string().default('7d'), CORS_ORIGIN: z.string().default('http://127.0.0.1:5173'), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), STORAGE_DRIVER: z.enum(['gcs']).default('gcs'), GCS_BUCKET: z.string().min(1), GCS_PROJECT_ID: z.string().min(1), GCS_CLIENT_EMAIL: z.string().email(), GCS_PRIVATE_KEY: z.string().min(1), }); ``` ```ts // backend/src/config/app.config.ts function normalizePrivateKey(value: string) { return value.replace(/\\n/g, '\n'); } export default () => ({ port: Number(process.env.PORT ?? 3000), appBaseUrl: process.env.APP_BASE_URL ?? 'http://localhost:3000', corsOrigin: process.env.CORS_ORIGIN ?? 'http://127.0.0.1:5173', jwtSecret: process.env.JWT_SECRET ?? 'replace-me', jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d', databaseUrl: process.env.DATABASE_URL ?? 'mysql://root:123456@localhost:3306/aitori_stylist', redisUrl: process.env.REDIS_URL ?? 'redis://:123456@localhost:6379', storage: { driver: process.env.STORAGE_DRIVER ?? 'gcs', gcs: { bucket: process.env.GCS_BUCKET ?? '', projectId: process.env.GCS_PROJECT_ID ?? '', clientEmail: process.env.GCS_CLIENT_EMAIL ?? '', privateKey: normalizePrivateKey(process.env.GCS_PRIVATE_KEY ?? ''), }, }, }); ``` - [ ] **Step 4: Add the new keys to the env files** ```env # backend/.env.example PORT=3000 APP_BASE_URL=http://localhost:3000 DATABASE_URL=mysql://root:123456@localhost:3306/aitori_stylist REDIS_URL=redis://:123456@localhost:6379 JWT_SECRET=replace-me JWT_EXPIRES_IN=7d CORS_ORIGIN=http://127.0.0.1:5173 NODE_ENV=development STORAGE_DRIVER=gcs GCS_BUCKET=aitori-public GCS_PROJECT_ID=app-aitori-jp GCS_CLIENT_EMAIL=oss-227@app-aitori-jp.iam.gserviceaccount.com GCS_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nreplace-me\n-----END PRIVATE KEY-----\n ``` ```env # backend/.env PORT=3000 APP_BASE_URL=http://localhost:3000 DATABASE_URL=mysql://root:123456@localhost:3306/aitori_stylist REDIS_URL=redis://:123456@localhost:6379 JWT_SECRET=replace-me JWT_EXPIRES_IN=7d CORS_ORIGIN=http://127.0.0.1:5173 NODE_ENV=development STORAGE_DRIVER=gcs GCS_BUCKET=aitori-public GCS_PROJECT_ID=app-aitori-jp GCS_CLIENT_EMAIL=oss-227@app-aitori-jp.iam.gserviceaccount.com GCS_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCGi3DHDN4CT4+R\nxJ/N2i5wgAK80J0sUH8mLsq5HA93JUyGUpD4SyL55X6VxKje1enHr8LvjRQVr2IB\nXhHa0n0GwubBj658kO+fpmDPObCQVcB/ODIsgwAeUiPnD0ypHXhKRtAnEqb/dKiw\nieuHPzbo/pTFN1HOb8XFgRJ1ks+P9F5cd+9ydCfChDsAzvDbbbAKLqV+SHPSs2rO\nO+yByCgnODTYwsgV0GA9V29y7ubtG2fSPIksGs5DSCDvVWGEIW1zPmLJcAY1XTSO\nL6305FBrGKjJSDj4cchMP9mv04pMCgMol7GCGFjEeJwOd5huImtPF1YjJyPm3Dku\nUP91eJz9AgMBAAECggEAAS3oBIKj6bxfS1bzhj2L/yklsDIaH2LEPoyV8wOGzWCB\nEdzxT6lyI/0dCMlWW2YS6SpsyIzbLBAWiWFujoiEsO8hg5K6FylURF+HUgGumJFI\np7gEu15zMF2mQJ0u3f03yigVPoAIgTNshoGeLb6odIHfKKku7FX1k2EQGgI6E6Gv\n0Iyryo1YQt6YiCuZHzkYU6kD3U5GsL+zyr4nR6dp55A6ZchDBEhJbkMGVaM+imxQ\nS7ABbhOfsOc0dy7Cfn5P1t/wyMNpol9W7fqaUjsTeRJCtvQWDUc83khtBC3bz61R\nY3e9bLf9jq4HBTbn3C3BfV169L3iZRKZN/2sfo61CQKBgQC5l02g5JnFCkdG9Ma2\nYQxxiwErRhpLhjmfm4R4yaqrZRhhfMG+kcS0meQn8Ud0go3XGEak5oJWXW+gZnNG\n6rEdpqVuzHBZUdPaQHYY2jV5fgYV614RzQB3n2xed7CPLwXr2AHj0UBMcEkUAVbD\nyckuAqyUlA/ymJ5ACg7DHGPX1QKBgQC5ln2Opk2ItFXooZ8PWkN/W55v5LzarcNU\nPFg1NuGiM9w9Jot/xRukFd3saU8hR28vtZID4Dt0YzUCZpoZJk3TmrG/x4XcFtHK\njZI7Z0pSrrZJgfSq9l8ALsGRGSGJb6AU6infORe9xm7m40r/9yoVBnbeP6L5h7ur\nvPCIlWusiQKBgFLk2a97a5MuvBddTUQ4pK4yQBtr/GKvWR2JAOpSaQCU7wbcCk1s\nHkfsVJPimN/ZUDE6jpMowh6BLVKfb+3E9ccgn9OVTLkYa1+YWydOj4PsXht8PVAG\nGKdVdbncC+hoQGTO3kk7NVTu8jUVRAO/JTR/zUWvvE/HCyffViifjZjhAoGANsIx\nj8p/7AsRog82CGigq85TpD5AfuFwVcVsXV8xGhPlMuHRnUSCUFrnXR8BaxdRtikD\nyUL7J+8xr+CoLS2BHyNwuN5dH8BX7ETzVbW3V0gXnv7KOztwcnRZRr+8hnm6KRGi\ngyusVqFbDaMY+QdrkkKTBiaOpsOm2xhnfFViLMkCgYA5rlv3xugTD3gt/O9W+2Vb\nuRvApi4fdC+h1XqU9ywwz70N9x+4xxUY0hBCU23y6O38rHNC7y6K5buvdd1zOQ62\n3maUGjv07ltxlgmpFeRbSb4pqoWsQtWvowRlDPmYpGheD1j6W4pvqx8z2AABK/sz\nMS/7pytLUKmgTFdWX6zMaA==\n-----END PRIVATE KEY-----\n ``` - [ ] **Step 5: Re-run the backend test suite entry point and confirm config still boots** Run: ```bash npm test -- --runInBand ``` Expected: the app boots with the new env/config keys and no schema errors. - [ ] **Step 6: Commit the storage configuration foundation** ```bash git add backend/package.json backend/package-lock.json backend/.env backend/.env.example backend/src/config/env.schema.ts backend/src/config/app.config.ts git commit -m "chore: add gcs storage configuration" ``` ### Task 2: Build The Reusable Storage Module And Unit Tests **Files:** - Create: `backend/src/modules/storage/dto/upload-file.dto.ts` - Create: `backend/src/modules/storage/dto/upload-result.dto.ts` - Create: `backend/src/modules/storage/storage.types.ts` - Create: `backend/src/modules/storage/storage.constants.ts` - Create: `backend/src/modules/storage/storage.driver.ts` - Create: `backend/src/modules/storage/gcs-storage.driver.ts` - Create: `backend/src/modules/storage/storage.service.ts` - Create: `backend/src/modules/storage/storage.module.ts` - Test: `backend/src/modules/storage/storage.service.spec.ts` - Modify: `backend/src/app.module.ts` **Interfaces:** - Produces: `StorageService.uploadBuffer(input: { buffer: Buffer; contentType: string; type: string; originalFilename: string }): Promise` - Produces: `StorageDriver.upload(input: { objectKey: string; buffer: Buffer; contentType: string }): Promise` - Produces: `UploadResultDto = { url: string; objectKey: string; bucket: string; contentType: string; size: number }` - [ ] **Step 1: Write the failing storage service unit test** ```ts // backend/src/modules/storage/storage.service.spec.ts import { BadRequestException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; import { StorageService } from './storage.service'; import { STORAGE_DRIVER } from './storage.module'; 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 = { '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); }); }); ``` - [ ] **Step 2: Run the unit test to verify the module does not exist yet** Run: ```bash npm test -- storage.service.spec.ts --runInBand ``` Expected: FAIL because `storage.service.ts` and related files do not exist yet. - [ ] **Step 3: Create the storage types, constants, and DTOs** ```ts // backend/src/modules/storage/storage.types.ts export type UploadBufferInput = { buffer: Buffer; contentType: string; type: string; originalFilename: string; }; export type DriverUploadInput = { objectKey: string; buffer: Buffer; contentType: string; }; ``` ```ts // backend/src/modules/storage/storage.constants.ts export const STORAGE_TYPE_PATTERN = /^[A-Za-z0-9_-]+$/; export const GCS_PUBLIC_BASE_URL = 'https://storage.googleapis.com'; ``` ```ts // backend/src/modules/storage/dto/upload-file.dto.ts import { IsNotEmpty, Matches } from 'class-validator'; import { STORAGE_TYPE_PATTERN } from '../storage.constants'; export class UploadFileDto { @IsNotEmpty() @Matches(STORAGE_TYPE_PATTERN, { message: '上传类型不合法', }) type!: string; } ``` ```ts // backend/src/modules/storage/dto/upload-result.dto.ts export class UploadResultDto { url!: string; objectKey!: string; bucket!: string; contentType!: string; size!: number; } ``` - [ ] **Step 4: Create the driver abstraction, GCS driver, and service** ```ts // backend/src/modules/storage/storage.driver.ts import { DriverUploadInput } from './storage.types'; export interface StorageDriver { upload(input: DriverUploadInput): Promise; } ``` ```ts // backend/src/modules/storage/gcs-storage.driver.ts import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Storage } from '@google-cloud/storage'; import { StorageDriver } from './storage.driver'; import { DriverUploadInput } from './storage.types'; @Injectable() export class GcsStorageDriver implements StorageDriver { private readonly client: Storage; private readonly bucketName: string; constructor(private readonly configService: ConfigService) { this.bucketName = this.configService.getOrThrow('storage.gcs.bucket'); this.client = new Storage({ projectId: this.configService.getOrThrow('storage.gcs.projectId'), credentials: { client_email: this.configService.getOrThrow('storage.gcs.clientEmail'), private_key: this.configService.getOrThrow('storage.gcs.privateKey'), }, }); } async upload(input: DriverUploadInput): Promise { await this.client.bucket(this.bucketName).file(input.objectKey).save(input.buffer, { contentType: input.contentType, resumable: false, }); } } ``` ```ts // backend/src/modules/storage/storage.service.ts import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { extname } from 'node:path'; import { randomBytes } from 'node:crypto'; import { UploadResultDto } from './dto/upload-result.dto'; import { GCS_PUBLIC_BASE_URL, STORAGE_TYPE_PATTERN } from './storage.constants'; import { StorageDriver } from './storage.driver'; import { UploadBufferInput } from './storage.types'; import { STORAGE_DRIVER } from './storage.module'; @Injectable() export class StorageService { constructor( @Inject(STORAGE_DRIVER) private readonly driver: StorageDriver, private readonly configService: ConfigService, ) {} async uploadBuffer(input: UploadBufferInput): Promise { if (!input.type) { throw new BadRequestException({ code: 'STORAGE_TYPE_REQUIRED', message: '上传类型不能为空', }); } if (!STORAGE_TYPE_PATTERN.test(input.type)) { throw new BadRequestException({ code: 'STORAGE_INVALID_TYPE', message: '上传类型不合法', }); } const extension = extname(input.originalFilename || '').toLowerCase(); if (!extension) { throw new BadRequestException({ code: 'STORAGE_FILE_EXTENSION_REQUIRED', message: '文件扩展名不能为空', }); } const filename = this.createFilename(extension); const objectKey = `stylist/${input.type}/${filename}`; await this.driver.upload({ objectKey, buffer: input.buffer, contentType: input.contentType, }); const bucket = this.configService.getOrThrow('storage.gcs.bucket'); return { url: `${GCS_PUBLIC_BASE_URL}/${bucket}/${objectKey}`, objectKey, bucket, contentType: input.contentType, size: input.buffer.length, }; } private createFilename(extension: string) { const now = new Date(); const timestamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z').replace('T', 'T'); return `${timestamp}_${randomBytes(4).toString('hex')}${extension}`; } } ``` - [ ] **Step 5: Wire the module and app import** ```ts // backend/src/modules/storage/storage.module.ts import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { GcsStorageDriver } from './gcs-storage.driver'; import { StorageController } from './storage.controller'; import { StorageService } from './storage.service'; export const STORAGE_DRIVER = Symbol('STORAGE_DRIVER'); @Module({ controllers: [StorageController], providers: [ StorageService, GcsStorageDriver, { provide: STORAGE_DRIVER, inject: [ConfigService, GcsStorageDriver], useFactory: (configService: ConfigService, gcsStorageDriver: GcsStorageDriver) => { const driver = configService.get('storage.driver'); if (driver !== 'gcs') { throw new Error(`Unsupported storage driver: ${driver}`); } return gcsStorageDriver; }, }, ], exports: [StorageService], }) export class StorageModule {} ``` ```ts // backend/src/app.module.ts import { StorageModule } from './modules/storage/storage.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, cache: true, load: [appConfig], validate: validateEnv, }), PrismaModule, RedisModule, AuthModule, CustomersModule, HealthModule, TemplatesModule, SimulationsModule, StorageModule, ], }) export class AppModule {} ``` - [ ] **Step 6: Re-run the unit tests to confirm the service works** Run: ```bash npm test -- storage.service.spec.ts --runInBand ``` Expected: PASS with one success case and one invalid-type case. - [ ] **Step 7: Commit the reusable storage module** ```bash git add backend/src/app.module.ts backend/src/modules/storage backend/src/modules/storage/storage.service.spec.ts git commit -m "feat: add reusable gcs storage module" ``` ### Task 3: Expose The HTTP Upload Endpoint And e2e Tests **Files:** - Create: `backend/src/modules/storage/storage.controller.ts` - Modify: `backend/test/app.e2e-spec.ts` **Interfaces:** - Produces: `POST /api/v1/storage/upload` - Consumes: multipart field `file` - Consumes: multipart field `type` - Produces: `{ ok: true, data: UploadResultDto, error: null }` - [ ] **Step 1: Write the failing e2e tests for HTTP upload** ```ts // backend/test/app.e2e-spec.ts it('/api/v1/storage/upload (POST) uploads a file and returns a full url', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/storage/upload') .field('type', 'template') .attach('file', Buffer.from('hello-image'), 'look.png') .expect(201); 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 a missing 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: 'STORAGE_FILE_REQUIRED', message: '上传文件不能为空', }, }); }); it('/api/v1/storage/upload (POST) rejects an invalid type', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/storage/upload') .field('type', '../template') .attach('file', Buffer.from('hello-image'), 'look.png') .expect(400); expect(response.body).toEqual({ ok: false, data: null, error: { code: 'STORAGE_INVALID_TYPE', message: '上传类型不合法', }, }); }); ``` - [ ] **Step 2: Run the e2e file and confirm the new route is not wired yet** Run: ```bash npm run test:e2e -- app.e2e-spec.ts ``` Expected: FAIL because `/api/v1/storage/upload` does not exist yet. - [ ] **Step 3: Implement the controller with Multer memory storage** ```ts // backend/src/modules/storage/storage.controller.ts import { BadRequestException, Body, Controller, HttpCode, HttpStatus, Post, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import multer from 'multer'; import { UploadFileDto } from './dto/upload-file.dto'; import { UploadResultDto } from './dto/upload-result.dto'; import { StorageService } from './storage.service'; @Controller('storage') export class StorageController { constructor(private readonly storageService: StorageService) {} @Post('upload') @HttpCode(HttpStatus.CREATED) @UseInterceptors(FileInterceptor('file', { storage: multer.memoryStorage() })) async upload( @UploadedFile() file: Express.Multer.File | undefined, @Body() body: UploadFileDto, ): Promise { if (!file) { throw new BadRequestException({ code: 'STORAGE_FILE_REQUIRED', message: '上传文件不能为空', }); } return this.storageService.uploadBuffer({ buffer: file.buffer, contentType: file.mimetype || 'application/octet-stream', type: body.type, originalFilename: file.originalname, }); } } ``` - [ ] **Step 4: Re-run the e2e file and confirm the new endpoint now passes** Run: ```bash npm run test:e2e -- app.e2e-spec.ts ``` Expected: PASS for the health/auth tests plus the new upload success and failure scenarios. - [ ] **Step 5: Commit the HTTP upload endpoint** ```bash git add backend/src/modules/storage/storage.controller.ts backend/test/app.e2e-spec.ts git commit -m "feat: add storage upload endpoint" ``` ### Task 4: Final Verification And Branch Hygiene **Files:** - Modify: `docs/superpowers/specs/2026-06-28-gcs-upload-storage-design.md` (only if the implementation forces a spec wording adjustment) **Interfaces:** - Consumes: storage module, config, controller, and tests from Tasks 1-3 - Produces: verified implementation ready for the next business case to call - [ ] **Step 1: Run the targeted backend verification commands** Run: ```bash npm run lint npm test -- storage.service.spec.ts auth.service.spec.ts --runInBand npm run test:e2e -- app.e2e-spec.ts auth.e2e-spec.ts ``` Expected: lint passes, unit tests pass, and e2e passes including the upload route. - [ ] **Step 2: Check diagnostics on all changed files** Files to inspect: ```text backend/src/config/env.schema.ts backend/src/config/app.config.ts backend/src/app.module.ts backend/src/modules/storage/dto/upload-file.dto.ts backend/src/modules/storage/dto/upload-result.dto.ts backend/src/modules/storage/storage.types.ts backend/src/modules/storage/storage.constants.ts backend/src/modules/storage/storage.driver.ts backend/src/modules/storage/gcs-storage.driver.ts backend/src/modules/storage/storage.service.ts backend/src/modules/storage/storage.controller.ts backend/src/modules/storage/storage.module.ts backend/src/modules/storage/storage.service.spec.ts backend/test/app.e2e-spec.ts ``` Expected: no newly introduced diagnostics remain unresolved. - [ ] **Step 3: Update the spec only if implementation details changed materially** ```md ## 验收标准 - 服务端返回完整可访问 URL,而不是只返回后半段路径 - `objectKey` 满足 `stylist/{type}/{serverGeneratedFilename}` 规则 - 服务端内部模块可以直接调用统一上传服务 ``` Expected: skip this step entirely if the implementation already matches the existing spec wording. - [ ] **Step 4: Commit the final doc sync only if Step 3 modified the spec** ```bash git add docs/superpowers/specs/2026-06-28-gcs-upload-storage-design.md git commit -m "docs: align gcs upload spec with implementation" ``` - [ ] **Step 5: Confirm the branch is clean and capture the final status** Run: ```bash git status --short git --no-pager log --oneline --decorate -n 5 ``` Expected: `git status --short` is empty and the latest commits cover config, storage module, and upload endpoint work.