diff --git a/SPMS.API/Controllers/ServiceController.cs b/SPMS.API/Controllers/ServiceController.cs new file mode 100644 index 0000000..222af1b --- /dev/null +++ b/SPMS.API/Controllers/ServiceController.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using SPMS.Application.DTOs.Service; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; + +namespace SPMS.API.Controllers; + +[ApiController] +[Route("v1/in/service")] +[ApiExplorerSettings(GroupName = "service")] +[Authorize(Roles = "Super")] +public class ServiceController : ControllerBase +{ + private readonly IServiceManagementService _serviceManagementService; + + public ServiceController(IServiceManagementService serviceManagementService) + { + _serviceManagementService = serviceManagementService; + } + + [HttpPost("list")] + [SwaggerOperation( + Summary = "서비스 목록 조회", + Description = "등록된 서비스 목록을 조회합니다. 페이징, 검색, 상태 필터를 지원합니다.")] + [SwaggerResponse(200, "조회 성공", typeof(ApiResponse))] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + public async Task GetListAsync([FromBody] ServiceListRequestDto request) + { + var result = await _serviceManagementService.GetListAsync(request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("{serviceCode}")] + [SwaggerOperation( + Summary = "서비스 상세 조회", + Description = "특정 서비스의 상세 정보를 조회합니다.")] + [SwaggerResponse(200, "조회 성공", typeof(ApiResponse))] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + [SwaggerResponse(404, "서비스를 찾을 수 없음")] + public async Task GetByServiceCodeAsync([FromRoute] string serviceCode) + { + var result = await _serviceManagementService.GetByServiceCodeAsync(serviceCode); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("{serviceCode}/status")] + [SwaggerOperation( + Summary = "서비스 상태 변경", + Description = "서비스의 상태를 변경합니다. (Active: 0, Suspended: 1)")] + [SwaggerResponse(200, "상태 변경 성공", typeof(ApiResponse))] + [SwaggerResponse(400, "잘못된 요청 또는 이미 해당 상태")] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + [SwaggerResponse(404, "서비스를 찾을 수 없음")] + public async Task ChangeStatusAsync( + [FromRoute] string serviceCode, + [FromBody] ChangeServiceStatusRequestDto request) + { + var result = await _serviceManagementService.ChangeStatusAsync(serviceCode, request); + return Ok(ApiResponse.Success(result)); + } +} diff --git a/SPMS.Application/DTOs/Service/ChangeServiceStatusRequestDto.cs b/SPMS.Application/DTOs/Service/ChangeServiceStatusRequestDto.cs new file mode 100644 index 0000000..95b167a --- /dev/null +++ b/SPMS.Application/DTOs/Service/ChangeServiceStatusRequestDto.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Service; + +public class ChangeServiceStatusRequestDto +{ + [Required(ErrorMessage = "상태를 선택해주세요.")] + [Range(0, 1, ErrorMessage = "상태는 Active(0) 또는 Suspended(1)만 가능합니다.")] + public int Status { get; set; } +} diff --git a/SPMS.Application/DTOs/Service/ServiceListRequestDto.cs b/SPMS.Application/DTOs/Service/ServiceListRequestDto.cs new file mode 100644 index 0000000..8e7f451 --- /dev/null +++ b/SPMS.Application/DTOs/Service/ServiceListRequestDto.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Service; + +public class ServiceListRequestDto +{ + [Range(1, int.MaxValue, ErrorMessage = "페이지 번호는 1 이상이어야 합니다.")] + public int Page { get; set; } = 1; + + [Range(1, 100, ErrorMessage = "페이지 크기는 1~100 사이여야 합니다.")] + public int PageSize { get; set; } = 20; + + public string? SearchKeyword { get; set; } + public int? Status { get; set; } +} diff --git a/SPMS.Application/DTOs/Service/ServiceListResponseDto.cs b/SPMS.Application/DTOs/Service/ServiceListResponseDto.cs new file mode 100644 index 0000000..db585f2 --- /dev/null +++ b/SPMS.Application/DTOs/Service/ServiceListResponseDto.cs @@ -0,0 +1,21 @@ +namespace SPMS.Application.DTOs.Service; + +public class ServiceListResponseDto +{ + public List Items { get; set; } = new(); + public int TotalCount { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); +} + +public class ServiceSummaryDto +{ + public string ServiceCode { get; set; } = string.Empty; + public string ServiceName { get; set; } = string.Empty; + public string? Description { get; set; } + public string SubTier { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public int DeviceCount { get; set; } +} diff --git a/SPMS.Application/DTOs/Service/ServiceResponseDto.cs b/SPMS.Application/DTOs/Service/ServiceResponseDto.cs new file mode 100644 index 0000000..bc473ca --- /dev/null +++ b/SPMS.Application/DTOs/Service/ServiceResponseDto.cs @@ -0,0 +1,25 @@ +namespace SPMS.Application.DTOs.Service; + +public class ServiceResponseDto +{ + public string ServiceCode { get; set; } = string.Empty; + public string ServiceName { get; set; } = string.Empty; + public string? Description { get; set; } + public string ApiKey { get; set; } = string.Empty; + public DateTime ApiKeyCreatedAt { get; set; } + public string? ApnsBundleId { get; set; } + public string? ApnsKeyId { get; set; } + public string? ApnsTeamId { get; set; } + public bool HasApnsKey { get; set; } + public bool HasFcmCredentials { get; set; } + public string? WebhookUrl { get; set; } + public string? Tags { get; set; } + public string SubTier { get; set; } = string.Empty; + public DateTime? SubStartedAt { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public string CreatedByName { get; set; } = string.Empty; + public DateTime? UpdatedAt { get; set; } + public int DeviceCount { get; set; } + public List AllowedIps { get; set; } = new(); +} diff --git a/SPMS.Application/DependencyInjection.cs b/SPMS.Application/DependencyInjection.cs index d3113d0..6fa1a60 100644 --- a/SPMS.Application/DependencyInjection.cs +++ b/SPMS.Application/DependencyInjection.cs @@ -11,6 +11,7 @@ public static class DependencyInjection // Application Services services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/SPMS.Application/Interfaces/IServiceManagementService.cs b/SPMS.Application/Interfaces/IServiceManagementService.cs new file mode 100644 index 0000000..329f60a --- /dev/null +++ b/SPMS.Application/Interfaces/IServiceManagementService.cs @@ -0,0 +1,10 @@ +using SPMS.Application.DTOs.Service; + +namespace SPMS.Application.Interfaces; + +public interface IServiceManagementService +{ + Task GetListAsync(ServiceListRequestDto request); + Task GetByServiceCodeAsync(string serviceCode); + Task ChangeStatusAsync(string serviceCode, ChangeServiceStatusRequestDto request); +} diff --git a/SPMS.Application/Services/ServiceManagementService.cs b/SPMS.Application/Services/ServiceManagementService.cs new file mode 100644 index 0000000..80b671d --- /dev/null +++ b/SPMS.Application/Services/ServiceManagementService.cs @@ -0,0 +1,153 @@ +using System.Linq.Expressions; +using SPMS.Application.DTOs.Service; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; +using SPMS.Domain.Entities; +using SPMS.Domain.Enums; +using SPMS.Domain.Exceptions; +using SPMS.Domain.Interfaces; + +namespace SPMS.Application.Services; + +public class ServiceManagementService : IServiceManagementService +{ + private readonly IServiceRepository _serviceRepository; + private readonly IUnitOfWork _unitOfWork; + + public ServiceManagementService(IServiceRepository serviceRepository, IUnitOfWork unitOfWork) + { + _serviceRepository = serviceRepository; + _unitOfWork = unitOfWork; + } + + public async Task GetListAsync(ServiceListRequestDto request) + { + Expression>? predicate = null; + + // 검색어 필터 + if (!string.IsNullOrWhiteSpace(request.SearchKeyword)) + { + var keyword = request.SearchKeyword; + predicate = s => s.ServiceName.Contains(keyword) || s.ServiceCode.Contains(keyword); + } + + // Status 필터 + if (request.Status.HasValue) + { + var status = (ServiceStatus)request.Status.Value; + if (predicate != null) + { + var basePredicate = predicate; + predicate = s => basePredicate.Compile()(s) && s.Status == status; + } + else + { + predicate = s => s.Status == status; + } + } + + var (items, totalCount) = await _serviceRepository.GetPagedAsync( + request.Page, + request.PageSize, + predicate, + s => s.CreatedAt, + descending: true); + + return new ServiceListResponseDto + { + Items = items.Select(MapToSummaryDto).ToList(), + TotalCount = totalCount, + Page = request.Page, + PageSize = request.PageSize + }; + } + + public async Task GetByServiceCodeAsync(string serviceCode) + { + var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode); + if (service is null) + { + throw new SpmsException( + ErrorCodes.NotFound, + "서비스를 찾을 수 없습니다.", + 404); + } + + // IP 목록 포함해서 다시 조회 + var serviceWithIps = await _serviceRepository.GetByIdWithIpsAsync(service.Id); + + return MapToDto(serviceWithIps ?? service); + } + + public async Task ChangeStatusAsync(string serviceCode, ChangeServiceStatusRequestDto request) + { + var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode); + if (service is null) + { + throw new SpmsException( + ErrorCodes.NotFound, + "서비스를 찾을 수 없습니다.", + 404); + } + + var newStatus = (ServiceStatus)request.Status; + + // 이미 같은 상태면 변경 없음 + if (service.Status == newStatus) + { + throw new SpmsException( + ErrorCodes.NoChange, + "이미 해당 상태입니다.", + 400); + } + + service.Status = newStatus; + service.UpdatedAt = DateTime.UtcNow; + + _serviceRepository.Update(service); + await _unitOfWork.SaveChangesAsync(); + + return MapToDto(service); + } + + private static ServiceSummaryDto MapToSummaryDto(Service service) + { + return new ServiceSummaryDto + { + ServiceCode = service.ServiceCode, + ServiceName = service.ServiceName, + Description = service.Description, + SubTier = service.SubTier.ToString(), + Status = service.Status.ToString(), + CreatedAt = service.CreatedAt, + DeviceCount = service.Devices?.Count ?? 0 + }; + } + + private static ServiceResponseDto MapToDto(Service service) + { + return new ServiceResponseDto + { + ServiceCode = service.ServiceCode, + ServiceName = service.ServiceName, + Description = service.Description, + ApiKey = service.ApiKey, + ApiKeyCreatedAt = service.ApiKeyCreatedAt, + ApnsBundleId = service.ApnsBundleId, + ApnsKeyId = service.ApnsKeyId, + ApnsTeamId = service.ApnsTeamId, + HasApnsKey = !string.IsNullOrEmpty(service.ApnsPrivateKey), + HasFcmCredentials = !string.IsNullOrEmpty(service.FcmCredentials), + WebhookUrl = service.WebhookUrl, + Tags = service.Tags, + SubTier = service.SubTier.ToString(), + SubStartedAt = service.SubStartedAt, + Status = service.Status.ToString(), + CreatedAt = service.CreatedAt, + CreatedByName = service.CreatedByAdmin?.Name ?? string.Empty, + UpdatedAt = service.UpdatedAt, + DeviceCount = service.Devices?.Count ?? 0, + AllowedIps = service.ServiceIps?.Select(ip => ip.IpAddress).ToList() ?? new List() + }; + } +}