diff --git a/SPMS.API/Controllers/FileController.cs b/SPMS.API/Controllers/FileController.cs new file mode 100644 index 0000000..7730483 --- /dev/null +++ b/SPMS.API/Controllers/FileController.cs @@ -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 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.Success(result)); + } + + [HttpPost("info")] + [SwaggerOperation(Summary = "파일 조회", Description = "파일 메타데이터를 조회합니다.")] + public async Task GetInfoAsync([FromBody] FileInfoRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _fileService.GetInfoAsync(serviceId, request.FileId); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("list")] + [SwaggerOperation(Summary = "파일 목록 조회", Description = "서비스의 파일 목록을 페이징 조회합니다.")] + public async Task GetListAsync([FromBody] FileListRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _fileService.GetListAsync(serviceId, request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("delete")] + [SwaggerOperation(Summary = "파일 삭제", Description = "파일을 삭제합니다. (Soft Delete)")] + public async Task 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; + } +} diff --git a/SPMS.API/appsettings.json b/SPMS.API/appsettings.json index e049e6f..577b74d 100644 --- a/SPMS.API/appsettings.json +++ b/SPMS.API/appsettings.json @@ -20,6 +20,10 @@ "CredentialEncryption": { "Key": "" }, + "FileStorage": { + "UploadPath": "Uploads", + "BaseUrl": "/uploads" + }, "Serilog": { "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"], "MinimumLevel": { diff --git a/SPMS.Application/DTOs/File/FileDeleteRequestDto.cs b/SPMS.Application/DTOs/File/FileDeleteRequestDto.cs new file mode 100644 index 0000000..1c51fd9 --- /dev/null +++ b/SPMS.Application/DTOs/File/FileDeleteRequestDto.cs @@ -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; } +} diff --git a/SPMS.Application/DTOs/File/FileInfoRequestDto.cs b/SPMS.Application/DTOs/File/FileInfoRequestDto.cs new file mode 100644 index 0000000..9a066f5 --- /dev/null +++ b/SPMS.Application/DTOs/File/FileInfoRequestDto.cs @@ -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; } +} diff --git a/SPMS.Application/DTOs/File/FileInfoResponseDto.cs b/SPMS.Application/DTOs/File/FileInfoResponseDto.cs new file mode 100644 index 0000000..1a55745 --- /dev/null +++ b/SPMS.Application/DTOs/File/FileInfoResponseDto.cs @@ -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; } +} diff --git a/SPMS.Application/DTOs/File/FileListRequestDto.cs b/SPMS.Application/DTOs/File/FileListRequestDto.cs new file mode 100644 index 0000000..21c4bc0 --- /dev/null +++ b/SPMS.Application/DTOs/File/FileListRequestDto.cs @@ -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; } +} diff --git a/SPMS.Application/DTOs/File/FileListResponseDto.cs b/SPMS.Application/DTOs/File/FileListResponseDto.cs new file mode 100644 index 0000000..99cee67 --- /dev/null +++ b/SPMS.Application/DTOs/File/FileListResponseDto.cs @@ -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 Items { get; set; } = new(); + + [JsonPropertyName("pagination")] + public PaginationDto Pagination { get; set; } = null!; +} diff --git a/SPMS.Application/DTOs/File/FileSummaryDto.cs b/SPMS.Application/DTOs/File/FileSummaryDto.cs new file mode 100644 index 0000000..7725cc1 --- /dev/null +++ b/SPMS.Application/DTOs/File/FileSummaryDto.cs @@ -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; } +} diff --git a/SPMS.Application/DTOs/File/FileUploadRequestDto.cs b/SPMS.Application/DTOs/File/FileUploadRequestDto.cs new file mode 100644 index 0000000..64b773c --- /dev/null +++ b/SPMS.Application/DTOs/File/FileUploadRequestDto.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.File; + +public class FileUploadRequestDto +{ + [Required] + public string FileType { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/File/FileUploadResponseDto.cs b/SPMS.Application/DTOs/File/FileUploadResponseDto.cs new file mode 100644 index 0000000..78b8540 --- /dev/null +++ b/SPMS.Application/DTOs/File/FileUploadResponseDto.cs @@ -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; } +} diff --git a/SPMS.Application/DependencyInjection.cs b/SPMS.Application/DependencyInjection.cs index 8d7cb20..05675cb 100644 --- a/SPMS.Application/DependencyInjection.cs +++ b/SPMS.Application/DependencyInjection.cs @@ -17,6 +17,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/SPMS.Application/Interfaces/IFileService.cs b/SPMS.Application/Interfaces/IFileService.cs new file mode 100644 index 0000000..45c2524 --- /dev/null +++ b/SPMS.Application/Interfaces/IFileService.cs @@ -0,0 +1,11 @@ +using SPMS.Application.DTOs.File; + +namespace SPMS.Application.Interfaces; + +public interface IFileService +{ + Task UploadAsync(long serviceId, long adminId, Stream fileStream, string fileName, long fileSize, string fileType); + Task GetInfoAsync(long serviceId, long fileId); + Task GetListAsync(long serviceId, FileListRequestDto request); + Task DeleteAsync(long serviceId, long fileId); +} diff --git a/SPMS.Application/Interfaces/IFileStorageService.cs b/SPMS.Application/Interfaces/IFileStorageService.cs new file mode 100644 index 0000000..6d0d7ab --- /dev/null +++ b/SPMS.Application/Interfaces/IFileStorageService.cs @@ -0,0 +1,8 @@ +namespace SPMS.Application.Interfaces; + +public interface IFileStorageService +{ + Task SaveAsync(long serviceId, string fileName, Stream fileStream); + Task DeleteAsync(string filePath); + string GetFileUrl(string filePath); +} diff --git a/SPMS.Application/Services/FileService.cs b/SPMS.Application/Services/FileService.cs new file mode 100644 index 0000000..cbe40d5 --- /dev/null +++ b/SPMS.Application/Services/FileService.cs @@ -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 AllowedImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { ".jpg", ".jpeg", ".png", ".gif" }; + private static readonly HashSet 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 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 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 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" + }; + } +} diff --git a/SPMS.Domain/Common/ErrorCodes.cs b/SPMS.Domain/Common/ErrorCodes.cs index 5bfa5de..4b64796 100644 --- a/SPMS.Domain/Common/ErrorCodes.cs +++ b/SPMS.Domain/Common/ErrorCodes.cs @@ -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"; } diff --git a/SPMS.Domain/Entities/FileEntity.cs b/SPMS.Domain/Entities/FileEntity.cs index dab02f6..d706c71 100644 --- a/SPMS.Domain/Entities/FileEntity.cs +++ b/SPMS.Domain/Entities/FileEntity.cs @@ -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!; diff --git a/SPMS.Domain/Interfaces/IFileRepository.cs b/SPMS.Domain/Interfaces/IFileRepository.cs index 24d565a..d982cdc 100644 --- a/SPMS.Domain/Interfaces/IFileRepository.cs +++ b/SPMS.Domain/Interfaces/IFileRepository.cs @@ -6,4 +6,7 @@ public interface IFileRepository : IRepository { Task GetByFileNameAsync(long serviceId, string fileName); Task FileExistsAsync(long serviceId, string fileName); + Task GetByIdAndServiceAsync(long id, long serviceId); + Task<(IReadOnlyList Items, int TotalCount)> GetPagedByServiceAsync( + long serviceId, int page, int size, string? fileType = null); } diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index dd831f2..0dec0f7 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -32,12 +32,16 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // External Services services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + // File Storage + services.AddSingleton(); + // Token Store & Email Service services.AddMemoryCache(); services.AddSingleton(); diff --git a/SPMS.Infrastructure/Migrations/20260210060138_AddFileEntitySoftDelete.Designer.cs b/SPMS.Infrastructure/Migrations/20260210060138_AddFileEntitySoftDelete.Designer.cs new file mode 100644 index 0000000..f06955d --- /dev/null +++ b/SPMS.Infrastructure/Migrations/20260210060138_AddFileEntitySoftDelete.Designer.cs @@ -0,0 +1,1099 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SPMS.Infrastructure; + +#nullable disable + +namespace SPMS.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260210060138_AddFileEntitySoftDelete")] + partial class AddFileEntitySoftDelete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("SPMS.Domain.Entities.Admin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdminCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("varchar(8)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EmailVerified") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("EmailVerifiedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("RefreshToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("Role") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AdminCode") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Admin", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.AppConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ConfigValue") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "ConfigKey") + .IsUnique(); + + b.ToTable("AppConfig", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Banner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("LinkType") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("LinkUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Position") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId"); + + b.ToTable("Banner", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.DailyStat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FailCnt") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("OpenCnt") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("SentCnt") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("StatDate") + .HasColumnType("date"); + + b.Property("SuccessCnt") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "StatDate") + .IsUnique(); + + b.ToTable("DailyStat", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AgreeUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("AppVersion") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeviceModel") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("DeviceToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("MarketingAgreed") + .HasColumnType("tinyint(1)"); + + b.Property("MktAgreeUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OsVersion") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Platform") + .HasColumnType("tinyint"); + + b.Property("PushAgreed") + .HasColumnType("tinyint(1)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Tags") + .HasColumnType("json"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "DeviceToken"); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Faq", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Answer") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId"); + + b.ToTable("Faq", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.FileEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FileType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("MimeType") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ServiceId"); + + b.ToTable("File", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("CustomData") + .HasColumnType("json"); + + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + + b.Property("ImageUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("LinkType") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("LinkUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("MessageCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("MessageCode") + .IsUnique(); + + b.HasIndex("ServiceId"); + + b.ToTable("Message", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Notice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("IsPinned") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ServiceId"); + + b.ToTable("Notice", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdminId") + .HasColumnType("bigint"); + + b.Property("Amount") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("PaidAt") + .HasColumnType("datetime(6)"); + + b.Property("PaymentKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PaymentMethod") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("tinyint"); + + b.Property("TierAfter") + .HasColumnType("tinyint"); + + b.Property("TierBefore") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AdminId"); + + b.HasIndex("ServiceId"); + + b.ToTable("Payment", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.PushOpenLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DeviceId") + .HasColumnType("bigint"); + + b.Property("MessageId") + .HasColumnType("bigint"); + + b.Property("OpenedAt") + .HasColumnType("datetime(6)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("MessageId"); + + b.HasIndex("ServiceId", "OpenedAt"); + + b.ToTable("PushOpenLog", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.PushSendLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DeviceId") + .HasColumnType("bigint"); + + b.Property("FailReason") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MessageId") + .HasColumnType("bigint"); + + b.Property("SentAt") + .HasColumnType("datetime(6)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("MessageId"); + + b.HasIndex("ServiceId", "SentAt"); + + b.ToTable("PushSendLog", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("ApiKeyCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApnsBundleId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ApnsKeyId") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("ApnsPrivateKey") + .HasColumnType("text"); + + b.Property("ApnsTeamId") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FcmCredentials") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ServiceCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("varchar(8)"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Status") + .HasColumnType("tinyint"); + + b.Property("SubStartedAt") + .HasColumnType("datetime(6)"); + + b.Property("SubTier") + .HasColumnType("tinyint"); + + b.Property("Tags") + .HasColumnType("json"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WebhookUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ServiceCode") + .IsUnique(); + + b.ToTable("Service", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.ServiceIp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("varchar(45)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId"); + + b.ToTable("ServiceIp", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.SystemLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("AdminId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasColumnType("json"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("varchar(45)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("TargetId") + .HasColumnType("bigint"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AdminId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ServiceId"); + + b.ToTable("SystemLog", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.WebhookLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EventType") + .HasColumnType("tinyint"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("json"); + + b.Property("ResponseBody") + .HasColumnType("text"); + + b.Property("ResponseCode") + .HasColumnType("int"); + + b.Property("SentAt") + .HasColumnType("datetime(6)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("tinyint"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "SentAt"); + + b.ToTable("WebhookLog", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.AppConfig", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Banner", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.DailyStat", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Device", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany("Devices") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Faq", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.FileEntity", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Message", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany("Messages") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Notice", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Payment", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "Admin") + .WithMany() + .HasForeignKey("AdminId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Admin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.PushOpenLog", b => + { + b.HasOne("SPMS.Domain.Entities.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Message", "Message") + .WithMany() + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("Message"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.PushSendLog", b => + { + b.HasOne("SPMS.Domain.Entities.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Message", "Message") + .WithMany() + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("Message"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Service", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.ServiceIp", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany("ServiceIps") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.SystemLog", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "Admin") + .WithMany() + .HasForeignKey("AdminId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Admin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.WebhookLog", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Service", b => + { + b.Navigation("Devices"); + + b.Navigation("Messages"); + + b.Navigation("ServiceIps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SPMS.Infrastructure/Migrations/20260210060138_AddFileEntitySoftDelete.cs b/SPMS.Infrastructure/Migrations/20260210060138_AddFileEntitySoftDelete.cs new file mode 100644 index 0000000..df7185d --- /dev/null +++ b/SPMS.Infrastructure/Migrations/20260210060138_AddFileEntitySoftDelete.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SPMS.Infrastructure.Migrations +{ + /// + public partial class AddFileEntitySoftDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "File", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "File", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "File"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "File"); + } + } +} diff --git a/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index f06c2ea..413a10b 100644 --- a/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -359,6 +359,9 @@ namespace SPMS.Infrastructure.Migrations b.Property("CreatedBy") .HasColumnType("bigint"); + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + b.Property("FileName") .IsRequired() .HasMaxLength(200) @@ -377,6 +380,11 @@ namespace SPMS.Infrastructure.Migrations .HasMaxLength(20) .HasColumnType("varchar(20)"); + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + b.Property("MimeType") .HasMaxLength(100) .HasColumnType("varchar(100)"); diff --git a/SPMS.Infrastructure/Persistence/Configurations/FileEntityConfiguration.cs b/SPMS.Infrastructure/Persistence/Configurations/FileEntityConfiguration.cs index 8a89d13..ff59e12 100644 --- a/SPMS.Infrastructure/Persistence/Configurations/FileEntityConfiguration.cs +++ b/SPMS.Infrastructure/Persistence/Configurations/FileEntityConfiguration.cs @@ -21,6 +21,8 @@ public class FileEntityConfiguration : IEntityTypeConfiguration 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() diff --git a/SPMS.Infrastructure/Persistence/Repositories/FileRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/FileRepository.cs new file mode 100644 index 0000000..3242c58 --- /dev/null +++ b/SPMS.Infrastructure/Persistence/Repositories/FileRepository.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using SPMS.Domain.Entities; +using SPMS.Domain.Interfaces; + +namespace SPMS.Infrastructure.Persistence.Repositories; + +public class FileRepository : Repository, IFileRepository +{ + public FileRepository(AppDbContext context) : base(context) { } + + public async Task GetByFileNameAsync(long serviceId, string fileName) + { + return await _dbSet + .FirstOrDefaultAsync(f => f.ServiceId == serviceId && f.FileName == fileName && !f.IsDeleted); + } + + public async Task FileExistsAsync(long serviceId, string fileName) + { + return await _dbSet + .AnyAsync(f => f.ServiceId == serviceId && f.FileName == fileName && !f.IsDeleted); + } + + public async Task GetByIdAndServiceAsync(long id, long serviceId) + { + return await _dbSet + .FirstOrDefaultAsync(f => f.Id == id && f.ServiceId == serviceId); + } + + public async Task<(IReadOnlyList 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); + } +} diff --git a/SPMS.Infrastructure/Services/LocalFileStorageService.cs b/SPMS.Infrastructure/Services/LocalFileStorageService.cs new file mode 100644 index 0000000..2474f2f --- /dev/null +++ b/SPMS.Infrastructure/Services/LocalFileStorageService.cs @@ -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 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}"; + } +}