diff --git a/apps/api/src/common/storage/storage.constants.ts b/apps/api/src/common/storage/storage.constants.ts new file mode 100644 index 0000000..ff09be4 --- /dev/null +++ b/apps/api/src/common/storage/storage.constants.ts @@ -0,0 +1,13 @@ +// 允许的图片 MIME 类型 +export const ALLOWED_IMAGE_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', +]; + +// 最大文件大小(2MB) +export const MAX_FILE_SIZE = 2 * 1024 * 1024; + +// 头像存储路径前缀 +export const AVATAR_PREFIX = 'avatars'; diff --git a/apps/api/src/common/storage/storage.module.ts b/apps/api/src/common/storage/storage.module.ts new file mode 100644 index 0000000..f7503cf --- /dev/null +++ b/apps/api/src/common/storage/storage.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; + +import { StorageService } from './storage.service'; + +@Global() +@Module({ + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/apps/api/src/common/storage/storage.service.ts b/apps/api/src/common/storage/storage.service.ts new file mode 100644 index 0000000..685dc4f --- /dev/null +++ b/apps/api/src/common/storage/storage.service.ts @@ -0,0 +1,68 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as Minio from 'minio'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class StorageService implements OnModuleInit { + private readonly logger = new Logger(StorageService.name); + private client: Minio.Client; + private bucket: string; + + constructor(private configService: ConfigService) { + this.client = new Minio.Client({ + endPoint: this.configService.get('MINIO_ENDPOINT', 'localhost'), + port: parseInt(this.configService.get('MINIO_PORT', '9000'), 10), + useSSL: this.configService.get('MINIO_USE_SSL', 'false') === 'true', + accessKey: this.configService.get('MINIO_ACCESS_KEY', 'minioadmin'), + secretKey: this.configService.get('MINIO_SECRET_KEY', 'minioadmin'), + }); + this.bucket = this.configService.get('MINIO_BUCKET', 'seclusion'); + } + + async onModuleInit() { + try { + const exists = await this.client.bucketExists(this.bucket); + if (!exists) { + await this.client.makeBucket(this.bucket); + this.logger.log(`Bucket "${this.bucket}" created`); + } + } catch (error) { + this.logger.error('Failed to initialize MinIO bucket', error); + } + } + + /** + * 上传文件 + * @returns objectName(MinIO 中的对象名) + */ + async uploadFile( + file: Express.Multer.File, + prefix: string, + ): Promise { + const ext = file.originalname.split('.').pop() || 'bin'; + const objectName = `${prefix}/${uuidv4()}.${ext}`; + + await this.client.putObject(this.bucket, objectName, file.buffer, file.size, { + 'Content-Type': file.mimetype, + }); + + return objectName; + } + + /** + * 删除文件 + */ + async deleteFile(objectName: string): Promise { + await this.client.removeObject(this.bucket, objectName); + } + + /** + * 生成临时访问 URL(presigned URL) + * @param objectName MinIO 中的对象名 + * @param expiresIn 有效期(秒) + */ + async getPresignedUrl(objectName: string, expiresIn: number): Promise { + return this.client.presignedGetObject(this.bucket, objectName, expiresIn); + } +} diff --git a/apps/api/src/file/dto/file.dto.ts b/apps/api/src/file/dto/file.dto.ts new file mode 100644 index 0000000..804aede --- /dev/null +++ b/apps/api/src/file/dto/file.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsInt, Min, Max } from 'class-validator'; + +import { FilePurpose } from '../file.constants'; + +/** 上传文件请求 DTO */ +export class UploadFileDto { + @ApiProperty({ + enum: Object.values(FilePurpose), + example: 'avatar', + description: '文件用途', + }) + @IsEnum(FilePurpose) + purpose: FilePurpose; +} + +/** 文件响应 DTO */ +export class FileResponseDto { + @ApiProperty({ example: 'clxxx123', description: '文件 ID' }) + id: string; + + @ApiProperty({ example: 'avatar.jpg', description: '原始文件名' }) + filename: string; + + @ApiProperty({ example: 'image/jpeg', description: 'MIME 类型' }) + mimeType: string; + + @ApiProperty({ example: 102400, description: '文件大小(字节)' }) + size: number; + + @ApiProperty({ example: 'avatar', description: '文件用途' }) + purpose: string; + + @ApiProperty({ example: '2026-01-19T10:00:00.000Z', description: '创建时间' }) + createdAt: string; +} + +/** 获取临时 URL 请求 DTO */ +export class GetPresignedUrlDto { + @ApiPropertyOptional({ + example: 300, + description: '有效期(秒),默认 300,最大 3600', + }) + @IsOptional() + @IsInt() + @Min(60) + @Max(3600) + expiresIn?: number; +} + +/** 临时 URL 响应 DTO */ +export class PresignedUrlResponseDto { + @ApiProperty({ + example: 'http://localhost:9000/seclusion/avatars/xxx.jpg?X-Amz-...', + description: '临时访问 URL', + }) + url: string; + + @ApiProperty({ example: 300, description: '有效期(秒)' }) + expiresIn: number; +} diff --git a/apps/api/src/file/file.constants.ts b/apps/api/src/file/file.constants.ts new file mode 100644 index 0000000..b388ee3 --- /dev/null +++ b/apps/api/src/file/file.constants.ts @@ -0,0 +1,9 @@ +// 文件用途 +export const FilePurpose = { + AVATAR: 'avatar', + ATTACHMENT: 'attachment', +} as const; +export type FilePurpose = (typeof FilePurpose)[keyof typeof FilePurpose]; + +// 临时 URL 默认有效期(秒) +export const DEFAULT_PRESIGNED_URL_EXPIRES = 5 * 60; // 5 分钟 diff --git a/apps/api/src/file/file.controller.ts b/apps/api/src/file/file.controller.ts new file mode 100644 index 0000000..bdfd554 --- /dev/null +++ b/apps/api/src/file/file.controller.ts @@ -0,0 +1,126 @@ +import { + Controller, + Post, + Get, + Delete, + Param, + Query, + Body, + UseGuards, + UseInterceptors, + UploadedFile, + BadRequestException, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiOkResponse, + ApiCreatedResponse, + ApiConsumes, + ApiBody, +} from '@nestjs/swagger'; + +import { + UploadFileDto, + FileResponseDto, + GetPresignedUrlDto, + PresignedUrlResponseDto, +} from './dto/file.dto'; +import { FileService } from './file.service'; + +import { CurrentUser } from '@/auth/decorators/current-user.decorator'; +import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard'; +import { SkipEncryption } from '@/common/crypto/decorators/skip-encryption.decorator'; + +interface AuthUser { + id: string; + email: string; +} + +@ApiTags('文件') +@Controller('files') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class FileController { + constructor(private readonly fileService: FileService) {} + + @Post('upload') + @SkipEncryption() + @UseInterceptors(FileInterceptor('file')) + @ApiOperation({ summary: '上传文件' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + description: '文件', + }, + purpose: { + type: 'string', + enum: ['avatar', 'attachment'], + description: '文件用途', + }, + }, + required: ['file', 'purpose'], + }, + }) + @ApiCreatedResponse({ type: FileResponseDto, description: '上传成功' }) + async upload( + @UploadedFile() file: Express.Multer.File, + @Body() dto: UploadFileDto, + @CurrentUser() user: AuthUser, + ): Promise { + if (!file) { + throw new BadRequestException('请选择要上传的文件'); + } + + const result = await this.fileService.upload(file, user.id, dto.purpose); + + return { + id: result.id, + filename: result.filename, + mimeType: result.mimeType, + size: result.size, + purpose: result.purpose, + createdAt: result.createdAt.toISOString(), + }; + } + + @Get(':id') + @ApiOperation({ summary: '获取文件信息' }) + @ApiOkResponse({ type: FileResponseDto, description: '文件信息' }) + async findById(@Param('id') id: string): Promise { + const file = await this.fileService.findById(id); + return { + id: file.id, + filename: file.filename, + mimeType: file.mimeType, + size: file.size, + purpose: file.purpose, + createdAt: file.createdAt.toISOString(), + }; + } + + @Get(':id/url') + @ApiOperation({ summary: '获取文件临时访问 URL' }) + @ApiOkResponse({ type: PresignedUrlResponseDto, description: '临时访问 URL' }) + async getPresignedUrl( + @Param('id') id: string, + @Query() query: GetPresignedUrlDto, + ): Promise { + return this.fileService.getPresignedUrl(id, query.expiresIn); + } + + @Delete(':id') + @ApiOperation({ summary: '删除文件' }) + @ApiOkResponse({ description: '删除成功' }) + async delete(@Param('id') id: string): Promise<{ message: string }> { + await this.fileService.delete(id); + return { message: '删除成功' }; + } +} diff --git a/apps/api/src/file/file.module.ts b/apps/api/src/file/file.module.ts new file mode 100644 index 0000000..ed0ce47 --- /dev/null +++ b/apps/api/src/file/file.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { FileController } from './file.controller'; +import { FileService } from './file.service'; + +@Module({ + controllers: [FileController], + providers: [FileService], + exports: [FileService], +}) +export class FileModule {} diff --git a/apps/api/src/file/file.service.ts b/apps/api/src/file/file.service.ts new file mode 100644 index 0000000..89907c8 --- /dev/null +++ b/apps/api/src/file/file.service.ts @@ -0,0 +1,84 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import type { File, Prisma } from '@prisma/client'; + +import { DEFAULT_PRESIGNED_URL_EXPIRES, FilePurpose } from './file.constants'; + +import { CrudService } from '@/common/crud/crud.service'; +import { ALLOWED_IMAGE_TYPES, MAX_FILE_SIZE } from '@/common/storage/storage.constants'; +import { StorageService } from '@/common/storage/storage.service'; +import { PrismaService } from '@/prisma/prisma.service'; + +@Injectable() +export class FileService extends CrudService< + File, + Prisma.FileCreateInput, + Prisma.FileUpdateInput, + Prisma.FileWhereInput, + Prisma.FileWhereUniqueInput +> { + constructor( + prisma: PrismaService, + private storageService: StorageService, + ) { + super(prisma, 'file'); + } + + /** + * 上传文件 + */ + async upload( + file: Express.Multer.File, + uploaderId: string, + purpose: FilePurpose, + ): Promise { + // 验证文件类型(头像场景) + if (purpose === FilePurpose.AVATAR) { + if (!ALLOWED_IMAGE_TYPES.includes(file.mimetype)) { + throw new BadRequestException('不支持的文件类型,仅支持 JPEG、PNG、GIF、WebP'); + } + if (file.size > MAX_FILE_SIZE) { + throw new BadRequestException('文件大小不能超过 2MB'); + } + } + + // 上传到 MinIO + const objectName = await this.storageService.uploadFile(file, purpose); + + // 保存文件记录 + return this.model.create({ + data: { + filename: file.originalname, + objectName, + mimeType: file.mimetype, + size: file.size, + purpose, + uploader: { connect: { id: uploaderId } }, + }, + }); + } + + /** + * 获取临时访问 URL + */ + async getPresignedUrl( + id: string, + expiresIn: number = DEFAULT_PRESIGNED_URL_EXPIRES, + ): Promise<{ url: string; expiresIn: number }> { + const file = await this.findById(id); + const url = await this.storageService.getPresignedUrl(file.objectName, expiresIn); + return { url, expiresIn }; + } + + /** + * 删除文件(覆盖父类方法,先删除 MinIO 文件) + */ + override async delete(id: string): Promise<{ message: string }> { + const file = await this.findById(id); + await this.storageService.deleteFile(file.objectName); + return super.delete(id); + } + + protected override getNotFoundMessage(id: string): string { + return `文件不存在: ${id}`; + } +} diff --git a/apps/web/src/components/forms/AvatarUpload.tsx b/apps/web/src/components/forms/AvatarUpload.tsx new file mode 100644 index 0000000..33201e6 --- /dev/null +++ b/apps/web/src/components/forms/AvatarUpload.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { Camera, Loader2 } from 'lucide-react'; +import { useRef } from 'react'; +import { toast } from 'sonner'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { useFileUrl } from '@/hooks/useFileUrl'; +import { useUploadAvatar } from '@/hooks/useUploadAvatar'; +import { useAuthStore } from '@/stores'; + +// 允许的图片类型 +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; +// 最大文件大小 2MB +const MAX_SIZE = 2 * 1024 * 1024; + +export function AvatarUpload() { + const inputRef = useRef(null); + const { user } = useAuthStore(); + const { mutate: upload, isPending } = useUploadAvatar(); + const { data: avatarData } = useFileUrl(user?.avatarId); + + const handleClick = () => { + inputRef.current?.click(); + }; + + const handleChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // 验证文件类型 + if (!ALLOWED_TYPES.includes(file.type)) { + toast.error('不支持的文件类型,仅支持 JPEG、PNG、GIF、WebP'); + return; + } + + // 验证文件大小 + if (file.size > MAX_SIZE) { + toast.error('文件大小不能超过 2MB'); + return; + } + + upload(file, { + onSuccess: () => { + toast.success('头像上传成功'); + }, + onError: (error) => { + toast.error(error.message || '头像上传失败'); + }, + }); + + // 清空 input 以便重复选择同一文件 + e.target.value = ''; + }; + + // 获取用户名首字母作为头像 + const initials = user?.name + ? user.name.slice(0, 2).toUpperCase() + : user?.email?.slice(0, 2).toUpperCase() || '?'; + + return ( +
+
+ + + + {initials} + + + {isPending && ( +
+ +
+ )} +
+ + +

