perf(upload): replace MinIO presigned-URL flow with local-disk storage

Previously the APK/IPA upload required two full public-internet transfers:
  1. POST /parse  → browser → gateway → admin-service (full file, for metadata)
  2. PUT presigned → browser → oss.gogenex.com / MinIO    (full file, to store)

Now follows the same pattern as RWADurian admin-service:
  - Single multipart POST /admin/versions/upload
  - admin-service parses buffer in-memory (yauzl / unzipper)
  - Saves to local disk (UPLOAD_DIR env, default ./uploads)
  - Download served via existing GET /app/version/download/:id (streams local file)

Changes:
  - file-storage.service.ts: drop minio dep, use fs/promises + crypto
  - admin-version.controller.ts: POST upload now accepts multipart file,
    removes GET presigned-url endpoint (no longer needed)
  - version.repository.ts (frontend): single FormData POST, removes
    three-step presigned-URL flow

Result: file crosses public internet once instead of twice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-07 05:49:36 -08:00
parent 839df343a4
commit 7933f3fe4a
3 changed files with 59 additions and 96 deletions

View File

@ -1,48 +1,40 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as path from 'path';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { Client as MinioClient } from 'minio';
const BUCKET = 'app-releases';
const PRESIGN_EXPIRY = 3600; // 1 hour
@Injectable() @Injectable()
export class FileStorageService { export class FileStorageService {
private readonly logger = new Logger(FileStorageService.name); private readonly logger = new Logger(FileStorageService.name);
private readonly minio: MinioClient; private readonly uploadDir: string;
private readonly ossBase: string;
constructor() { constructor() {
this.minio = new MinioClient({ this.uploadDir = process.env.UPLOAD_DIR || './uploads';
endPoint: process.env.MINIO_ENDPOINT || 'oss.gogenex.com',
port: parseInt(process.env.MINIO_PORT || '443', 10),
useSSL: (process.env.MINIO_USE_SSL ?? 'true') === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || 'genex-admin',
secretKey: process.env.MINIO_SECRET_KEY || 'genex-minio-secret',
});
this.ossBase = process.env.OSS_BASE_URL || 'https://oss.gogenex.com';
} }
/** Generate object name for a new upload */ /** Generate a unique filename for a new upload */
generateObjectName(originalName: string, platform: string, versionName: string): string { generateObjectName(originalName: string, platform: string, versionName: string): string {
const ext = originalName.split('.').pop() || 'bin'; const ext = originalName.split('.').pop() || 'bin';
const timestamp = Date.now(); const timestamp = Date.now();
const random = crypto.randomBytes(4).toString('hex'); const random = crypto.randomBytes(4).toString('hex');
return `${platform}/${versionName}/${timestamp}-${random}.${ext}`; return `${platform}-${versionName}-${timestamp}-${random}.${ext}`;
} }
/** Generate a presigned PUT URL so the browser can upload directly to MinIO */ /** Save file buffer to local disk, return size and sha256 */
async presignedPutUrl(objectName: string): Promise<string> { async saveFile(buffer: Buffer, filename: string): Promise<{ size: number; sha256: string }> {
return this.minio.presignedPutObject(BUCKET, objectName, PRESIGN_EXPIRY); await fs.mkdir(this.uploadDir, { recursive: true });
const filePath = path.join(this.uploadDir, filename);
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
await fs.writeFile(filePath, buffer);
this.logger.log(`Saved file: ${filename} (${buffer.length} bytes, sha256: ${sha256})`);
return { size: buffer.length, sha256 };
} }
/** Public download URL for an object */ /** Stream local file to a writable (e.g. Express Response) */
downloadUrl(objectName: string): string { async streamFile(filename: string, destination: NodeJS.WritableStream): Promise<void> {
return `${this.ossBase}/app-releases/${objectName}`; const filePath = path.join(this.uploadDir, path.basename(filename));
} const stream = fsSync.createReadStream(filePath);
/** Stream file from MinIO to a writable (e.g. Express Response) */
async streamFile(objectName: string, destination: NodeJS.WritableStream): Promise<void> {
const stream = await this.minio.getObject(BUCKET, objectName);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
stream.pipe(destination as any); stream.pipe(destination as any);
stream.on('end', resolve); stream.on('end', resolve);
@ -50,12 +42,14 @@ export class FileStorageService {
}); });
} }
/** Get file metadata (size) from MinIO */ /** Get file size and content-type from local disk */
async statFile(objectName: string): Promise<{ size: number; contentType: string }> { async statFile(filename: string): Promise<{ size: number; contentType: string }> {
const stat = await this.minio.statObject(BUCKET, objectName); const filePath = path.join(this.uploadDir, path.basename(filename));
return { const stat = await fs.stat(filePath);
size: stat.size, const ext = path.extname(filename).toLowerCase();
contentType: (stat.metaData as any)?.['content-type'] || 'application/octet-stream', const contentType = ext === '.apk'
}; ? 'application/vnd.android.package-archive'
: 'application/octet-stream';
return { size: stat.size, contentType };
} }
} }

View File

