fix: harden real backend workflows and channel connections

This commit is contained in:
hectorzhao
2026-07-06 17:54:53 +08:00
parent 8cca361441
commit b5132d7f4e
47 changed files with 2530 additions and 314 deletions
+20 -1
View File
@@ -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);
}
}
+36
View 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');
});
});
+22 -3
View File
@@ -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(),
};
}
+44 -2
View File
@@ -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;
}
}