+ 支持 JPEG、PNG、GIF、WebP,最大 2MB +

+
+ ); +} diff --git a/apps/web/src/hooks/useFileUrl.ts b/apps/web/src/hooks/useFileUrl.ts new file mode 100644 index 0000000..850a16f --- /dev/null +++ b/apps/web/src/hooks/useFileUrl.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getFileUrl } from '@/services/file.service'; + +/** + * 获取文件临时访问 URL 的 Hook + */ +export function useFileUrl(fileId: string | null | undefined) { + return useQuery({ + queryKey: ['file-url', fileId], + queryFn: () => getFileUrl(fileId!), + enabled: !!fileId, + // URL 有效期 5 分钟,提前 1 分钟刷新 + staleTime: 4 * 60 * 1000, + refetchInterval: 4 * 60 * 1000, + }); +} diff --git a/apps/web/src/hooks/useUploadAvatar.ts b/apps/web/src/hooks/useUploadAvatar.ts new file mode 100644 index 0000000..ca30ee3 --- /dev/null +++ b/apps/web/src/hooks/useUploadAvatar.ts @@ -0,0 +1,23 @@ +import { useMutation } from '@tanstack/react-query'; + +import { uploadFile } from '@/services/file.service'; +import { useAuthStore } from '@/stores'; + +export function useUploadAvatar() { + const { user, setUser } = useAuthStore(); + + return useMutation({ + mutationFn: async (file: File) => { + if (!user) { + throw new Error('用户未登录'); + } + return uploadFile(file, 'avatar'); + }, + onSuccess: (data) => { + // 更新 store 中的用户头像 ID + if (user) { + setUser({ ...user, avatarId: data.id }); + } + }, + }); +} diff --git a/apps/web/src/services/file.service.ts b/apps/web/src/services/file.service.ts new file mode 100644 index 0000000..facd504 --- /dev/null +++ b/apps/web/src/services/file.service.ts @@ -0,0 +1,70 @@ +import { API_ENDPOINTS } from '@/config/constants'; +import { httpClient, type CustomRequestConfig } from '@/lib/http'; + +/** 文件用途 */ +export type FilePurpose = 'avatar' | 'attachment'; + +/** 文件响应 */ +export interface FileResponse { + id: string; + filename: string; + mimeType: string; + size: number; + purpose: string; + createdAt: string; +} + +/** 临时访问 URL 响应 */ +export interface PresignedUrlResponse { + url: string; + expiresIn: number; +} + +/** + * 上传文件 + */ +export async function uploadFile( + file: File, + purpose: FilePurpose, +): Promise { + const formData = new FormData(); + formData.append('file', file); + formData.append('purpose', purpose); + + const config: CustomRequestConfig = { + headers: { + 'Content-Type': 'multipart/form-data', + }, + skipEncryption: true, + }; + + const response = await httpClient.post( + `${API_ENDPOINTS.FILES}/upload`, + formData, + config, + ); + + return response.data; +} + +/** + * 获取文件临时访问 URL + */ +export async function getFileUrl( + fileId: string, + expiresIn?: number, +): Promise { + const params = expiresIn ? { expiresIn } : {}; + const response = await httpClient.get( + `${API_ENDPOINTS.FILES}/${fileId}/url`, + { params }, + ); + return response.data; +} + +/** + * 删除文件 + */ +export async function deleteFile(fileId: string): Promise { + await httpClient.delete(`${API_ENDPOINTS.FILES}/${fileId}`); +}