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
type 保持自由输入,但必须经过安全校验,避免路径穿越和命名污染。objectKey 作为辅助信息保留。url,减少访问拼接逻辑和前后端重复认知。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.Files:
backend/package.jsonbackend/.envbackend/.env.examplebackend/src/config/env.schema.tsbackend/src/config/app.config.tsInterfaces:
STORAGE_DRIVER, GCS_BUCKET, GCS_PROJECT_ID, GCS_CLIENT_EMAIL, GCS_PRIVATE_KEYProduces: 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"
Files:
backend/src/modules/storage/dto/upload-file.dto.tsbackend/src/modules/storage/dto/upload-result.dto.tsbackend/src/modules/storage/storage.types.tsbackend/src/modules/storage/storage.constants.tsbackend/src/modules/storage/storage.driver.tsbackend/src/modules/storage/gcs-storage.driver.tsbackend/src/modules/storage/storage.service.tsbackend/src/modules/storage/storage.module.tsbackend/src/modules/storage/storage.service.spec.tsbackend/src/app.module.tsInterfaces:
StorageService.uploadBuffer(input: { buffer: Buffer; contentType: string; type: string; originalFilename: string }): Promise<UploadResultDto>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"
Files:
backend/src/modules/storage/storage.controller.tsbackend/test/app.e2e-spec.tsInterfaces:
POST /api/v1/storage/uploadfiletypeProduces: { 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"
Files:
docs/superpowers/specs/2026-06-28-gcs-upload-storage-design.md (only if the implementation forces a spec wording adjustment)Interfaces:
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.
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.