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 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); } 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); } // 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 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 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 }; } 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() }; } }