improvement: 서비스 통합 등록 플로우 구현 (#212) #213

Merged
seonkyu.kim merged 1 commits from improvement/#212-service-register-flow into develop 2026-02-25 03:34:33 +00:00
5 changed files with 201 additions and 16 deletions
Showing only changes of commit 4916488175 - Show all commits

View File

@ -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 = "서비스 등록",

View 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;
}

View 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; }
}

View File

@ -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);

View File

@ -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)