From a2d563aa9d0133588829e41f2487bac5c25a86b8 Mon Sep 17 00:00:00 2001 From: SEAN Date: Wed, 25 Feb 2026 17:16:13 +0900 Subject: [PATCH] =?UTF-8?q?improvement:=20=EA=B8=B0=EA=B8=B0=20=EC=97=91?= =?UTF-8?q?=EC=85=80=20=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B8=B0=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DeviceExportRequestDto: 목록 필터와 동일한 필터 파라미터 (page/size 제외) - IDeviceRepository/DeviceRepository: GetAllFilteredAsync 추가 (전체 반환) - DeviceService: ClosedXML 기반 엑셀 생성 (14개 컬럼) - DeviceController: POST /v1/in/device/export [Authorize] 엔드포인트 추가 Closes #241 --- SPMS.API/Controllers/DeviceController.cs | 11 ++++ .../DTOs/Device/DeviceExportRequestDto.cs | 24 ++++++++ SPMS.Application/Interfaces/IDeviceService.cs | 1 + SPMS.Application/Services/DeviceService.cs | 55 +++++++++++++++++++ SPMS.Domain/Interfaces/IDeviceRepository.cs | 5 ++ .../Repositories/DeviceRepository.cs | 46 ++++++++++++++++ 6 files changed, 142 insertions(+) create mode 100644 SPMS.Application/DTOs/Device/DeviceExportRequestDto.cs diff --git a/SPMS.API/Controllers/DeviceController.cs b/SPMS.API/Controllers/DeviceController.cs index ab3f1a4..f8c7c1f 100644 --- a/SPMS.API/Controllers/DeviceController.cs +++ b/SPMS.API/Controllers/DeviceController.cs @@ -92,6 +92,17 @@ public class DeviceController : ControllerBase return Ok(ApiResponse.Success(result)); } + [HttpPost("export")] + [Authorize] + [SwaggerOperation(Summary = "기기 엑셀 내보내기", Description = "목록 필터와 동일 조건으로 기기 목록을 엑셀(.xlsx)로 내보냅니다. JWT 인증 필요.")] + public async Task ExportAsync([FromBody] DeviceExportRequestDto request) + { + var serviceId = GetOptionalServiceId(); + var fileBytes = await _deviceService.ExportAsync(serviceId, request); + var fileName = $"device_export_{DateTime.UtcNow:yyyyMMdd}.xlsx"; + return File(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); + } + private long GetServiceId() { if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId) diff --git a/SPMS.Application/DTOs/Device/DeviceExportRequestDto.cs b/SPMS.Application/DTOs/Device/DeviceExportRequestDto.cs new file mode 100644 index 0000000..db6d53a --- /dev/null +++ b/SPMS.Application/DTOs/Device/DeviceExportRequestDto.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Device; + +public class DeviceExportRequestDto +{ + [JsonPropertyName("platform")] + public string? Platform { get; set; } + + [JsonPropertyName("push_agreed")] + public bool? PushAgreed { get; set; } + + [JsonPropertyName("tags")] + public List? Tags { get; set; } + + [JsonPropertyName("is_active")] + public bool? IsActive { get; set; } + + [JsonPropertyName("keyword")] + public string? Keyword { get; set; } + + [JsonPropertyName("marketing_agreed")] + public bool? MarketingAgreed { get; set; } +} diff --git a/SPMS.Application/Interfaces/IDeviceService.cs b/SPMS.Application/Interfaces/IDeviceService.cs index a8d9a98..a0973a7 100644 --- a/SPMS.Application/Interfaces/IDeviceService.cs +++ b/SPMS.Application/Interfaces/IDeviceService.cs @@ -10,6 +10,7 @@ public interface IDeviceService Task DeleteAsync(long serviceId, DeviceDeleteRequestDto request); Task AdminDeleteAsync(long deviceId); Task GetListAsync(long? serviceId, DeviceListRequestDto request); + Task ExportAsync(long? serviceId, DeviceExportRequestDto request); Task SetTagsAsync(long serviceId, DeviceTagsRequestDto request); Task SetAgreeAsync(long serviceId, DeviceAgreeRequestDto request); } diff --git a/SPMS.Application/Services/DeviceService.cs b/SPMS.Application/Services/DeviceService.cs index 68ccdbd..02deddd 100644 --- a/SPMS.Application/Services/DeviceService.cs +++ b/SPMS.Application/Services/DeviceService.cs @@ -174,6 +174,61 @@ public class DeviceService : IDeviceService }; } + public async Task ExportAsync(long? serviceId, DeviceExportRequestDto request) + { + Platform? platform = null; + if (!string.IsNullOrWhiteSpace(request.Platform)) + platform = ParsePlatform(request.Platform); + + var items = await _deviceRepository.GetAllFilteredAsync( + serviceId, platform, request.PushAgreed, request.IsActive, + request.Tags, request.Keyword, request.MarketingAgreed); + + using var workbook = new ClosedXML.Excel.XLWorkbook(); + var ws = workbook.Worksheets.Add("기기 목록"); + + ws.Cell(1, 1).Value = "Device ID"; + ws.Cell(1, 2).Value = "Device Token"; + ws.Cell(1, 3).Value = "서비스명"; + ws.Cell(1, 4).Value = "서비스코드"; + ws.Cell(1, 5).Value = "플랫폼"; + ws.Cell(1, 6).Value = "모델"; + ws.Cell(1, 7).Value = "OS 버전"; + ws.Cell(1, 8).Value = "앱 버전"; + ws.Cell(1, 9).Value = "푸시 동의"; + ws.Cell(1, 10).Value = "광고 동의"; + ws.Cell(1, 11).Value = "활성 상태"; + ws.Cell(1, 12).Value = "태그"; + ws.Cell(1, 13).Value = "등록일"; + ws.Cell(1, 14).Value = "마지막 활동"; + ws.Row(1).Style.Font.Bold = true; + + var row = 2; + foreach (var d in items) + { + ws.Cell(row, 1).Value = d.Id; + ws.Cell(row, 2).Value = d.DeviceToken; + ws.Cell(row, 3).Value = d.Service?.ServiceName ?? string.Empty; + ws.Cell(row, 4).Value = d.Service?.ServiceCode ?? string.Empty; + ws.Cell(row, 5).Value = d.Platform.ToString().ToLowerInvariant(); + ws.Cell(row, 6).Value = d.DeviceModel ?? string.Empty; + ws.Cell(row, 7).Value = d.OsVersion ?? string.Empty; + ws.Cell(row, 8).Value = d.AppVersion ?? string.Empty; + ws.Cell(row, 9).Value = d.PushAgreed ? "Y" : "N"; + ws.Cell(row, 10).Value = d.MarketingAgreed ? "Y" : "N"; + ws.Cell(row, 11).Value = d.IsActive ? "활성" : "비활성"; + ws.Cell(row, 12).Value = d.Tags ?? string.Empty; + ws.Cell(row, 13).Value = d.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"); + ws.Cell(row, 14).Value = d.UpdatedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? string.Empty; + row++; + } + ws.Columns().AdjustToContents(); + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return stream.ToArray(); + } + public async Task SetTagsAsync(long serviceId, DeviceTagsRequestDto request) { var device = await _deviceRepository.GetByIdAndServiceAsync(request.DeviceId, serviceId); diff --git a/SPMS.Domain/Interfaces/IDeviceRepository.cs b/SPMS.Domain/Interfaces/IDeviceRepository.cs index 3b1a279..5605bb0 100644 --- a/SPMS.Domain/Interfaces/IDeviceRepository.cs +++ b/SPMS.Domain/Interfaces/IDeviceRepository.cs @@ -14,4 +14,9 @@ public interface IDeviceRepository : IRepository Platform? platform = null, bool? pushAgreed = null, bool? isActive = null, List? tags = null, string? keyword = null, bool? marketingAgreed = null); + Task> GetAllFilteredAsync( + long? serviceId, + Platform? platform = null, bool? pushAgreed = null, + bool? isActive = null, List? tags = null, + string? keyword = null, bool? marketingAgreed = null); } diff --git a/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs index ea7df13..5ef970c 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs @@ -82,4 +82,50 @@ public class DeviceRepository : Repository, IDeviceRepository return (items, totalCount); } + + public async Task> GetAllFilteredAsync( + long? serviceId, + Platform? platform = null, bool? pushAgreed = null, + bool? isActive = null, List? tags = null, + string? keyword = null, bool? marketingAgreed = null) + { + IQueryable query = _dbSet.Include(d => d.Service); + + if (serviceId.HasValue) + query = query.Where(d => d.ServiceId == serviceId.Value); + + if (platform.HasValue) + query = query.Where(d => d.Platform == platform.Value); + + if (pushAgreed.HasValue) + query = query.Where(d => d.PushAgreed == pushAgreed.Value); + + if (isActive.HasValue) + query = query.Where(d => d.IsActive == isActive.Value); + + if (marketingAgreed.HasValue) + query = query.Where(d => d.MarketingAgreed == marketingAgreed.Value); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var trimmed = keyword.Trim(); + if (long.TryParse(trimmed, out var deviceId)) + query = query.Where(d => d.Id == deviceId || d.DeviceToken.Contains(trimmed)); + else + query = query.Where(d => d.DeviceToken.Contains(trimmed)); + } + + if (tags != null && tags.Count > 0) + { + foreach (var tag in tags) + { + var tagStr = tag.ToString(); + query = query.Where(d => d.Tags != null && EF.Functions.Like(d.Tags, $"%{tagStr}%")); + } + } + + return await query + .OrderByDescending(d => d.CreatedAt) + .ToListAsync(); + } } -- 2.45.1