diff --git a/SPMS.API/Controllers/ServiceController.cs b/SPMS.API/Controllers/ServiceController.cs index e9869ba..309c982 100644 --- a/SPMS.API/Controllers/ServiceController.cs +++ b/SPMS.API/Controllers/ServiceController.cs @@ -33,6 +33,23 @@ public class ServiceController : ControllerBase return Ok(ApiResponse.Success(result)); } + [HttpPost("register")] + [SwaggerOperation( + Summary = "서비스 통합 등록", + Description = "서비스 생성과 플랫폼 자격증명 등록을 단일 호출로 완료합니다. FCM/APNs 자격증명은 선택사항이며, 제공 시 검증 실패하면 전체 롤백됩니다.")] + [SwaggerResponse(200, "등록 성공", typeof(ApiResponse))] + [SwaggerResponse(400, "잘못된 요청")] + [SwaggerResponse(409, "서비스명 중복")] + public async Task RegisterAsync([FromBody] RegisterServiceRequestDto request) + { + var adminIdClaim = User.FindFirst("adminId")?.Value; + if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId)) + throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다."); + + var result = await _serviceManagementService.RegisterAsync(request, adminId); + return Ok(ApiResponse.Success(result)); + } + [HttpPost("create")] [SwaggerOperation( Summary = "서비스 등록", diff --git a/SPMS.Application/DTOs/Service/RegisterServiceRequestDto.cs b/SPMS.Application/DTOs/Service/RegisterServiceRequestDto.cs new file mode 100644 index 0000000..cba3df2 --- /dev/null +++ b/SPMS.Application/DTOs/Service/RegisterServiceRequestDto.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Service; + +public class RegisterServiceRequestDto +{ + [Required(ErrorMessage = "서비스명은 필수입니다.")] + [StringLength(100, MinimumLength = 2, ErrorMessage = "서비스명은 2~100자여야 합니다.")] + public string ServiceName { get; set; } = string.Empty; + + [StringLength(500, ErrorMessage = "설명은 500자 이내여야 합니다.")] + public string? Description { get; set; } + + /// + /// FCM 자격증명 (선택) + /// + public FcmCredentialDto? Fcm { get; set; } + + /// + /// APNs 자격증명 (선택) + /// + public ApnsCredentialDto? Apns { get; set; } +} + +public class FcmCredentialDto +{ + [Required(ErrorMessage = "Service Account JSON은 필수입니다.")] + public string ServiceAccountJson { get; set; } = string.Empty; +} + +public class ApnsCredentialDto +{ + [Required(ErrorMessage = "Bundle ID는 필수입니다.")] + public string BundleId { get; set; } = string.Empty; + + [Required(ErrorMessage = "Key ID는 필수입니다.")] + [StringLength(10, MinimumLength = 10, ErrorMessage = "Key ID는 10자리여야 합니다.")] + public string KeyId { get; set; } = string.Empty; + + [Required(ErrorMessage = "Team ID는 필수입니다.")] + [StringLength(10, MinimumLength = 10, ErrorMessage = "Team ID는 10자리여야 합니다.")] + public string TeamId { get; set; } = string.Empty; + + [Required(ErrorMessage = "Private Key는 필수입니다.")] + public string PrivateKey { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/Service/RegisterServiceResponseDto.cs b/SPMS.Application/DTOs/Service/RegisterServiceResponseDto.cs new file mode 100644 index 0000000..bdc9f5c --- /dev/null +++ b/SPMS.Application/DTOs/Service/RegisterServiceResponseDto.cs @@ -0,0 +1,23 @@ +namespace SPMS.Application.DTOs.Service; + +public class RegisterServiceResponseDto +{ + public string ServiceCode { get; set; } = string.Empty; + public string ServiceName { get; set; } = string.Empty; + public string ApiKey { get; set; } = string.Empty; + public DateTime ApiKeyCreatedAt { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public PlatformResultDto Platforms { get; set; } = new(); +} + +public class PlatformResultDto +{ + public PlatformStatusDto? Android { get; set; } + public PlatformStatusDto? Ios { get; set; } +} + +public class PlatformStatusDto +{ + public bool Registered { get; set; } +} diff --git a/SPMS.Application/Interfaces/IServiceManagementService.cs b/SPMS.Application/Interfaces/IServiceManagementService.cs index b6a195c..ce47ee2 100644 --- a/SPMS.Application/Interfaces/IServiceManagementService.cs +++ b/SPMS.Application/Interfaces/IServiceManagementService.cs @@ -6,6 +6,7 @@ public interface IServiceManagementService { Task CheckServiceNameAsync(ServiceNameCheckRequestDto request); Task CreateAsync(CreateServiceRequestDto request, long adminId); + Task RegisterAsync(RegisterServiceRequestDto request, long adminId); Task UpdateAsync(UpdateServiceRequestDto request); Task GetListAsync(ServiceListRequestDto request); Task GetByServiceCodeAsync(string serviceCode); diff --git a/SPMS.Application/Services/ServiceManagementService.cs b/SPMS.Application/Services/ServiceManagementService.cs index d53636f..306990d 100644 --- a/SPMS.Application/Services/ServiceManagementService.cs +++ b/SPMS.Application/Services/ServiceManagementService.cs @@ -347,14 +347,7 @@ public class ServiceManagementService : IServiceManagementService 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); - } + ValidateApnsCredentials(request.PrivateKey); // Encrypt and store service.ApnsBundleId = request.BundleId; @@ -378,10 +371,111 @@ public class ServiceManagementService : IServiceManagementService 404); } - // Validate JSON format and required fields + 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); + + if (request.Apns != null) + ValidateApnsCredentials(request.Apns.PrivateKey); + + // 3. 트랜잭션 시작 + using var transaction = await _unitOfWork.BeginTransactionAsync(); try { - var jsonDoc = JsonDocument.Parse(request.ServiceAccountJson); + // 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.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 _) || @@ -401,13 +495,17 @@ public class ServiceManagementService : IServiceManagementService "유효하지 않은 JSON 형식입니다.", 400); } + } - // Encrypt and store - service.FcmCredentials = _credentialEncryptionService.Encrypt(request.ServiceAccountJson); - service.UpdatedAt = DateTime.UtcNow; - - _serviceRepository.Update(service); - await _unitOfWork.SaveChangesAsync(); + private static void ValidateApnsCredentials(string privateKey) + { + if (!privateKey.Contains("BEGIN PRIVATE KEY") || !privateKey.Contains("END PRIVATE KEY")) + { + throw new SpmsException( + ErrorCodes.InvalidCredentials, + "유효하지 않은 APNs Private Key 형식입니다. .p8 파일 내용을 입력해주세요.", + 400); + } } public async Task GetCredentialsAsync(string serviceCode)