feat: 파일 업로드/조회/삭제 API 구현 (#98)

This commit is contained in:
SEAN 2026-02-10 15:03:24 +09:00
parent 314df2e664
commit 658fa1d63d
24 changed files with 1677 additions and 0 deletions

View File

@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using SPMS.Application.DTOs.File;
using SPMS.Application.Interfaces;
using SPMS.Domain.Common;
using SPMS.Domain.Exceptions;
namespace SPMS.API.Controllers;
[ApiController]
[Route("v1/in/file")]
[Authorize]
[ApiExplorerSettings(GroupName = "file")]
public class FileController : ControllerBase
{
private readonly IFileService _fileService;
public FileController(IFileService fileService)
{
_fileService = fileService;
}
[HttpPost("upload")]
[SwaggerOperation(Summary = "파일 업로드", Description = "이미지 또는 CSV 파일을 업로드합니다.")]
[RequestSizeLimit(52_428_800)] // 50MB
public async Task<IActionResult> UploadAsync(IFormFile file, [FromForm] string file_type)
{
var serviceId = GetServiceId();
var adminId = GetAdminId();
using var stream = file.OpenReadStream();
var result = await _fileService.UploadAsync(
serviceId, adminId, stream, file.FileName, file.Length, file_type);
return Ok(ApiResponse<FileUploadResponseDto>.Success(result));
}
[HttpPost("info")]
[SwaggerOperation(Summary = "파일 조회", Description = "파일 메타데이터를 조회합니다.")]
public async Task<IActionResult> GetInfoAsync([FromBody] FileInfoRequestDto request)
{
var serviceId = GetServiceId();
var result = await _fileService.GetInfoAsync(serviceId, request.FileId);
return Ok(ApiResponse<FileInfoResponseDto>.Success(result));
}
[HttpPost("list")]
[SwaggerOperation(Summary = "파일 목록 조회", Description = "서비스의 파일 목록을 페이징 조회합니다.")]
public async Task<IActionResult> GetListAsync([FromBody] FileListRequestDto request)
{
var serviceId = GetServiceId();
var result = await _fileService.GetListAsync(serviceId, request);
return Ok(ApiResponse<FileListResponseDto>.Success(result));
}
[HttpPost("delete")]
[SwaggerOperation(Summary = "파일 삭제", Description = "파일을 삭제합니다. (Soft Delete)")]
public async Task<IActionResult> DeleteAsync([FromBody] FileDeleteRequestDto request)
{
var serviceId = GetServiceId();
await _fileService.DeleteAsync(serviceId, request.FileId);
return Ok(ApiResponse.Success());
}
private long GetServiceId()
{
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)
return serviceId;
throw new SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400);
}
private long GetAdminId()
{
var adminIdClaim = User.FindFirst("adminId")?.Value;
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
throw new SpmsException(ErrorCodes.Unauthorized, "인증 정보가 올바르지 않습니다.", 401);
return adminId;
}
}

View File

@ -20,6 +20,10 @@
"CredentialEncryption": {
"Key": ""
},
"FileStorage": {
"UploadPath": "Uploads",
"BaseUrl": "/uploads"
},
"Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
"MinimumLevel": {

View File

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.File;
public class FileDeleteRequestDto
{
[Required]
[JsonPropertyName("file_id")]
public long FileId { get; set; }
}

View File

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.File;
public class FileInfoRequestDto
{
[Required]
[JsonPropertyName("file_id")]
public long FileId { get; set; }
}

View File

@ -0,0 +1,30 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.File;
public class FileInfoResponseDto
{
[JsonPropertyName("file_id")]
public long FileId { get; set; }
[JsonPropertyName("service_code")]
public string ServiceCode { get; set; } = string.Empty;
[JsonPropertyName("file_name")]
public string FileName { get; set; } = string.Empty;
[JsonPropertyName("file_url")]
public string FileUrl { get; set; } = string.Empty;
[JsonPropertyName("file_size")]
public long FileSize { get; set; }
[JsonPropertyName("file_type")]
public string FileType { get; set; } = string.Empty;
[JsonPropertyName("uploaded_by")]
public string UploadedBy { get; set; } = string.Empty;
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
}

View File

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.File;
public class FileListRequestDto
{
[JsonPropertyName("page")]
public int Page { get; set; } = 1;
[JsonPropertyName("size")]
public int Size { get; set; } = 20;
[JsonPropertyName("file_type")]
public string? FileType { get; set; }
}

View File

@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
using SPMS.Application.DTOs.Notice;
namespace SPMS.Application.DTOs.File;
public class FileListResponseDto
{
[JsonPropertyName("items")]
public List<FileSummaryDto> Items { get; set; } = new();
[JsonPropertyName("pagination")]
public PaginationDto Pagination { get; set; } = null!;
}

View File

@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.File;
public class FileSummaryDto
{
[JsonPropertyName("file_id")]
public long FileId { get; set; }
[JsonPropertyName("file_name")]
public string FileName { get; set; } = string.Empty;
[JsonPropertyName("file_url")]
public string FileUrl { get; set; } = string.Empty;
[JsonPropertyName("file_size")]
public long FileSize { get; set; }
[JsonPropertyName("file_type")]
public string FileType { get; set; } = string.Empty;
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
}

View File

@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace SPMS.Application.DTOs.File;
public class FileUploadRequestDto
{
[Required]
public string FileType { get; set; } = string.Empty;
}

View File

@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.File;
public class FileUploadResponseDto
{
[JsonPropertyName("file_id")]
public long FileId { get; set; }
[JsonPropertyName("file_name")]
public string FileName { get; set; } = string.Empty;
[JsonPropertyName("file_url")]
public string FileUrl { get; set; } = string.Empty;
[JsonPropertyName("file_size")]
public long FileSize { get; set; }
[JsonPropertyName("file_type")]
public string FileType { get; set; } = string.Empty;
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
}

View File

@ -17,6 +17,7 @@ public static class DependencyInjection
services.AddScoped<IFaqService, FaqService>();
services.AddScoped<IAppConfigService, AppConfigService>();
services.AddScoped<IDeviceService, DeviceService>();
services.AddScoped<IFileService, FileService>();
return services;
}

