294 lines
11 KiB
C#
294 lines
11 KiB
C#
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
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 IMessageRepository _messageRepository;
|
|
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,
|
|
IMessageRepository messageRepository,
|
|
IUnitOfWork unitOfWork)
|
|
{
|
|
_fileRepository = fileRepository;
|
|
_fileStorageService = fileStorageService;
|
|
_serviceRepository = serviceRepository;
|
|
_adminRepository = adminRepository;
|
|
_messageRepository = messageRepository;
|
|
_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();
|
|
}
|
|
|
|
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();
|
|
|
|
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"
|
|
};
|
|
}
|
|
}
|