- TagSummaryDto에 tag_index 필드 추가 (서비스별 Id 순서 1-based 동적 계산) - ServiceSummaryDto/ServiceResponseDto에 service_id 필드 추가 - ServiceCodeMiddleware OPTIONAL_FOR_ADMIN에 /v1/in/tag 경로 추가 Closes #267
1124 lines
38 KiB
C#
1124 lines
38 KiB
C#
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<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;
|
|
}
|
|
|
|
// 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<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> 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<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);
|
|
}
|
|
|
|
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<RegisterServiceResponseDto> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// p12 인증서 Base64를 파싱하여 유효성 검증하고 만료일을 반환합니다.
|
|
/// </summary>
|
|
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<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.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<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
|
|
{
|
|
ServiceId = service.Id,
|
|
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
|
|
{
|
|
ServiceId = service.Id,
|
|
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<string>()
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// API Key를 마스킹합니다 (앞 8자 노출 + ********).
|
|
/// </summary>
|
|
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] + "********";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 서비스의 플랫폼 자격증명 상태를 판정하여 PlatformSummaryDto를 반환합니다.
|
|
/// Android: FcmCredentials 유무로 판정
|
|
/// iOS: ApnsAuthType(p8/p12)에 따라 만료 상태 포함 판정
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|