SPMS_API/SPMS.Application/Services/ServiceManagementService.cs
SEAN 4577d8c10d improvement: 서비스명 중복 확인 API 및 전용 에러코드 추가 (#210)
- POST /v1/in/service/name/check 엔드포인트 추가
- ServiceNameDuplicate(134) 에러코드 추가
- CreateAsync/UpdateAsync 서비스명 중복 에러코드 변경
- CreateServiceRequestDto MinimumLength=2 검증 추가

Closes #210
2026-02-25 12:15:28 +09:00

716 lines
23 KiB
C#

using System.Linq.Expressions;
using System.Security.Cryptography;
using System.Text.Json;
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;
private readonly ICredentialEncryptionService _credentialEncryptionService;
public ServiceManagementService(
IServiceRepository serviceRepository,
IUnitOfWork unitOfWork,
ICredentialEncryptionService credentialEncryptionService)
{
_serviceRepository = serviceRepository;
_unitOfWork = unitOfWork;
_credentialEncryptionService = credentialEncryptionService;
}
public async Task<ServiceNameCheckResponseDto> CheckServiceNameAsync(ServiceNameCheckRequestDto request)
{
var exists = await _serviceRepository.ServiceNameExistsAsync(request.ServiceName);
return new ServiceNameCheckResponseDto
{
ServiceName = request.ServiceName,
IsAvailable = !exists
};
}
public async Task<CreateServiceResponseDto> CreateAsync(CreateServiceRequestDto request, long adminId)
{
// 서비스명 중복 검사
var nameExists = await _serviceRepository.ServiceNameExistsAsync(request.ServiceName);
if (nameExists)
{
throw new SpmsException(
ErrorCodes.ServiceNameDuplicate,
"이미 존재하는 서비스명입니다.",
409);
}
// ServiceCode 생성 (NanoID 8자리)
var serviceCode = await GenerateUniqueServiceCodeAsync();
// API Key 생성 (48자 = 36바이트 Base64)
var randomBytes = new byte[36];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(randomBytes);
}
var apiKey = Convert.ToBase64String(randomBytes);
var now = DateTime.UtcNow;
var service = new Service
{
ServiceCode = serviceCode,
ServiceName = request.ServiceName,
Description = request.Description,
ApiKey = apiKey,
ApiKeyCreatedAt = now,
SubTier = SubTier.Free,
Status = ServiceStatus.Active,
CreatedAt = now,
CreatedBy = adminId,
IsDeleted = false
};
await _serviceRepository.AddAsync(service);
await _unitOfWork.SaveChangesAsync();
return new CreateServiceResponseDto
{
ServiceCode = service.ServiceCode,
ApiKey = apiKey,
ApiKeyCreatedAt = service.ApiKeyCreatedAt
};
}
public async Task<ServiceResponseDto> UpdateAsync(UpdateServiceRequestDto request)
{
var service = await _serviceRepository.GetByServiceCodeAsync(request.ServiceCode);
if (service is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"서비스를 찾을 수 없습니다.",
404);
}
var hasChanges = false;
// ServiceName 변경
if (request.ServiceName != null && request.ServiceName != service.ServiceName)
{
var nameExists = await _serviceRepository.ServiceNameExistsAsync(request.ServiceName);
if (nameExists)
{
throw new SpmsException(
ErrorCodes.ServiceNameDuplicate,
"이미 존재하는 서비스명입니다.",
409);
}
service.ServiceName = request.ServiceName;
hasChanges = true;
}
// Description 변경
if (request.Description != null && request.Description != service.Description)
{
service.Description = request.Description;
hasChanges = true;
}
// WebhookUrl 변경
if (request.WebhookUrl != null && request.WebhookUrl != service.WebhookUrl)
{
service.WebhookUrl = request.WebhookUrl;
hasChanges = true;
}
// Tags 변경
if (request.Tags != null && request.Tags != service.Tags)
{
service.Tags = request.Tags;
hasChanges = true;
}
if (!hasChanges)
{
throw new SpmsException(
ErrorCodes.NoChange,
"변경된 내용이 없습니다.",
400);
}
service.UpdatedAt = DateTime.UtcNow;
_serviceRepository.Update(service);
await _unitOfWork.SaveChangesAsync();
var serviceWithIps = await _serviceRepository.GetByIdWithIpsAsync(service.Id);
return MapToDto(serviceWithIps ?? service);
}
private async Task<string> GenerateUniqueServiceCodeAsync()
{
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const int codeLength = 8;
var buffer = new byte[codeLength];
for (var attempt = 0; attempt < 10; attempt++)
{
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(buffer);
}
var code = new char[codeLength];
for (var i = 0; i < codeLength; i++)
{
code[i] = alphabet[buffer[i] % alphabet.Length];
}
var serviceCode = new string(code);
var exists = await _serviceRepository.ServiceCodeExistsAsync(serviceCode);
if (!exists)
return serviceCode;
}
throw new SpmsException(
ErrorCodes.InternalError,
"서비스 코드 생성에 실패했습니다. 다시 시도해주세요.",
500);
}
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 DeleteAsync(DeleteServiceRequestDto request)
{
var service = await _serviceRepository.GetByServiceCodeAsync(request.ServiceCode);
if (service is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"서비스를 찾을 수 없습니다.",
404);
}
if (service.IsDeleted)
{
throw new SpmsException(
ErrorCodes.Conflict,
"이미 삭제된 서비스입니다.",
409);
}
service.IsDeleted = true;
service.DeletedAt = DateTime.UtcNow;
service.Status = ServiceStatus.Suspended;
service.UpdatedAt = DateTime.UtcNow;
_serviceRepository.Update(service);
await _unitOfWork.SaveChangesAsync();
}
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);
}
public async Task<ApiKeyRefreshResponseDto> RefreshApiKeyAsync(string serviceCode)
{
var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode);
if (service is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"서비스를 찾을 수 없습니다.",
404);
}
// 32~64자 랜덤 API Key 생성 (48자 = 36바이트 Base64)
var randomBytes = new byte[36];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(randomBytes);
}
var newApiKey = Convert.ToBase64String(randomBytes);
service.ApiKey = newApiKey;
service.ApiKeyCreatedAt = DateTime.UtcNow;
service.UpdatedAt = DateTime.UtcNow;
_serviceRepository.Update(service);
await _unitOfWork.SaveChangesAsync();
return new ApiKeyRefreshResponseDto
{
ServiceCode = service.ServiceCode,
ApiKey = newApiKey,
ApiKeyCreatedAt = service.ApiKeyCreatedAt
};
}
public async Task RegisterApnsCredentialsAsync(string serviceCode, ApnsCredentialsRequestDto request)
{
var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode);
if (service is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"서비스를 찾을 수 없습니다.",
404);
}
// Validate .p8 private key format
if (!request.PrivateKey.Contains("BEGIN PRIVATE KEY") || !request.PrivateKey.Contains("END PRIVATE KEY"))
{
throw new SpmsException(
ErrorCodes.InvalidCredentials,
"유효하지 않은 APNs Private Key 형식입니다. .p8 파일 내용을 입력해주세요.",
400);
}
// Encrypt and store
service.ApnsBundleId = request.BundleId;
service.ApnsKeyId = request.KeyId;
service.ApnsTeamId = request.TeamId;
service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.PrivateKey);
service.UpdatedAt = DateTime.UtcNow;
_serviceRepository.Update(service);
await _unitOfWork.SaveChangesAsync();
}
public async Task RegisterFcmCredentialsAsync(string serviceCode, FcmCredentialsRequestDto request)
{
var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode);
if (service is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"서비스를 찾을 수 없습니다.",
404);
}
// Validate JSON format and required fields
try
{
var jsonDoc = JsonDocument.Parse(request.ServiceAccountJson);
var root = jsonDoc.RootElement;
if (!root.TryGetProperty("project_id", out _) ||
!root.TryGetProperty("private_key", out _) ||
!root.TryGetProperty("client_email", out _))
{
throw new SpmsException(
ErrorCodes.InvalidCredentials,
"FCM Service Account JSON에 필수 필드(project_id, private_key, client_email)가 없습니다.",
400);
}
}
catch (JsonException)
{
throw new SpmsException(
ErrorCodes.InvalidCredentials,
"유효하지 않은 JSON 형식입니다.",
400);
}
// Encrypt and store
service.FcmCredentials = _credentialEncryptionService.Encrypt(request.ServiceAccountJson);
service.UpdatedAt = DateTime.UtcNow;
_serviceRepository.Update(service);
await _unitOfWork.SaveChangesAsync();
}
public async Task<CredentialsResponseDto> GetCredentialsAsync(string serviceCode)
{
var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode);
if (service is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"서비스를 찾을 수 없습니다.",
404);
}
var response = new CredentialsResponseDto();
// APNs info (meta only, no private key)
if (!string.IsNullOrEmpty(service.ApnsKeyId))
{
response.Apns = new ApnsCredentialsInfoDto
{
BundleId = service.ApnsBundleId ?? string.Empty,
KeyId = service.ApnsKeyId ?? string.Empty,
TeamId = service.ApnsTeamId ?? string.Empty,
HasPrivateKey = !string.IsNullOrEmpty(service.ApnsPrivateKey)
};
}
// FCM info (project_id only, no private key)
if (!string.IsNullOrEmpty(service.FcmCredentials))
{
var fcmJson = _credentialEncryptionService.Decrypt(service.FcmCredentials);
string? projectId = null;
try
{
var jsonDoc = JsonDocument.Parse(fcmJson);
if (jsonDoc.RootElement.TryGetProperty("project_id", out var projectIdElement))
{
projectId = projectIdElement.GetString();
}
}
catch
{
// Ignore parsing errors
}
response.Fcm = new FcmCredentialsInfoDto
{
ProjectId = projectId,
HasCredentials = true
};
}
return response;
}
public async Task<ServiceTagsResponseDto> GetTagsAsync(ServiceTagsRequestDto request)
{
var service = await _serviceRepository.GetByServiceCodeAsync(request.ServiceCode);
if (service is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"서비스를 찾을 수 없습니다.",
404);
}
return BuildTagsResponse(service);
}
public async Task<ServiceTagsResponseDto> UpdateTagsAsync(UpdateServiceTagsRequestDto request)
{
var service = await _serviceRepository.GetByServiceCodeAsync(request.ServiceCode);
if (service is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"서비스를 찾을 수 없습니다.",
404);
}
var newTagsJson = JsonSerializer.Serialize(request.Tags);
if (newTagsJson == service.Tags)
{
throw new SpmsException(
ErrorCodes.NoChange,
"변경된 내용이 없습니다.",
400);
}
service.Tags = newTagsJson;
service.UpdatedAt = DateTime.UtcNow;
_serviceRepository.Update(service);
await _unitOfWork.SaveChangesAsync();
return BuildTagsResponse(service);
}
private static ServiceTagsResponseDto BuildTagsResponse(Service service)
{
var tagNames = new List<string>();
if (!string.IsNullOrEmpty(service.Tags))
{
try
{
tagNames = JsonSerializer.Deserialize<List<string>>(service.Tags) ?? new List<string>();
}
catch
{
// Invalid JSON, return empty
}
}
return new ServiceTagsResponseDto
{
ServiceCode = service.ServiceCode,
Tags = tagNames.Select((name, index) => new TagItemDto
{
TagIndex = index,
TagName = name
}).ToList(),
TotalCount = tagNames.Count
};
}
public async Task<WebhookConfigResponseDto> ConfigureWebhookAsync(string serviceCode, WebhookConfigRequestDto request)
{
var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode);
if (service is null)
throw new SpmsException(ErrorCodes.NotFound, "서비스를 찾을 수 없습니다.", 404);
if (request.Events != null)
{
var validEvents = new HashSet<string> { "push_sent", "push_failed", "push_clicked" };
foreach (var evt in request.Events)
{
if (!validEvents.Contains(evt))
throw new SpmsException(ErrorCodes.BadRequest, $"유효하지 않은 이벤트 타입: {evt}", 400);
}
}
service.WebhookUrl = request.WebhookUrl;
service.WebhookEvents = request.Events != null && request.Events.Count > 0
? JsonSerializer.Serialize(request.Events)
: null;
service.UpdatedAt = DateTime.UtcNow;
_serviceRepository.Update(service);
await _unitOfWork.SaveChangesAsync();
return BuildWebhookConfigResponse(service);
}
public async Task<WebhookConfigResponseDto> GetWebhookConfigAsync(string serviceCode)
{
var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode);
if (service is null)
throw new SpmsException(ErrorCodes.NotFound, "서비스를 찾을 수 없습니다.", 404);
return BuildWebhookConfigResponse(service);
}
private static WebhookConfigResponseDto BuildWebhookConfigResponse(Service service)
{
var events = new List<string>();
if (!string.IsNullOrEmpty(service.WebhookEvents))
{
try { events = JsonSerializer.Deserialize<List<string>>(service.WebhookEvents) ?? new(); }
catch { /* ignore */ }
}
return new WebhookConfigResponseDto
{
WebhookUrl = service.WebhookUrl,
Events = events,
IsActive = !string.IsNullOrEmpty(service.WebhookUrl) && events.Count > 0
};
}
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
{
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>()
};
}
}