feat: IP 화이트리스트 관리 API 구현 (#50) #51

Merged
seonkyu.kim merged 1 commits from feature/#50-ip-whitelist into develop 2026-02-09 15:55:02 +00:00
8 changed files with 198 additions and 0 deletions
Showing only changes of commit c8a3d616c3 - Show all commits

View File

@ -125,4 +125,52 @@ public class ServiceController : ControllerBase
var result = await _serviceManagementService.GetCredentialsAsync(serviceCode);
return Ok(ApiResponse<CredentialsResponseDto>.Success(result));
}
[HttpPost("{serviceCode}/ip/list")]
[SwaggerOperation(
Summary = "IP 화이트리스트 조회",
Description = "서비스에 등록된 IP 화이트리스트 목록을 조회합니다.")]
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<IpListResponseDto>))]
[SwaggerResponse(401, "인증되지 않은 요청")]
[SwaggerResponse(403, "권한 없음")]
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
public async Task<IActionResult> GetIpListAsync([FromRoute] string serviceCode)
{
var result = await _serviceManagementService.GetIpListAsync(serviceCode);
return Ok(ApiResponse<IpListResponseDto>.Success(result));
}
[HttpPost("{serviceCode}/ip/add")]
[SwaggerOperation(
Summary = "IP 추가",
Description = "서비스의 IP 화이트리스트에 새 IP를 추가합니다. IPv4 형식만 지원합니다.")]
[SwaggerResponse(200, "추가 성공", typeof(ApiResponse<ServiceIpDto>))]
[SwaggerResponse(400, "잘못된 요청 (유효하지 않은 IP 형식)")]
[SwaggerResponse(401, "인증되지 않은 요청")]
[SwaggerResponse(403, "권한 없음")]
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
[SwaggerResponse(409, "이미 등록된 IP")]
public async Task<IActionResult> AddIpAsync(
[FromRoute] string serviceCode,
[FromBody] AddIpRequestDto request)
{
var result = await _serviceManagementService.AddIpAsync(serviceCode, request);
return Ok(ApiResponse<ServiceIpDto>.Success(result));
}
[HttpPost("{serviceCode}/ip/delete")]
[SwaggerOperation(
Summary = "IP 삭제",
Description = "서비스의 IP 화이트리스트에서 IP를 삭제합니다.")]
[SwaggerResponse(200, "삭제 성공", typeof(ApiResponse))]
[SwaggerResponse(401, "인증되지 않은 요청")]
[SwaggerResponse(403, "권한 없음")]
[SwaggerResponse(404, "서비스 또는 IP를 찾을 수 없음")]
public async Task<IActionResult> DeleteIpAsync(
[FromRoute] string serviceCode,
[FromBody] DeleteIpRequestDto request)
{
await _serviceManagementService.DeleteIpAsync(serviceCode, request);
return Ok(ApiResponse.Success());
}
}

View File

@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace SPMS.Application.DTOs.Service;
public class AddIpRequestDto
{
[Required(ErrorMessage = "IP 주소는 필수입니다.")]
[RegularExpression(
@"^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$",
ErrorMessage = "유효한 IPv4 주소 형식이 아닙니다.")]
public string IpAddress { get; set; } = string.Empty;
}

View File

