improvement: 서비스 통합 등록 플로우 구현 (#212) #213
|
|
@ -33,6 +33,23 @@ public class ServiceController : ControllerBase
|
||||||
return Ok(ApiResponse<ServiceNameCheckResponseDto>.Success(result));
|
return Ok(ApiResponse<ServiceNameCheckResponseDto>.Success(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("register")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "서비스 통합 등록",
|
||||||
|
Description = "서비스 생성과 플랫폼 자격증명 등록을 단일 호출로 완료합니다. FCM/APNs 자격증명은 선택사항이며, 제공 시 검증 실패하면 전체 롤백됩니다.")]
|
||||||
|
[SwaggerResponse(200, "등록 성공", typeof(ApiResponse<RegisterServiceResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청")]
|
||||||
|
[SwaggerResponse(409, "서비스명 중복")]
|
||||||
|
public async Task<IActionResult> 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<RegisterServiceResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("create")]
|
[HttpPost("create")]
|
||||||
[SwaggerOperation(
|
[SwaggerOperation(
|
||||||
Summary = "서비스 등록",
|
Summary = "서비스 등록",
|
||||||
|
|
|
||||||
46
SPMS.Application/DTOs/Service/RegisterServiceRequestDto.cs
Normal file
46
SPMS.Application/DTOs/Service/RegisterServiceRequestDto.cs
Normal file
|
|
@ -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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FCM 자격증명 (선택)
|
||||||
|
/// </summary>
|
||||||
|
public FcmCredentialDto? Fcm { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// APNs 자격증명 (선택)
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
23
SPMS.Application/DTOs/Service/RegisterServiceResponseDto.cs
Normal file
23
SPMS.Application/DTOs/Service/RegisterServiceResponseDto.cs
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ public interface IServiceManagementService
|
||||||
{
|
{
|
||||||
Task<ServiceNameCheckResponseDto> CheckServiceNameAsync(ServiceNameCheckRequestDto request);
|
Task<ServiceNameCheckResponseDto> CheckServiceNameAsync(ServiceNameCheckRequestDto request);
|
||||||
Task<CreateServiceResponseDto> CreateAsync(CreateServiceRequestDto request, long adminId);
|
Task<CreateServiceResponseDto> CreateAsync(CreateServiceRequestDto request, long adminId);
|
||||||
|
Task<RegisterServiceResponseDto> RegisterAsync(RegisterServiceRequestDto request, long adminId);
|
||||||
Task<ServiceResponseDto> UpdateAsync(UpdateServiceRequestDto request);
|
Task<ServiceResponseDto> UpdateAsync(UpdateServiceRequestDto request);
|
||||||
Task<ServiceListResponseDto> GetListAsync(ServiceListRequestDto request);
|
Task<ServiceListResponseDto> GetListAsync(ServiceListRequestDto request);
|
||||||
Task<ServiceResponseDto> GetByServiceCodeAsync(string serviceCode);
|
Task<ServiceResponseDto> GetByServiceCodeAsync(string serviceCode);
|
||||||
|
|
|
||||||
|
|
@ -347,14 +347,7 @@ public class ServiceManagementService : IServiceManagementService
|
||||||
404);
|
404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate .p8 private key format
|
ValidateApnsCredentials(request.PrivateKey);
|
||||||
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
|
// Encrypt and store
|
||||||
service.ApnsBundleId = request.BundleId;
|
service.ApnsBundleId = request.BundleId;
|
||||||
|
|
@ -378,10 +371,111 @@ public class ServiceManagementService : IServiceManagementService
|
||||||
404);
|
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<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);
|
||||||
|
|
||||||
|
if (request.Apns != null)
|
||||||
|
ValidateApnsCredentials(request.Apns.PrivateKey);
|
||||||
|
|
||||||
|
// 3. 트랜잭션 시작
|
||||||
|
using var transaction = await _unitOfWork.BeginTransactionAsync();
|
||||||
try
|
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;
|
var root = jsonDoc.RootElement;
|
||||||
|
|
||||||
if (!root.TryGetProperty("project_id", out _) ||
|
if (!root.TryGetProperty("project_id", out _) ||
|
||||||
|
|
@ -401,13 +495,17 @@ public class ServiceManagementService : IServiceManagementService
|
||||||
"유효하지 않은 JSON 형식입니다.",
|
"유효하지 않은 JSON 형식입니다.",
|
||||||
400);
|
400);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Encrypt and store
|
private static void ValidateApnsCredentials(string privateKey)
|
||||||
service.FcmCredentials = _credentialEncryptionService.Encrypt(request.ServiceAccountJson);
|
{
|
||||||
service.UpdatedAt = DateTime.UtcNow;
|
if (!privateKey.Contains("BEGIN PRIVATE KEY") || !privateKey.Contains("END PRIVATE KEY"))
|
||||||
|
{
|
||||||
_serviceRepository.Update(service);
|
throw new SpmsException(
|
||||||
await _unitOfWork.SaveChangesAsync();
|
ErrorCodes.InvalidCredentials,
|
||||||
|
"유효하지 않은 APNs Private Key 형식입니다. .p8 파일 내용을 입력해주세요.",
|
||||||
|
400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CredentialsResponseDto> GetCredentialsAsync(string serviceCode)
|
public async Task<CredentialsResponseDto> GetCredentialsAsync(string serviceCode)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user