2026-06-28-gcs-upload-storage.md 26 KB

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

    // backend/package.json
    {
    "dependencies": {
    "@google-cloud/storage": "^7.13.0"
    }
    }
    
    // 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:

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

    // 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),
    });
    
    // 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

    # 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
    
    # 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:

npm test -- --runInBand

Expected: the app boots with the new env/config keys and no schema errors.

  • [ ] Step 6: Commit the storage configuration foundation

    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<UploadResultDto>
  • Produces: StorageDriver.upload(input: { objectKey: string; buffer: Buffer; contentType: string }): Promise<void>
  • Produces: UploadResultDto = { url: string; objectKey: string; bucket: string; contentType: string; size: number }

  • [ ] Step 1: Write the failing storage service unit test

    // 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<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);
    });
    });
    
  • [ ] Step 2: Run the unit test to verify the module does not exist yet

Run:

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

    // 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;
    };
    
    // 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';
    
    // 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;
    }
    
    // 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

    // backend/src/modules/storage/storage.driver.ts
    import { DriverUploadInput } from './storage.types';
    
    export interface StorageDriver {
    upload(input: DriverUploadInput): Promise<void>;
    }
    
    // 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<string>('storage.gcs.bucket');
    this.client = new Storage({
      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.client.bucket(this.bucketName).file(input.objectKey).save(input.buffer, {
      contentType: input.contentType,
      resumable: false,
    });
    }
    }
    
    // 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<UploadResultDto> {
    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<string>('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

    // 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<string>('storage.driver');
        if (driver !== 'gcs') {
          throw new Error(`Unsupported storage driver: ${driver}`);
        }
        return gcsStorageDriver;
      },
    },
    ],
    exports: [StorageService],
    })
    export class StorageModule {}
    
    // 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:

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

    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

    // 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:

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

    // 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<UploadResultDto> {
    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:

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

    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:

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:

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

    ## 验收标准
    
    - 服务端返回完整可访问 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

    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:

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.