View File

@ -0,0 +1,11 @@
using SPMS.Application.DTOs.File;
namespace SPMS.Application.Interfaces;
public interface IFileService
{
Task<FileUploadResponseDto> UploadAsync(long serviceId, long adminId, Stream fileStream, string fileName, long fileSize, string fileType);
Task<FileInfoResponseDto> GetInfoAsync(long serviceId, long fileId);
Task<FileListResponseDto> GetListAsync(long serviceId, FileListRequestDto request);
Task DeleteAsync(long serviceId, long fileId);
}

View File

@ -0,0 +1,8 @@
namespace SPMS.Application.Interfaces;
public interface IFileStorageService
{
Task<string> SaveAsync(long serviceId, string fileName, Stream fileStream);
Task DeleteAsync(string filePath);
string GetFileUrl(string filePath);
}

View File

@ -0,0 +1,181 @@
using SPMS.Application.DTOs.File;
using SPMS.Application.DTOs.Notice;
using SPMS.Application.Interfaces;
using SPMS.Domain.Common;
using SPMS.Domain.Entities;
using SPMS.Domain.Exceptions;
using SPMS.Domain.Interfaces;
namespace SPMS.Application.Services;
public class FileService : IFileService
{
private readonly IFileRepository _fileRepository;
private readonly IFileStorageService _fileStorageService;
private readonly IServiceRepository _serviceRepository;
private readonly IAdminRepository _adminRepository;
private readonly IUnitOfWork _unitOfWork;
private static readonly HashSet<string> AllowedImageExtensions = new(StringComparer.OrdinalIgnoreCase)
{ ".jpg", ".jpeg", ".png", ".gif" };
private static readonly HashSet<string> AllowedCsvExtensions = new(StringComparer.OrdinalIgnoreCase)
{ ".csv" };
private const long MaxImageSize = 5 * 1024 * 1024; // 5MB
private const long MaxCsvSize = 50 * 1024 * 1024; // 50MB
public FileService(
IFileRepository fileRepository,
IFileStorageService fileStorageService,
IServiceRepository serviceRepository,
IAdminRepository adminRepository,
IUnitOfWork unitOfWork)
{
_fileRepository = fileRepository;
_fileStorageService = fileStorageService;
_serviceRepository = serviceRepository;
_adminRepository = adminRepository;
_unitOfWork = unitOfWork;
}
public async Task<FileUploadResponseDto> UploadAsync(
long serviceId, long adminId, Stream fileStream,
string fileName, long fileSize, string fileType)
{
ValidateFileType(fileType, fileName, fileSize);
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var mimeType = GetMimeType(extension);
var savedPath = await _fileStorageService.SaveAsync(serviceId, fileName, fileStream);
var entity = new FileEntity
{
ServiceId = serviceId,
FileName = fileName,
FilePath = savedPath,
FileSize = fileSize,
FileType = fileType.ToLowerInvariant(),
MimeType = mimeType,
CreatedAt = DateTime.UtcNow,
CreatedBy = adminId
};
await _fileRepository.AddAsync(entity);
await _unitOfWork.SaveChangesAsync();
return new FileUploadResponseDto
{
FileId = entity.Id,
FileName = entity.FileName,
FileUrl = _fileStorageService.GetFileUrl(entity.FilePath),
FileSize = entity.FileSize,
FileType = entity.FileType,
CreatedAt = entity.CreatedAt
};
}
public async Task<FileInfoResponseDto> GetInfoAsync(long serviceId, long fileId)
{
var file = await _fileRepository.GetByIdAndServiceAsync(fileId, serviceId);
if (file == null || file.IsDeleted)
throw new SpmsException(ErrorCodes.FileNotFound, "존재하지 않는 파일입니다.", 404);
var service = await _serviceRepository.GetByIdAsync(file.ServiceId);
var admin = await _adminRepository.GetByIdAsync(file.CreatedBy);
return new FileInfoResponseDto
{
FileId = file.Id,
ServiceCode = service?.ServiceCode ?? string.Empty,
FileName = file.FileName,
FileUrl = _fileStorageService.GetFileUrl(file.FilePath),
FileSize = file.FileSize,
FileType = file.FileType,
UploadedBy = admin?.Email ?? string.Empty,
CreatedAt = file.CreatedAt
};
}
public async Task<FileListResponseDto> GetListAsync(long serviceId, FileListRequestDto request)
{
var (items, totalCount) = await _fileRepository.GetPagedByServiceAsync(
serviceId, request.Page, request.Size, request.FileType);
var totalPages = (int)Math.Ceiling((double)totalCount / request.Size);
return new FileListResponseDto
{
Items = items.Select(f => new FileSummaryDto
{
FileId = f.Id,
FileName = f.FileName,
FileUrl = _fileStorageService.GetFileUrl(f.FilePath),
FileSize = f.FileSize,
FileType = f.FileType,
CreatedAt = f.CreatedAt
}).ToList(),
Pagination = new PaginationDto
{
Page = request.Page,
Size = request.Size,
TotalCount = totalCount,
TotalPages = totalPages
}
};
}
public async Task DeleteAsync(long serviceId, long fileId)
{
var file = await _fileRepository.GetByIdAndServiceAsync(fileId, serviceId);
if (file == null || file.IsDeleted)
throw new SpmsException(ErrorCodes.FileNotFound, "존재하지 않는 파일입니다.", 404);
file.IsDeleted = true;
file.DeletedAt = DateTime.UtcNow;
_fileRepository.Update(file);
await _unitOfWork.SaveChangesAsync();
}
private static void ValidateFileType(string fileType, string fileName, long fileSize)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
switch (fileType.ToLowerInvariant())
{
case "image":
if (!AllowedImageExtensions.Contains(extension))
throw new SpmsException(ErrorCodes.FileTypeNotAllowed,
"허용되지 않는 이미지 형식입니다. (jpg, png, gif만 가능)", 400);
if (fileSize > MaxImageSize)
throw new SpmsException(ErrorCodes.FileSizeExceeded,
"이미지 파일은 5MB를 초과할 수 없습니다.", 400);
break;
case "csv":
if (!AllowedCsvExtensions.Contains(extension))
throw new SpmsException(ErrorCodes.FileTypeNotAllowed,
"허용되지 않는 파일 형식입니다. (csv만 가능)", 400);
if (fileSize > MaxCsvSize)
throw new SpmsException(ErrorCodes.FileSizeExceeded,
"CSV 파일은 50MB를 초과할 수 없습니다.", 400);
break;
default:
throw new SpmsException(ErrorCodes.FileTypeNotAllowed,
"유효하지 않은 파일 타입입니다. (image, csv만 가능)", 400);
}
}
private static string GetMimeType(string extension)
{
return extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".csv" => "text/csv",
_ => "application/octet-stream"
};
}
}

