feat: CSV 검증/템플릿 다운로드 API 구현 (#100)
All checks were successful
SPMS_API/pipeline/head This commit looks good
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/101
This commit is contained in:
commit
1cae5c3754
|
|
@ -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<IActionResult> 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<CsvValidateResponseDto>.Success(result));
|
||||
}
|
||||
|
||||
[HttpPost("csv/template")]
|
||||
[SwaggerOperation(Summary = "CSV 템플릿 다운로드", Description = "메시지별 CSV 템플릿을 다운로드합니다.")]
|
||||
public async Task<IActionResult> 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)
|
||||
|
|
|
|||
11
SPMS.Application/DTOs/File/CsvTemplateRequestDto.cs
Normal file
11
SPMS.Application/DTOs/File/CsvTemplateRequestDto.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
12
SPMS.Application/DTOs/File/CsvValidateErrorDto.cs
Normal file
12
SPMS.Application/DTOs/File/CsvValidateErrorDto.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
27
SPMS.Application/DTOs/File/CsvValidateResponseDto.cs
Normal file
27
SPMS.Application/DTOs/File/CsvValidateResponseDto.cs
Normal file
|
|
@ -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<string> Columns { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("required_variables")]
|
||||
public List<string> RequiredVariables { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("matched")]
|
||||
public bool Matched { get; set; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public List<CsvValidateErrorDto> Errors { get; set; } = new();
|
||||
}
|
||||
|
|
@ -8,4 +8,6 @@ public interface IFileService
|
|||
Task<FileInfoResponseDto> GetInfoAsync(long serviceId, long fileId);
|
||||
Task<FileListResponseDto> GetListAsync(long serviceId, FileListRequestDto request);
|
||||
Task DeleteAsync(long serviceId, long fileId);
|
||||
Task<CsvValidateResponseDto> ValidateCsvAsync(long serviceId, Stream csvStream, string fileName, string messageCode);
|
||||
Task<byte[]> GetCsvTemplateAsync(long serviceId, string messageCode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> 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<CsvValidateResponseDto> 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<CsvValidateErrorDto>();
|
||||
|
||||
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<byte[]> 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<string> { "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<string> 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();
|
||||
|
|
|
|||
|
|
@ -5,4 +5,5 @@ namespace SPMS.Domain.Interfaces;
|
|||
public interface IMessageRepository : IRepository<Message>
|
||||
{
|
||||
Task<Message?> GetByMessageCodeAsync(string messageCode);
|
||||
Task<Message?> GetByMessageCodeAndServiceAsync(string messageCode, long serviceId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ public static class DependencyInjection
|
|||
services.AddScoped<IAppConfigRepository, AppConfigRepository>();
|
||||
services.AddScoped<IDeviceRepository, DeviceRepository>();
|
||||
services.AddScoped<IFileRepository, FileRepository>();
|
||||
services.AddScoped<IMessageRepository, MessageRepository>();
|
||||
|
||||
// External Services
|
||||
services.AddScoped<IJwtService, JwtService>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using SPMS.Domain.Entities;
|
||||
using SPMS.Domain.Interfaces;
|
||||
|
||||
namespace SPMS.Infrastructure.Persistence.Repositories;
|
||||
|
||||
public class MessageRepository : Repository<Message>, IMessageRepository
|
||||
{
|
||||
public MessageRepository(AppDbContext context) : base(context) { }
|
||||
|
||||
public async Task<Message?> GetByMessageCodeAsync(string messageCode)
|
||||
{
|
||||
return await _dbSet
|
||||
.FirstOrDefaultAsync(m => m.MessageCode == messageCode && !m.IsDeleted);
|
||||
}
|
||||
|
||||
public async Task<Message?> GetByMessageCodeAndServiceAsync(string messageCode, long serviceId)
|
||||
{
|
||||
return await _dbSet
|
||||
.FirstOrDefaultAsync(m => m.MessageCode == messageCode && m.ServiceId == serviceId && !m.IsDeleted);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user