fix: harden real backend workflows and channel connections
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
@@ -11,6 +11,11 @@ type UploadedMultipartFile = {
|
||||
buffer: Buffer;
|
||||
};
|
||||
|
||||
type DownloadResponse = {
|
||||
setHeader(name: string, value: number | string): void;
|
||||
send(content: Buffer): void;
|
||||
};
|
||||
|
||||
@ApiTags('files')
|
||||
@Controller('admin/files')
|
||||
export class FilesController {
|
||||
@@ -21,6 +26,17 @@ export class FilesController {
|
||||
return this.files.list(tenantId);
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
async download(@Param('id') id: string, @Query('disposition') disposition: string | undefined, @Res() response: DownloadResponse) {
|
||||
const { fileObject, content } = await this.files.getDownload(id);
|
||||
const mode = disposition === 'inline' ? 'inline' : 'attachment';
|
||||
const encodedName = encodeURIComponent(fileObject.fileName);
|
||||
response.setHeader('Content-Type', fileObject.contentType || 'application/octet-stream');
|
||||
response.setHeader('Content-Length', content.length);
|
||||
response.setHeader('Content-Disposition', `${mode}; filename*=UTF-8''${encodedName}`);
|
||||
response.send(content);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: CreateFileObjectDto) {
|
||||
return this.files.create(body);
|
||||
@@ -34,6 +50,9 @@ export class FilesController {
|
||||
@Post('upload')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 20 * 1024 * 1024 } }))
|
||||
upload(@UploadedFile() file: UploadedMultipartFile, @Body('purpose') purpose: string, @Body('prefix') prefix?: string, @TenantId() tenantId?: string) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Upload file is required');
|
||||
}
|
||||
return this.files.upload({ tenantId, purpose: purpose || 'general', prefix }, file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('FilesService', () => {
|
||||
bucket: 'cmpp-platform',
|
||||
fileName: '营业执照.png',
|
||||
contentType: 'image/png',
|
||||
sizeBytes: '12',
|
||||
purpose: 'signature_material',
|
||||
}));
|
||||
|
||||
@@ -47,4 +48,39 @@ describe('FilesService', () => {
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('downloads file content from object storage by FileObject id', async () => {
|
||||
const fileObject = {
|
||||
id: 'file-1',
|
||||
tenantId: 'tenant-1',
|
||||
bucket: 'cmpp-platform',
|
||||
objectKey: 'signature-materials/sig-1/file.png',
|
||||
fileName: 'file.png',
|
||||
contentType: 'image/png',
|
||||
sizeBytes: BigInt(12),
|
||||
checksum: null,
|
||||
purpose: 'signature_material',
|
||||
createdAt: new Date('2026-07-06T00:00:00.000Z'),
|
||||
};
|
||||
const prisma = {
|
||||
fileObject: {
|
||||
findUnique: jest.fn().mockResolvedValue(fileObject),
|
||||
},
|
||||
};
|
||||
const objectStorage = {
|
||||
getObject: jest.fn().mockResolvedValue(Buffer.from('file-content')),
|
||||
};
|
||||
const service = new FilesService(prisma as never, objectStorage as never);
|
||||
|
||||
await expect(service.getDownload('file-1')).resolves.toEqual({
|
||||
fileObject: expect.objectContaining({
|
||||
id: 'file-1',
|
||||
fileName: 'file.png',
|
||||
sizeBytes: '12',
|
||||
}),
|
||||
content: Buffer.from('file-content'),
|
||||
});
|
||||
expect(prisma.fileObject.findUnique).toHaveBeenCalledWith({ where: { id: 'file-1' } });
|
||||
expect(objectStorage.getObject).toHaveBeenCalledWith('signature-materials/sig-1/file.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -38,7 +38,7 @@ export class FilesService {
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}).then((items) => items.map(serializeFileObject));
|
||||
}
|
||||
|
||||
create(data: CreateFileObjectDto) {
|
||||
@@ -52,7 +52,7 @@ export class FilesService {
|
||||
checksum: data.checksum,
|
||||
purpose: data.purpose,
|
||||
};
|
||||
return this.prisma.fileObject.create({ data: createData });
|
||||
return this.prisma.fileObject.create({ data: createData }).then(serializeFileObject);
|
||||
}
|
||||
|
||||
async createPresignedUpload(data: CreatePresignedUploadDto) {
|
||||
@@ -79,4 +79,23 @@ export class FilesService {
|
||||
purpose: data.purpose,
|
||||
});
|
||||
}
|
||||
|
||||
async getDownload(id: string) {
|
||||
const fileObject = await this.prisma.fileObject.findUnique({ where: { id } });
|
||||
if (!fileObject) {
|
||||
throw new NotFoundException('File object not found');
|
||||
}
|
||||
const content = await this.objectStorage.getObject(fileObject.objectKey);
|
||||
return {
|
||||
fileObject: serializeFileObject(fileObject),
|
||||
content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function serializeFileObject<T extends { sizeBytes: bigint | number | string }>(fileObject: T) {
|
||||
return {
|
||||
...fileObject,
|
||||
sizeBytes: fileObject.sizeBytes.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Client } from 'minio';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectStorageService {
|
||||
private readonly client: Client;
|
||||
private readonly bucket: string;
|
||||
private readonly driver: string;
|
||||
private readonly localRoot: string;
|
||||
private bucketReady = false;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
const endpoint = config.get<string>('MINIO_ENDPOINT') ?? 'localhost:9000';
|
||||
const [endPoint, portText] = endpoint.split(':');
|
||||
this.bucket = config.get<string>('MINIO_BUCKET') ?? 'cmpp-platform';
|
||||
this.driver = config.get<string>('OBJECT_STORAGE_DRIVER') ?? 'minio';
|
||||
this.localRoot = config.get<string>('OBJECT_STORAGE_LOCAL_ROOT') ?? join(process.cwd(), '..', '.local-data', 'object-storage');
|
||||
this.client = new Client({
|
||||
endPoint,
|
||||
port: Number(portText ?? 9000),
|
||||
@@ -20,17 +27,52 @@ export class ObjectStorageService {
|
||||
});
|
||||
}
|
||||
|
||||
presignedPutObject(objectKey: string, expirySeconds = 3600) {
|
||||
async presignedPutObject(objectKey: string, expirySeconds = 3600) {
|
||||
if (this.driver === 'local') {
|
||||
return Promise.resolve(`local://${this.bucket}/${objectKey}?expires=${expirySeconds}`);
|
||||
}
|
||||
await this.ensureBucket();
|
||||
return this.client.presignedPutObject(this.bucket, objectKey, expirySeconds);
|
||||
}
|
||||
|
||||
putObject(objectKey: string, content: Buffer, sizeBytes: number, contentType: string) {
|
||||
async putObject(objectKey: string, content: Buffer, sizeBytes: number, contentType: string) {
|
||||
if (this.driver === 'local') {
|
||||
const filePath = join(this.localRoot, this.bucket, ...objectKey.split('/'));
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, content);
|
||||
return { etag: `local-${sizeBytes}-${contentType}` };
|
||||
}
|
||||
await this.ensureBucket();
|
||||
return this.client.putObject(this.bucket, objectKey, content, sizeBytes, {
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
}
|
||||
|
||||
async getObject(objectKey: string) {
|
||||
if (this.driver === 'local') {
|
||||
return readFile(join(this.localRoot, this.bucket, ...objectKey.split('/')));
|
||||
}
|
||||
await this.ensureBucket();
|
||||
const stream = await this.client.getObject(this.bucket, objectKey);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
getBucket() {
|
||||
return this.bucket;
|
||||
}
|
||||
|
||||
private async ensureBucket() {
|
||||
if (this.bucketReady) {
|
||||
return;
|
||||
}
|
||||
const exists = await this.client.bucketExists(this.bucket);
|
||||
if (!exists) {
|
||||
await this.client.makeBucket(this.bucket, '');
|
||||
}
|
||||
this.bucketReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user