@ -82,59 +82,51 @@ export class AdminVersionController {
return { code: 0, data: version }; return { code: 0, data: version };
} }
@Get('presigned-url')
@ApiOperation({ summary: 'Get presigned PUT URL for direct browser-to-MinIO upload' })
async getPresignedUrl(
@Query('filename') filename: string,
@Query('platform') platform: string,
@Query('versionName') versionName: string,
) {
const objectName = this.fileStorage.generateObjectName(filename, platform.toUpperCase(), versionName);
const uploadUrl = await this.fileStorage.presignedPutUrl(objectName);
return {
code: 0,
data: { uploadUrl, objectName, downloadUrl: this.fileStorage.downloadUrl(objectName) },
};
}
@Post('upload') @Post('upload')
@ApiOperation({ summary: 'Save version metadata after direct MinIO upload' }) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 500 * 1024 * 1024 } }))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload APK/IPA — parse + save in one request' })
async uploadVersion( async uploadVersion(
@UploadedFile() file: Express.Multer.File,
@Body() body: { @Body() body: {
appType?: string; appType?: string;
platform: string; platform?: string;
versionCode?: string;
versionName?: string; versionName?: string;
buildNumber?: string; buildNumber?: string;
changelog?: string; changelog?: string;
isForceUpdate?: string; isForceUpdate?: string;
minOsVersion?: string; minOsVersion?: string;
releaseDate?: string; releaseDate?: string;
objectName: string;
fileSize: string;
fileSha256?: string;
}, },
@Req() req: any, @Req() req: any,
) { ) {
if (!file) return { code: 400, message: 'No file uploaded' };
const info = await this.packageParser.parse(file.buffer, file.originalname);
const appType: AppType = (body.appType || 'GENEX_MOBILE').toUpperCase() as AppType; const appType: AppType = (body.appType || 'GENEX_MOBILE').toUpperCase() as AppType;
const platform: Platform = body.platform.toUpperCase() as Platform; const platform: Platform = (
const versionCode = body.versionCode ? parseInt(body.versionCode, 10) : 1; body.platform || (file.originalname.toLowerCase().endsWith('.ipa') ? 'IOS' : 'ANDROID')
const versionName = body.versionName || '1.0.0'; ).toUpperCase() as Platform;
const versionCode = info.versionCode || 0;
const versionName = info.versionName || body.versionName || '0.0.0';
const buildNumber = body.buildNumber || versionCode.toString(); const buildNumber = body.buildNumber || versionCode.toString();
const filename = this.fileStorage.generateObjectName(file.originalname, platform, versionName);
const saved = await this.fileStorage.saveFile(file.buffer, filename);
const version = await this.versionService.createVersion({ const version = await this.versionService.createVersion({
appType, appType,
platform, platform,
versionCode, versionCode,
versionName, versionName,
buildNumber, buildNumber,
storageKey: body.objectName, storageKey: filename,
downloadUrl: this.fileStorage.downloadUrl(body.objectName), downloadUrl: `/api/v1/app/version/download/`,
fileSize: body.fileSize, fileSize: String(saved.size),
fileSha256: body.fileSha256 || '', fileSha256: saved.sha256,
changelog: body.changelog || '', changelog: body.changelog || '',
isForceUpdate: body.isForceUpdate === 'true', isForceUpdate: body.isForceUpdate === 'true',
minOsVersion: body.minOsVersion, minOsVersion: info.minSdkVersion || body.minOsVersion,
releaseDate: body.releaseDate ? new Date(body.releaseDate) : undefined, releaseDate: body.releaseDate ? new Date(body.releaseDate) : undefined,
createdBy: req.user?.sub, createdBy: req.user?.sub,
}); });

View File

@ -21,39 +21,16 @@ class VersionRepository implements IVersionRepository {
} }
async upload(input: UploadVersionInput): Promise<AppVersion> { async upload(input: UploadVersionInput): Promise<AppVersion> {
// Step 1: get presigned PUT URL from admin-service const fd = new FormData();
const { uploadUrl, objectName, downloadUrl } = await httpClient.get<{ fd.append('file', input.file);
uploadUrl: string; fd.append('appType', input.appType);
objectName: string; fd.append('platform', input.platform);
downloadUrl: string; fd.append('versionName', input.versionName || '');
}>('/api/v1/admin/versions/presigned-url', { fd.append('buildNumber', input.buildNumber || '');
params: { fd.append('changelog', input.changelog || '');
filename: input.file.name, fd.append('minOsVersion', input.minOsVersion || '');
platform: input.platform, fd.append('isForceUpdate', String(input.isForceUpdate));
versionName: input.versionName || '1.0.0', return httpClient.post<AppVersion>('/api/v1/admin/versions/upload', fd, { timeout: 300000 });
},
});
// Step 2: PUT file directly to MinIO (browser → MinIO, no admin-service relay)
const putRes = await fetch(uploadUrl, {
method: 'PUT',
body: input.file,
headers: { 'Content-Type': input.file.type || 'application/octet-stream' },
});
if (!putRes.ok) throw new Error(`MinIO upload failed: ${putRes.status}`);
// Step 3: save metadata to admin-service
return httpClient.post<AppVersion>('/api/v1/admin/versions/upload', {
appType: input.appType,
platform: input.platform,
versionName: input.versionName,
buildNumber: input.buildNumber,
changelog: input.changelog,
minOsVersion: input.minOsVersion,
isForceUpdate: String(input.isForceUpdate),
objectName,
fileSize: String(input.file.size),
});
} }
async update(id: string, input: UpdateVersionInput): Promise<AppVersion> { async update(id: string, input: UpdateVersionInput): Promise<AppVersion> {