feat: 서비스 관리 API 구현 (#44)

- ServiceController: 서비스 목록/상세/상태변경 엔드포인트
- ServiceManagementService: 비즈니스 로직 구현
- Service DTOs: 요청/응답 DTO 4종
- DI 등록

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
seonkyu.kim 2026-02-10 00:01:33 +09:00
parent b58662b520
commit cac56761f4
8 changed files with 301 additions and 0 deletions

View File

@ -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<ServiceListResponseDto>))]
[SwaggerResponse(401, "인증되지 않은 요청")]
[SwaggerResponse(403, "권한 없음")]
public async Task<IActionResult> GetListAsync([FromBody] ServiceListRequestDto request)
{
var result = await _serviceManagementService.GetListAsync(request);
return Ok(ApiResponse<ServiceListResponseDto>.Success(result));
}
[HttpPost("{serviceCode}")]
[SwaggerOperation(
Summary = "서비스 상세 조회",
Description = "특정 서비스의 상세 정보를 조회합니다.")]
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<ServiceResponseDto>))]
[SwaggerResponse(401, "인증되지 않은 요청")]
[SwaggerResponse(403, "권한 없음")]
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
public async Task<IActionResult> GetByServiceCodeAsync([FromRoute] string serviceCode)
{
var result = await _serviceManagementService.GetByServiceCodeAsync(serviceCode);
return Ok(ApiResponse<ServiceResponseDto>.Success(result));
}
[HttpPost("{serviceCode}/status")]
[SwaggerOperation(
Summary = "서비스 상태 변경",
Description = "서비스의 상태를 변경합니다. (Active: 0, Suspended: 1)")]
[SwaggerResponse(200, "상태 변경 성공", typeof(ApiResponse<ServiceResponseDto>))]
[SwaggerResponse(400, "잘못된 요청 또는 이미 해당 상태")]
[SwaggerResponse(401, "인증되지 않은 요청")]
[SwaggerResponse(403, "권한 없음")]
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
public async Task<IActionResult> ChangeStatusAsync(
[FromRoute] string serviceCode,
[FromBody] ChangeServiceStatusRequestDto request)
{
var result = await _serviceManagementService.ChangeStatusAsync(serviceCode, request);
return Ok(ApiResponse<ServiceResponseDto>.Success(result));
}
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -0,0 +1,21 @@
namespace SPMS.Application.DTOs.Service;
public class ServiceListResponseDto
{
public List<ServiceSummaryDto> 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; }
}

View File

@ -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<string> AllowedIps { get; set; } = new();
}

View File

@ -11,6 +11,7 @@ public static class DependencyInjection
// Application Services
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<IServiceManagementService, ServiceManagementService>();
return services;
}

View File

@ -0,0 +1,10 @@
using SPMS.Application.DTOs.Service;
namespace SPMS.Application.Interfaces;
public interface IServiceManagementService
{
Task<ServiceListResponseDto> GetListAsync(ServiceListRequestDto request);
Task<ServiceResponseDto> GetByServiceCodeAsync(string serviceCode);
Task<ServiceResponseDto> ChangeStatusAsync(string serviceCode, ChangeServiceStatusRequestDto request);
}

View File

@ -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<ServiceListResponseDto> GetListAsync(ServiceListRequestDto request)
{
Expression<Func<Service, bool>>? 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<ServiceResponseDto> 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<ServiceResponseDto> 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<string>()
};
}
}