fix: harden real backend admin workflows and ui

This commit is contained in:
hectorzhao
2026-07-03 19:29:56 +08:00
parent dd09d91c1e
commit 8cca361441
71 changed files with 5111 additions and 4439 deletions
+15 -1
View File
@@ -1,8 +1,16 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { CreateFileObjectDto, CreatePresignedUploadDto, FilesService } from './files.service';
type UploadedMultipartFile = {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
};
@ApiTags('files')
@Controller('admin/files')
export class FilesController {
@@ -22,4 +30,10 @@ export class FilesController {
createPresignedUpload(@Body() body: CreatePresignedUploadDto) {
return this.files.createPresignedUpload(body);
}
@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) {
return this.files.upload({ tenantId, purpose: purpose || 'general', prefix }, file);
}
}
+50
View File
@@ -0,0 +1,50 @@
import { FilesService } from './files.service';
describe('FilesService', () => {
it('uploads file content to object storage before creating FileObject metadata', async () => {
const prisma = {
fileObject: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'file-1', ...data })),
},
};
const objectStorage = {
getBucket: jest.fn().mockReturnValue('cmpp-platform'),
putObject: jest.fn().mockResolvedValue({ etag: 'etag-1' }),
presignedPutObject: jest.fn(),
};
const service = new FilesService(prisma as never, objectStorage as never);
const file = {
originalname: '营业执照.png',
mimetype: 'image/png',
size: 12,
buffer: Buffer.from('file-content'),
};
await expect(service.upload({ tenantId: 'tenant-1', purpose: 'signature_material', prefix: 'signature-materials/sig-1' }, file))
.resolves.toEqual(expect.objectContaining({
id: 'file-1',
tenantId: 'tenant-1',
bucket: 'cmpp-platform',
fileName: '营业执照.png',
contentType: 'image/png',
purpose: 'signature_material',
}));
expect(objectStorage.putObject).toHaveBeenCalledWith(
expect.stringMatching(/^signature-materials\/sig-1\/\d+-[a-f0-9-]+-营业执照\.png$/),
file.buffer,
12,
'image/png',
);
expect(prisma.fileObject.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
bucket: 'cmpp-platform',
fileName: '营业执照.png',
contentType: 'image/png',
sizeBytes: BigInt(12),
purpose: 'signature_material',
}),
});
});
});
+22
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { ObjectStorageService } from './object-storage.service';
@@ -19,6 +20,12 @@ export interface CreatePresignedUploadDto {
expiresInSeconds?: number;
}
export interface UploadFileDto {
tenantId?: string;
purpose: string;
prefix?: string;
}
@Injectable()
export class FilesService {
constructor(
@@ -57,4 +64,19 @@ export class FilesService {
expiresInSeconds: data.expiresInSeconds ?? 3600,
};
}
async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
const safeName = file.originalname.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`;
await this.objectStorage.putObject(objectKey, file.buffer, file.size, file.mimetype || 'application/octet-stream');
return this.create({
tenantId: data.tenantId,
bucket: this.objectStorage.getBucket(),
objectKey,
fileName: file.originalname,
contentType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size,
purpose: data.purpose,
});
}
}
+6
View File
@@ -24,6 +24,12 @@ export class ObjectStorageService {
return this.client.presignedPutObject(this.bucket, objectKey, expirySeconds);
}
putObject(objectKey: string, content: Buffer, sizeBytes: number, contentType: string) {
return this.client.putObject(this.bucket, objectKey, content, sizeBytes, {
'Content-Type': contentType,
});
}
getBucket() {
return this.bucket;
}