@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace SPMS.Application.DTOs.Service;
public class DeleteIpRequestDto
{
[Required(ErrorMessage = "IP ID는 필수입니다.")]
public long IpId { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace SPMS.Application.DTOs.Service;
public class IpListResponseDto
{
public string ServiceCode { get; set; } = string.Empty;
public List<ServiceIpDto> Items { get; set; } = new();
public int TotalCount { get; set; }
}
public class ServiceIpDto
{
public long Id { get; set; }
public string IpAddress { get; set; } = string.Empty;
}

View File

@ -11,4 +11,9 @@ public interface IServiceManagementService
Task RegisterApnsCredentialsAsync(string serviceCode, ApnsCredentialsRequestDto request);
Task RegisterFcmCredentialsAsync(string serviceCode, FcmCredentialsRequestDto request);
Task<CredentialsResponseDto> GetCredentialsAsync(string serviceCode);
// IP Whitelist
Task<IpListResponseDto> GetIpListAsync(string serviceCode);
Task<ServiceIpDto> AddIpAsync(string serviceCode, AddIpRequestDto request);
Task DeleteIpAsync(string serviceCode, DeleteIpRequestDto request);
}

View File

@ -279,6 +279,90 @@ public class ServiceManagementService : IServiceManagementService
return response;
}
public async Task<IpListResponseDto> GetIpListAsync(string serviceCode)
{
var service = await _serviceRepository.GetByServiceCodeWithIpsAsync(serviceCode);
if (service is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"서비스를 찾을 수 없습니다.",
404);
}
return new IpListResponseDto
{
ServiceCode = service.ServiceCode,
Items = service.ServiceIps.Select(ip => new ServiceIpDto
{
Id = ip.Id,
IpAddress = ip.IpAddress
}).ToList(),
TotalCount = service.ServiceIps.Count
};
}
public async Task<ServiceIpDto> AddIpAsync(string serviceCode, AddIpRequestDto request)
{
var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode);
if (service is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"서비스를 찾을 수 없습니다.",
404);
}
// Check for duplicate IP
var exists = await _serviceRepository.ServiceIpExistsAsync(service.Id, request.IpAddress);
if (exists)
{
throw new SpmsException(
ErrorCodes.Conflict,
"이미 등록된 IP 주소입니다.",
409);
}
var serviceIp = new ServiceIp
{
ServiceId = service.Id,
IpAddress = request.IpAddress
};
await _serviceRepository.AddServiceIpAsync(serviceIp);
await _unitOfWork.SaveChangesAsync();
return new ServiceIpDto
{
Id = serviceIp.Id,
IpAddress = serviceIp.IpAddress
};
}
public async Task DeleteIpAsync(string serviceCode, DeleteIpRequestDto request)
{
var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode);
if (service is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"서비스를 찾을 수 없습니다.",
404);
}
var serviceIp = await _serviceRepository.GetServiceIpByIdAsync(request.IpId);
if (serviceIp is null || serviceIp.ServiceId != service.Id)
{
throw new SpmsException(
ErrorCodes.NotFound,
"IP 주소를 찾을 수 없습니다.",
404);
}
_serviceRepository.DeleteServiceIp(serviceIp);
await _unitOfWork.SaveChangesAsync();
}
private static ServiceSummaryDto MapToSummaryDto(Service service)
{
return new ServiceSummaryDto

View File

@ -8,5 +8,12 @@ public interface IServiceRepository : IRepository<Service>
Task<Service?> GetByServiceCodeAsync(string serviceCode);
Task<Service?> GetByApiKeyAsync(string apiKey);
Task<Service?> GetByIdWithIpsAsync(long id);
Task<Service?> GetByServiceCodeWithIpsAsync(string serviceCode);
Task<IReadOnlyList<Service>> GetByStatusAsync(ServiceStatus status);
// ServiceIp methods
Task<ServiceIp?> GetServiceIpByIdAsync(long ipId);
Task<bool> ServiceIpExistsAsync(long serviceId, string ipAddress);
Task AddServiceIpAsync(ServiceIp serviceIp);
void DeleteServiceIp(ServiceIp serviceIp);
}

View File

@ -20,6 +20,25 @@ public class ServiceRepository : Repository<Service>, IServiceRepository
.Include(s => s.ServiceIps)
.FirstOrDefaultAsync(s => s.Id == id);
public async Task<Service?> GetByServiceCodeWithIpsAsync(string serviceCode)
=> await _dbSet
.Include(s => s.ServiceIps)
.FirstOrDefaultAsync(s => s.ServiceCode == serviceCode);
public async Task<IReadOnlyList<Service>> GetByStatusAsync(ServiceStatus status)
=> await _dbSet.Where(s => s.Status == status).ToListAsync();
// ServiceIp methods
public async Task<ServiceIp?> GetServiceIpByIdAsync(long ipId)
=> await _context.Set<ServiceIp>().FirstOrDefaultAsync(ip => ip.Id == ipId);
public async Task<bool> ServiceIpExistsAsync(long serviceId, string ipAddress)
=> await _context.Set<ServiceIp>()
.AnyAsync(ip => ip.ServiceId == serviceId && ip.IpAddress == ipAddress);
public async Task AddServiceIpAsync(ServiceIp serviceIp)
=> await _context.Set<ServiceIp>().AddAsync(serviceIp);
public void DeleteServiceIp(ServiceIp serviceIp)
=> _context.Set<ServiceIp>().Remove(serviceIp);
}