using System.Linq.Expressions; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; 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 CheckServiceNameAsync(ServiceNameCheckRequestDto request) { var exists = await _serviceRepository.ServiceNameExistsAsync(request.ServiceName); return new ServiceNameCheckResponseDto { ServiceName = request.ServiceName, IsAvailable = !exists }; } public async Task 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 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; } // Status 변경 if (request.Status.HasValue) { var newStatus = (ServiceStatus)request.Status.Value; if (service.Status != newStatus) { service.Status = newStatus; 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 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 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 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 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 ViewApiKeyAsync(string serviceCode) { var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode); if (service is null) { throw new SpmsException( ErrorCodes.NotFound, "서비스를 찾을 수 없습니다.", 404); } return new ApiKeyRefreshResponseDto { ServiceCode = service.ServiceCode, ApiKey = service.ApiKey, ApiKeyCreatedAt = service.ApiKeyCreatedAt }; } public async Task 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); } service.ApnsBundleId = request.BundleId; service.ApnsAuthType = request.AuthType; if (request.AuthType == "p12") { // p12 필수 필드 검증 if (string.IsNullOrEmpty(request.CertificateBase64)) throw new SpmsException(ErrorCodes.BadRequest, "p12 인증서(CertificateBase64)는 필수입니다.", 400); if (string.IsNullOrEmpty(request.CertPassword)) throw new SpmsException(ErrorCodes.BadRequest, "p12 비밀번호(CertPassword)는 필수입니다.", 400); var expiresAt = ValidateApnsP12Credentials(request.CertificateBase64, request.CertPassword); service.ApnsCertificate = _credentialEncryptionService.Encrypt(request.CertificateBase64); service.ApnsCertPassword = _credentialEncryptionService.Encrypt(request.CertPassword); service.ApnsCertExpiresAt = expiresAt; // p8 필드 초기화 service.ApnsKeyId = null; service.ApnsTeamId = null; service.ApnsPrivateKey = null; } else { // p8 필수 필드 검증 if (string.IsNullOrEmpty(request.KeyId)) throw new SpmsException(ErrorCodes.BadRequest, "Key ID는 필수입니다.", 400); if (string.IsNullOrEmpty(request.TeamId)) throw new SpmsException(ErrorCodes.BadRequest, "Team ID는 필수입니다.", 400); if (string.IsNullOrEmpty(request.PrivateKey)) throw new SpmsException(ErrorCodes.BadRequest, "Private Key는 필수입니다.", 400); ValidateApnsP8Credentials(request.PrivateKey); service.ApnsKeyId = request.KeyId; service.ApnsTeamId = request.TeamId; service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.PrivateKey); // p12 필드 초기화 service.ApnsCertificate = null; service.ApnsCertPassword = null; service.ApnsCertExpiresAt = null; } 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); } ValidateFcmCredentials(request.ServiceAccountJson); // Encrypt and store service.FcmCredentials = _credentialEncryptionService.Encrypt(request.ServiceAccountJson); service.UpdatedAt = DateTime.UtcNow; _serviceRepository.Update(service); await _unitOfWork.SaveChangesAsync(); } public async Task RegisterAsync(RegisterServiceRequestDto request, long adminId) { // 1. 서비스명 중복 검사 var nameExists = await _serviceRepository.ServiceNameExistsAsync(request.ServiceName); if (nameExists) { throw new SpmsException( ErrorCodes.ServiceNameDuplicate, "이미 존재하는 서비스명입니다.", 409); } // 2. 자격증명 검증 (트랜잭션 전 빠른 실패) if (request.Fcm != null) ValidateFcmCredentials(request.Fcm.ServiceAccountJson); DateTime? apnsCertExpiresAt = null; if (request.Apns != null) { if (request.Apns.AuthType == "p12") { if (string.IsNullOrEmpty(request.Apns.CertificateBase64)) throw new SpmsException(ErrorCodes.BadRequest, "p12 인증서(CertificateBase64)는 필수입니다.", 400); if (string.IsNullOrEmpty(request.Apns.CertPassword)) throw new SpmsException(ErrorCodes.BadRequest, "p12 비밀번호(CertPassword)는 필수입니다.", 400); apnsCertExpiresAt = ValidateApnsP12Credentials(request.Apns.CertificateBase64, request.Apns.CertPassword); } else { if (string.IsNullOrEmpty(request.Apns.KeyId)) throw new SpmsException(ErrorCodes.BadRequest, "Key ID는 필수입니다.", 400); if (string.IsNullOrEmpty(request.Apns.TeamId)) throw new SpmsException(ErrorCodes.BadRequest, "Team ID는 필수입니다.", 400); if (string.IsNullOrEmpty(request.Apns.PrivateKey)) throw new SpmsException(ErrorCodes.BadRequest, "Private Key는 필수입니다.", 400); ValidateApnsP8Credentials(request.Apns.PrivateKey); } } // 3. 트랜잭션 시작 using var transaction = await _unitOfWork.BeginTransactionAsync(); try { // 4. 서비스 생성 var serviceCode = await GenerateUniqueServiceCodeAsync(); 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 }; // 5. 자격증명 저장 (검증 통과한 것만) if (request.Fcm != null) { service.FcmCredentials = _credentialEncryptionService.Encrypt(request.Fcm.ServiceAccountJson); } if (request.Apns != null) { service.ApnsBundleId = request.Apns.BundleId; service.ApnsAuthType = request.Apns.AuthType; if (request.Apns.AuthType == "p12") { service.ApnsCertificate = _credentialEncryptionService.Encrypt(request.Apns.CertificateBase64!); service.ApnsCertPassword = _credentialEncryptionService.Encrypt(request.Apns.CertPassword!); service.ApnsCertExpiresAt = apnsCertExpiresAt; } else { service.ApnsKeyId = request.Apns.KeyId; service.ApnsTeamId = request.Apns.TeamId; service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.Apns.PrivateKey!); } } await _serviceRepository.AddAsync(service); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitTransactionAsync(); // 6. 응답 반환 return new RegisterServiceResponseDto { ServiceCode = service.ServiceCode, ServiceName = service.ServiceName, ApiKey = apiKey, ApiKeyCreatedAt = service.ApiKeyCreatedAt, Status = service.Status.ToString(), CreatedAt = service.CreatedAt, Platforms = new PlatformResultDto { Android = request.Fcm != null ? new PlatformStatusDto { Registered = true } : null, Ios = request.Apns != null ? new PlatformStatusDto { Registered = true } : null } }; } catch { await _unitOfWork.RollbackTransactionAsync(); throw; } } private static void ValidateFcmCredentials(string serviceAccountJson) { try { var jsonDoc = JsonDocument.Parse(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); } } private static void ValidateApnsP8Credentials(string privateKey) { if (!privateKey.Contains("BEGIN PRIVATE KEY") || !privateKey.Contains("END PRIVATE KEY")) { throw new SpmsException( ErrorCodes.InvalidCredentials, "유효하지 않은 APNs Private Key 형식입니다. .p8 파일 내용을 입력해주세요.", 400); } } /// /// p12 인증서 Base64를 파싱하여 유효성 검증하고 만료일을 반환합니다. /// private static DateTime ValidateApnsP12Credentials(string certBase64, string certPassword) { byte[] certBytes; try { certBytes = Convert.FromBase64String(certBase64); } catch (FormatException) { throw new SpmsException( ErrorCodes.InvalidCredentials, "유효하지 않은 Base64 형식입니다. p12 인증서를 Base64로 인코딩하여 전송해주세요.", 400); } try { using var cert = X509CertificateLoader.LoadPkcs12(certBytes, certPassword); return cert.NotAfter; } catch (CryptographicException) { throw new SpmsException( ErrorCodes.InvalidCredentials, "p12 인증서를 읽을 수 없습니다. 인증서 파일 또는 비밀번호를 확인해주세요.", 400); } } public async Task 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.ApnsBundleId)) { var apnsInfo = new ApnsCredentialsInfoDto { BundleId = service.ApnsBundleId, AuthType = service.ApnsAuthType, KeyId = service.ApnsKeyId, TeamId = service.ApnsTeamId, HasPrivateKey = !string.IsNullOrEmpty(service.ApnsPrivateKey), HasCertificate = !string.IsNullOrEmpty(service.ApnsCertificate), CertExpiresAt = service.ApnsCertExpiresAt }; // 상태 진단 — BuildIosSummary 로직 재활용 var iosSummary = BuildIosSummary(service); if (iosSummary != null) { apnsInfo.CredentialStatus = iosSummary.CredentialStatus ?? "ok"; apnsInfo.StatusReason = iosSummary.StatusReason; } response.Apns = apnsInfo; } // 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, CredentialStatus = "ok" }; } return response; } public async Task DeleteApnsCredentialsAsync(string serviceCode) { var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode); if (service is null) { throw new SpmsException( ErrorCodes.NotFound, "서비스를 찾을 수 없습니다.", 404); } // APNs 자격증명이 없으면 에러 if (string.IsNullOrEmpty(service.ApnsBundleId)) { throw new SpmsException( ErrorCodes.NotFound, "삭제할 APNs 자격증명이 없습니다.", 404); } service.ApnsBundleId = null; service.ApnsKeyId = null; service.ApnsTeamId = null; service.ApnsPrivateKey = null; service.ApnsAuthType = null; service.ApnsCertificate = null; service.ApnsCertPassword = null; service.ApnsCertExpiresAt = null; service.UpdatedAt = DateTime.UtcNow; _serviceRepository.Update(service); await _unitOfWork.SaveChangesAsync(); } public async Task DeleteFcmCredentialsAsync(string serviceCode) { var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode); if (service is null) { throw new SpmsException( ErrorCodes.NotFound, "서비스를 찾을 수 없습니다.", 404); } // FCM 자격증명이 없으면 에러 if (string.IsNullOrEmpty(service.FcmCredentials)) { throw new SpmsException( ErrorCodes.NotFound, "삭제할 FCM 자격증명이 없습니다.", 404); } service.FcmCredentials = null; service.UpdatedAt = DateTime.UtcNow; _serviceRepository.Update(service); await _unitOfWork.SaveChangesAsync(); } public async Task 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 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(); if (!string.IsNullOrEmpty(service.Tags)) { try { tagNames = JsonSerializer.Deserialize>(service.Tags) ?? new List(); } 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 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 { "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 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(); if (!string.IsNullOrEmpty(service.WebhookEvents)) { try { events = JsonSerializer.Deserialize>(service.WebhookEvents) ?? new(); } catch { /* ignore */ } } return new WebhookConfigResponseDto { WebhookUrl = service.WebhookUrl, Events = events, IsActive = !string.IsNullOrEmpty(service.WebhookUrl) && events.Count > 0 }; } public async Task 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 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, Platforms = BuildPlatformSummary(service) }; } private static ServiceResponseDto MapToDto(Service service) { return new ServiceResponseDto { ServiceCode = service.ServiceCode, ServiceName = service.ServiceName, Description = service.Description, ApiKey = MaskApiKey(service.ApiKey), ApiKeyCreatedAt = service.ApiKeyCreatedAt, ApnsBundleId = service.ApnsBundleId, ApnsKeyId = service.ApnsKeyId, ApnsTeamId = service.ApnsTeamId, ApnsAuthType = service.ApnsAuthType, HasApnsKey = !string.IsNullOrEmpty(service.ApnsPrivateKey), HasFcmCredentials = !string.IsNullOrEmpty(service.FcmCredentials), Platforms = BuildPlatformSummary(service), 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() }; } /// /// API Key를 마스킹합니다 (앞 8자 노출 + ********). /// private static string MaskApiKey(string apiKey) { if (string.IsNullOrEmpty(apiKey)) return string.Empty; if (apiKey.Length <= 8) return new string('*', apiKey.Length); return apiKey[..8] + "********"; } /// /// 서비스의 플랫폼 자격증명 상태를 판정하여 PlatformSummaryDto를 반환합니다. /// Android: FcmCredentials 유무로 판정 /// iOS: ApnsAuthType(p8/p12)에 따라 만료 상태 포함 판정 /// private static PlatformSummaryDto? BuildPlatformSummary(Service service) { var android = BuildAndroidSummary(service); var ios = BuildIosSummary(service); // 양쪽 다 null이면 null 반환 if (android == null && ios == null) return null; return new PlatformSummaryDto { Android = android, Ios = ios }; } private static PlatformCredentialSummaryDto? BuildAndroidSummary(Service service) { if (string.IsNullOrEmpty(service.FcmCredentials)) return null; return new PlatformCredentialSummaryDto { Registered = true, CredentialStatus = "ok" }; } private static PlatformCredentialSummaryDto? BuildIosSummary(Service service) { // APNs 미등록 if (string.IsNullOrEmpty(service.ApnsBundleId)) return null; var summary = new PlatformCredentialSummaryDto { Registered = true }; if (service.ApnsAuthType == "p12") { var now = DateTime.UtcNow; if (service.ApnsCertExpiresAt.HasValue) { if (service.ApnsCertExpiresAt.Value < now) { summary.CredentialStatus = "error"; summary.StatusReason = "p12 인증서가 만료되었습니다."; } else if (service.ApnsCertExpiresAt.Value < now.AddDays(30)) { summary.CredentialStatus = "warn"; summary.StatusReason = "p12 인증서 만료가 30일 이내입니다."; } else { summary.CredentialStatus = "ok"; } summary.ExpiresAt = service.ApnsCertExpiresAt; } else { // p12인데 만료일 정보 없음 (비정상) summary.CredentialStatus = "warn"; summary.StatusReason = "p12 인증서 만료일 정보가 없습니다."; } } else { // p8 또는 레거시(AuthType null + PrivateKey 존재) summary.CredentialStatus = "ok"; } return summary; } }