View File

@ -39,4 +39,9 @@ public static class ErrorCodes
// === Push (6) ===
public const string PushSendFailed = "161";
public const string PushStateChangeNotAllowed = "162";
// === File (8) ===
public const string FileNotFound = "181";
public const string FileTypeNotAllowed = "182";
public const string FileSizeExceeded = "183";
}

View File

@ -10,6 +10,8 @@ public class FileEntity : BaseEntity
public string? MimeType { get; set; }
public DateTime CreatedAt { get; set; }
public long CreatedBy { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
// Navigation
public Service Service { get; set; } = null!;

View File

@ -6,4 +6,7 @@ public interface IFileRepository : IRepository<FileEntity>
{
Task<FileEntity?> GetByFileNameAsync(long serviceId, string fileName);
Task<bool> FileExistsAsync(long serviceId, string fileName);
Task<FileEntity?> GetByIdAndServiceAsync(long id, long serviceId);
Task<(IReadOnlyList<FileEntity> Items, int TotalCount)> GetPagedByServiceAsync(
long serviceId, int page, int size, string? fileType = null);
}

View File

@ -32,12 +32,16 @@ public static class DependencyInjection
services.AddScoped<IFaqRepository, FaqRepository>();
services.AddScoped<IAppConfigRepository, AppConfigRepository>();
services.AddScoped<IDeviceRepository, DeviceRepository>();
services.AddScoped<IFileRepository, FileRepository>();
// External Services
services.AddScoped<IJwtService, JwtService>();
services.AddSingleton<IE2EEService, E2EEService>();
services.AddSingleton<ICredentialEncryptionService, CredentialEncryptionService>();
// File Storage
services.AddSingleton<IFileStorageService, LocalFileStorageService>();
// Token Store & Email Service
services.AddMemoryCache();
services.AddSingleton<ITokenStore, InMemoryTokenStore>();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SPMS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddFileEntitySoftDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "DeletedAt",
table: "File",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "File",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "File");
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "File");
}
}
}

