diff --git a/SPMS.API/Controllers/FileController.cs b/SPMS.API/Controllers/FileController.cs index 7730483..21d46ad 100644 --- a/SPMS.API/Controllers/FileController.cs +++ b/SPMS.API/Controllers/FileController.cs @@ -63,6 +63,29 @@ public class FileController : ControllerBase return Ok(ApiResponse.Success()); } + [HttpPost("csv/validate")] + [SwaggerOperation(Summary = "CSV 검증", Description = "대용량 발송용 CSV 파일을 검증합니다.")] + [RequestSizeLimit(52_428_800)] // 50MB + public async Task ValidateCsvAsync(IFormFile file, [FromForm] string message_code) + { + var serviceId = GetServiceId(); + + using var stream = file.OpenReadStream(); + var result = await _fileService.ValidateCsvAsync(serviceId, stream, file.FileName, message_code); + + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("csv/template")] + [SwaggerOperation(Summary = "CSV 템플릿 다운로드", Description = "메시지별 CSV 템플릿을 다운로드합니다.")] + public async Task GetCsvTemplateAsync([FromBody] CsvTemplateRequestDto request) + { + var serviceId = GetServiceId(); + var csvBytes = await _fileService.GetCsvTemplateAsync(serviceId, request.MessageCode); + + return File(csvBytes, "text/csv", $"template_{request.MessageCode}.csv"); + } + private long GetServiceId() { if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId) diff --git a/SPMS.Application/DTOs/File/CsvTemplateRequestDto.cs b/SPMS.Application/DTOs/File/CsvTemplateRequestDto.cs new file mode 100644 index 0000000..038b62d --- /dev/null +++ b/SPMS.Application/DTOs/File/CsvTemplateRequestDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.File; + +public class CsvTemplateRequestDto +{ + [Required] + [JsonPropertyName("message_code")] + public string MessageCode { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/File/CsvValidateErrorDto.cs b/SPMS.Application/DTOs/File/CsvValidateErrorDto.cs new file mode 100644 index 0000000..3fff7c0 --- /dev/null +++ b/SPMS.Application/DTOs/File/CsvValidateErrorDto.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.File; + +public class CsvValidateErrorDto +{ + [JsonPropertyName("row")] + public int Row { get; set; } + + [JsonPropertyName("error")] + public string Error { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/File/CsvValidateResponseDto.cs b/SPMS.Application/DTOs/File/CsvValidateResponseDto.cs new file mode 100644 index 0000000..9ca858b --- /dev/null +++ b/SPMS.Application/DTOs/File/CsvValidateResponseDto.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.File; + +public class CsvValidateResponseDto +{ + [JsonPropertyName("total_rows")] + public int TotalRows { get; set; } + + [JsonPropertyName("valid_rows")] + public int ValidRows { get; set; } + + [JsonPropertyName("invalid_rows")] + public int InvalidRows { get; set; } + + [JsonPropertyName("columns")] + public List Columns { get; set; } = new(); + + [JsonPropertyName("required_variables")] + public List RequiredVariables { get; set; } = new(); + + [JsonPropertyName("matched")] + public bool Matched { get; set; } + + [JsonPropertyName("errors")] + public List Errors { get; set; } = new(); +} diff --git a/SPMS.Application/Interfaces/IFileService.cs b/SPMS.Application/Interfaces/IFileService.cs index 45c2524..e94e94a 100644 --- a/SPMS.Application/Interfaces/IFileService.cs +++ b/SPMS.Application/Interfaces/IFileService.cs @@ -8,4 +8,6 @@ public interface IFileService Task GetInfoAsync(long serviceId, long fileId); Task GetListAsync(long serviceId, FileListRequestDto request); Task DeleteAsync(long serviceId, long fileId); + Task ValidateCsvAsync(long serviceId, Stream csvStream, string fileName, string messageCode); + Task GetCsvTemplateAsync(long serviceId, string messageCode); } diff --git a/SPMS.Application/Services/FileService.cs b/SPMS.Application/Services/FileService.cs index cbe40d5..56574b4 100644 --- a/SPMS.Application/Services/FileService.cs +++ b/SPMS.Application/Services/FileService.cs @@ -1,3 +1,5 @@ +using System.Text; +using System.Text.RegularExpressions; using SPMS.Application.DTOs.File; using SPMS.Application.DTOs.Notice; using SPMS.Application.Interfaces; @@ -14,6 +16,7 @@ public class FileService : IFileService private readonly IFileStorageService _fileStorageService; private readonly IServiceRepository _serviceRepository; private readonly IAdminRepository _adminRepository; + private readonly IMessageRepository _messageRepository; private readonly IUnitOfWork _unitOfWork; private static readonly HashSet AllowedImageExtensions = new(StringComparer.OrdinalIgnoreCase) @@ -29,12 +32,14 @@ public class FileService : IFileService IFileStorageService fileStorageService, IServiceRepository serviceRepository, IAdminRepository adminRepository, + IMessageRepository messageRepository, IUnitOfWork unitOfWork) { _fileRepository = fileRepository; _fileStorageService = fileStorageService; _serviceRepository = serviceRepository; _adminRepository = adminRepository; + _messageRepository = messageRepository; _unitOfWork = unitOfWork; } @@ -137,6 +142,113 @@ public class FileService : IFileService await _unitOfWork.SaveChangesAsync(); } + public async Task ValidateCsvAsync( + long serviceId, Stream csvStream, string fileName, string messageCode) + { + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + if (extension != ".csv") + throw new SpmsException(ErrorCodes.FileTypeNotAllowed, "CSV 파일만 검증할 수 있습니다.", 400); + + var message = await _messageRepository.GetByMessageCodeAndServiceAsync(messageCode, serviceId); + if (message == null) + throw new SpmsException(ErrorCodes.NotFound, "존재하지 않는 메시지입니다.", 404); + + var requiredVariables = ExtractTemplateVariables(message.Body); + + using var reader = new StreamReader(csvStream, Encoding.UTF8); + var headerLine = await reader.ReadLineAsync(); + if (string.IsNullOrWhiteSpace(headerLine)) + throw new SpmsException(ErrorCodes.BadRequest, "CSV 파일이 비어있습니다.", 400); + + var columns = headerLine.Split(',').Select(c => c.Trim()).ToList(); + var hasDeviceId = columns.Contains("device_id", StringComparer.OrdinalIgnoreCase); + var matched = hasDeviceId && requiredVariables.All(v => + columns.Contains(v, StringComparer.OrdinalIgnoreCase)); + + var errors = new List(); + + if (!hasDeviceId) + errors.Add(new CsvValidateErrorDto { Row = 0, Error = "device_id 컬럼이 없습니다." }); + + foreach (var variable in requiredVariables.Where(v => + !columns.Contains(v, StringComparer.OrdinalIgnoreCase))) + { + errors.Add(new CsvValidateErrorDto { Row = 0, Error = $"변수 '{variable}'이 CSV에 없습니다." }); + } + + var deviceIdIndex = columns.FindIndex(c => + c.Equals("device_id", StringComparison.OrdinalIgnoreCase)); + + var totalRows = 0; + var invalidRows = 0; + var rowNumber = 1; + + while (await reader.ReadLineAsync() is { } line) + { + totalRows++; + rowNumber++; + + if (string.IsNullOrWhiteSpace(line)) + { + invalidRows++; + errors.Add(new CsvValidateErrorDto { Row = rowNumber, Error = "빈 행입니다." }); + continue; + } + + var values = line.Split(','); + + if (deviceIdIndex >= 0 && deviceIdIndex < values.Length) + { + var deviceIdValue = values[deviceIdIndex].Trim(); + if (string.IsNullOrWhiteSpace(deviceIdValue)) + { + invalidRows++; + errors.Add(new CsvValidateErrorDto { Row = rowNumber, Error = "device_id 누락" }); + } + else if (!long.TryParse(deviceIdValue, out _)) + { + invalidRows++; + errors.Add(new CsvValidateErrorDto { Row = rowNumber, Error = "잘못된 device_id 형식" }); + } + } + } + + return new CsvValidateResponseDto + { + TotalRows = totalRows, + ValidRows = totalRows - invalidRows, + InvalidRows = invalidRows, + Columns = columns, + RequiredVariables = requiredVariables, + Matched = matched, + Errors = errors + }; + } + + public async Task GetCsvTemplateAsync(long serviceId, string messageCode) + { + var message = await _messageRepository.GetByMessageCodeAndServiceAsync(messageCode, serviceId); + if (message == null) + throw new SpmsException(ErrorCodes.NotFound, "존재하지 않는 메시지입니다.", 404); + + var variables = ExtractTemplateVariables(message.Body); + var headers = new List { "device_id" }; + headers.AddRange(variables); + + var sb = new StringBuilder(); + sb.AppendLine(string.Join(",", headers)); + sb.AppendLine(string.Join(",", headers.Select(h => + h == "device_id" ? "(여기에 데이터 입력)" : $"예시_{h}"))); + + return Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray(); + } + + private static List ExtractTemplateVariables(string template) + { + var matches = Regex.Matches(template, @"\{\{(\w+)\}\}"); + return matches.Select(m => m.Groups[1].Value).Distinct().ToList(); + } + private static void ValidateFileType(string fileType, string fileName, long fileSize) { var extension = Path.GetExtension(fileName).ToLowerInvariant(); diff --git a/SPMS.Domain/Interfaces/IMessageRepository.cs b/SPMS.Domain/Interfaces/IMessageRepository.cs index 39781ae..4f590dd 100644 --- a/SPMS.Domain/Interfaces/IMessageRepository.cs +++ b/SPMS.Domain/Interfaces/IMessageRepository.cs @@ -5,4 +5,5 @@ namespace SPMS.Domain.Interfaces; public interface IMessageRepository : IRepository { Task GetByMessageCodeAsync(string messageCode); + Task GetByMessageCodeAndServiceAsync(string messageCode, long serviceId); } diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index 0dec0f7..ba1c68c 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -33,6 +33,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // External Services services.AddScoped(); diff --git a/SPMS.Infrastructure/Persistence/Repositories/MessageRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/MessageRepository.cs new file mode 100644 index 0000000..31c4356 --- /dev/null +++ b/SPMS.Infrastructure/Persistence/Repositories/MessageRepository.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using SPMS.Domain.Entities; +using SPMS.Domain.Interfaces; + +namespace SPMS.Infrastructure.Persistence.Repositories; + +public class MessageRepository : Repository, IMessageRepository +{ + public MessageRepository(AppDbContext context) : base(context) { } + + public async Task GetByMessageCodeAsync(string messageCode) + { + return await _dbSet + .FirstOrDefaultAsync(m => m.MessageCode == messageCode && !m.IsDeleted); + } + + public async Task GetByMessageCodeAndServiceAsync(string messageCode, long serviceId) + { + return await _dbSet + .FirstOrDefaultAsync(m => m.MessageCode == messageCode && m.ServiceId == serviceId && !m.IsDeleted); + } +}