- APNs 키 등록 API (POST /v1/in/service/{serviceCode}/apns)
- FCM 키 등록 API (POST /v1/in/service/{serviceCode}/fcm)
- 키 정보 조회 API (POST /v1/in/service/{serviceCode}/credentials)
- AES-256 암호화로 민감 정보 저장
- 조회 시 메타 정보만 반환 (Private Key 미노출)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
323 lines
11 KiB
C#
323 lines
11 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<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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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>()
|
|
};
|
|
}
|
|
}
|