View File

@ -359,6 +359,9 @@ namespace SPMS.Infrastructure.Migrations
b.Property<long>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime(6)");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(200)
@ -377,6 +380,11 @@ namespace SPMS.Infrastructure.Migrations
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(false);
b.Property<string>("MimeType")
.HasMaxLength(100)
.HasColumnType("varchar(100)");

View File

@ -21,6 +21,8 @@ public class FileEntityConfiguration : IEntityTypeConfiguration<FileEntity>
builder.Property(e => e.MimeType).HasMaxLength(100);
builder.Property(e => e.CreatedAt).IsRequired();
builder.Property(e => e.CreatedBy).IsRequired();
builder.Property(e => e.IsDeleted).HasDefaultValue(false);
builder.Property(e => e.DeletedAt);
builder.HasOne(e => e.Service)
.WithMany()

View File

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
using SPMS.Domain.Entities;
using SPMS.Domain.Interfaces;
namespace SPMS.Infrastructure.Persistence.Repositories;
public class FileRepository : Repository<FileEntity>, IFileRepository
{
public FileRepository(AppDbContext context) : base(context) { }
public async Task<FileEntity?> GetByFileNameAsync(long serviceId, string fileName)
{
return await _dbSet
.FirstOrDefaultAsync(f => f.ServiceId == serviceId && f.FileName == fileName && !f.IsDeleted);
}
public async Task<bool> FileExistsAsync(long serviceId, string fileName)
{
return await _dbSet
.AnyAsync(f => f.ServiceId == serviceId && f.FileName == fileName && !f.IsDeleted);
}
public async Task<FileEntity?> GetByIdAndServiceAsync(long id, long serviceId)
{
return await _dbSet
.FirstOrDefaultAsync(f => f.Id == id && f.ServiceId == serviceId);
}
public async Task<(IReadOnlyList<FileEntity> Items, int TotalCount)> GetPagedByServiceAsync(
long serviceId, int page, int size, string? fileType = null)
{
var query = _dbSet.Where(f => f.ServiceId == serviceId && !f.IsDeleted);
if (!string.IsNullOrWhiteSpace(fileType))
query = query.Where(f => f.FileType == fileType.ToLowerInvariant());
var totalCount = await query.CountAsync();
var items = await query
.OrderByDescending(f => f.CreatedAt)
.Skip((page - 1) * size)
.Take(size)
.ToListAsync();
return (items, totalCount);
}
}

View File

@ -0,0 +1,43 @@
using Microsoft.Extensions.Configuration;
using SPMS.Application.Interfaces;
namespace SPMS.Infrastructure.Services;
public class LocalFileStorageService : IFileStorageService
{
private readonly string _uploadPath;
private readonly string _baseUrl;
public LocalFileStorageService(IConfiguration configuration)
{
_uploadPath = configuration["FileStorage:UploadPath"] ?? "Uploads";
_baseUrl = configuration["FileStorage:BaseUrl"] ?? "/uploads";
}
public async Task<string> SaveAsync(long serviceId, string fileName, Stream fileStream)
{
var directory = Path.Combine(_uploadPath, serviceId.ToString());
Directory.CreateDirectory(directory);
var uniqueName = $"{Guid.NewGuid():N}_{fileName}";
var fullPath = Path.Combine(directory, uniqueName);
using var output = new FileStream(fullPath, FileMode.Create);
await fileStream.CopyToAsync(output);
return $"{serviceId}/{uniqueName}";
}
public Task DeleteAsync(string filePath)
{
var fullPath = Path.Combine(_uploadPath, filePath);
if (System.IO.File.Exists(fullPath))
System.IO.File.Delete(fullPath);
return Task.CompletedTask;
}
public string GetFileUrl(string filePath)
{
return $"{_baseUrl.TrimEnd('/')}/{filePath}";
}
}